[local_auth] support localizedFallbackTitle in IOSAuthMessages (#3806)

Add support localizedFallbackTitle in IOSAuthMessages.
diff --git a/packages/local_auth/local_auth/CHANGELOG.md b/packages/local_auth/local_auth/CHANGELOG.md
index d17eb4a..9387540 100644
--- a/packages/local_auth/local_auth/CHANGELOG.md
+++ b/packages/local_auth/local_auth/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.1.11
+
+* Adds support `localizedFallbackTitle` in authenticateWithBiometrics on iOS.
+
 ## 1.1.10
 
 * Removes dependency on `meta`.
diff --git a/packages/local_auth/local_auth/example/ios/RunnerTests/FLTLocalAuthPluginTests.m b/packages/local_auth/local_auth/example/ios/RunnerTests/FLTLocalAuthPluginTests.m
index dc409da..3572524 100644
--- a/packages/local_auth/local_auth/example/ios/RunnerTests/FLTLocalAuthPluginTests.m
+++ b/packages/local_auth/local_auth/example/ios/RunnerTests/FLTLocalAuthPluginTests.m
@@ -186,4 +186,87 @@
   [self waitForExpectationsWithTimeout:kTimeout handler:nil];
 }
 
+- (void)testLocalizedFallbackTitle {
+  FLTLocalAuthPlugin *plugin = [[FLTLocalAuthPlugin alloc] init];
+  id mockAuthContext = OCMClassMock([LAContext class]);
+  plugin.authContextOverrides = @[ mockAuthContext ];
+
+  const LAPolicy policy = LAPolicyDeviceOwnerAuthentication;
+  NSString *reason = @"a reason";
+  NSString *localizedFallbackTitle = @"a title";
+  OCMStub([mockAuthContext canEvaluatePolicy:policy error:[OCMArg setTo:nil]]).andReturn(YES);
+
+  // evaluatePolicy:localizedReason:reply: calls back on an internal queue, which is not
+  // guaranteed to be on the main thread. Ensure that's handled correctly by calling back on
+  // a background thread.
+  void (^backgroundThreadReplyCaller)(NSInvocation *) = ^(NSInvocation *invocation) {
+    void (^reply)(BOOL, NSError *);
+    [invocation getArgument:&reply atIndex:4];
+    dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0), ^{
+      reply(NO, [NSError errorWithDomain:@"error" code:99 userInfo:nil]);
+    });
+  };
+  OCMStub([mockAuthContext evaluatePolicy:policy localizedReason:reason reply:[OCMArg any]])
+      .andDo(backgroundThreadReplyCaller);
+
+  FlutterMethodCall *call =
+      [FlutterMethodCall methodCallWithMethodName:@"authenticate"
+                                        arguments:@{
+                                          @"biometricOnly" : @(NO),
+                                          @"localizedReason" : reason,
+                                          @"localizedFallbackTitle" : localizedFallbackTitle,
+                                        }];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"Result is called"];
+  [plugin handleMethodCall:call
+                    result:^(id _Nullable result) {
+                      XCTAssertTrue([NSThread isMainThread]);
+                      XCTAssertTrue([result isKindOfClass:[NSNumber class]]);
+                      OCMVerify([mockAuthContext setLocalizedFallbackTitle:localizedFallbackTitle]);
+                      XCTAssertFalse([result boolValue]);
+                      [expectation fulfill];
+                    }];
+  [self waitForExpectationsWithTimeout:kTimeout handler:nil];
+}
+
+- (void)testSkippedLocalizedFallbackTitle {
+  FLTLocalAuthPlugin *plugin = [[FLTLocalAuthPlugin alloc] init];
+  id mockAuthContext = OCMClassMock([LAContext class]);
+  plugin.authContextOverrides = @[ mockAuthContext ];
+
+  const LAPolicy policy = LAPolicyDeviceOwnerAuthentication;
+  NSString *reason = @"a reason";
+  OCMStub([mockAuthContext canEvaluatePolicy:policy error:[OCMArg setTo:nil]]).andReturn(YES);
+
+  // evaluatePolicy:localizedReason:reply: calls back on an internal queue, which is not
+  // guaranteed to be on the main thread. Ensure that's handled correctly by calling back on
+  // a background thread.
+  void (^backgroundThreadReplyCaller)(NSInvocation *) = ^(NSInvocation *invocation) {
+    void (^reply)(BOOL, NSError *);
+    [invocation getArgument:&reply atIndex:4];
+    dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0), ^{
+      reply(NO, [NSError errorWithDomain:@"error" code:99 userInfo:nil]);
+    });
+  };
+  OCMStub([mockAuthContext evaluatePolicy:policy localizedReason:reason reply:[OCMArg any]])
+      .andDo(backgroundThreadReplyCaller);
+
+  FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"authenticate"
+                                                              arguments:@{
+                                                                @"biometricOnly" : @(NO),
+                                                                @"localizedReason" : reason,
+                                                              }];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"Result is called"];
+  [plugin handleMethodCall:call
+                    result:^(id _Nullable result) {
+                      XCTAssertTrue([NSThread isMainThread]);
+                      XCTAssertTrue([result isKindOfClass:[NSNumber class]]);
+                      OCMVerify([mockAuthContext setLocalizedFallbackTitle:nil]);
+                      XCTAssertFalse([result boolValue]);
+                      [expectation fulfill];
+                    }];
+  [self waitForExpectationsWithTimeout:kTimeout handler:nil];
+}
+
 @end
diff --git a/packages/local_auth/local_auth/ios/Classes/FLTLocalAuthPlugin.m b/packages/local_auth/local_auth/ios/Classes/FLTLocalAuthPlugin.m
index c2dc9db..70113ef 100644
--- a/packages/local_auth/local_auth/ios/Classes/FLTLocalAuthPlugin.m
+++ b/packages/local_auth/local_auth/ios/Classes/FLTLocalAuthPlugin.m
@@ -122,7 +122,9 @@
   NSError *authError = nil;
   self.lastCallArgs = nil;
   self.lastResult = nil;
-  context.localizedFallbackTitle = @"";
+  context.localizedFallbackTitle = arguments[@"localizedFallbackTitle"] == [NSNull null]
+                                       ? nil
+                                       : arguments[@"localizedFallbackTitle"];
 
   if ([context canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics
                            error:&authError]) {
@@ -146,7 +148,9 @@
   NSError *authError = nil;
   _lastCallArgs = nil;
   _lastResult = nil;
-  context.localizedFallbackTitle = @"";
+  context.localizedFallbackTitle = arguments[@"localizedFallbackTitle"] == [NSNull null]
+                                       ? nil
+                                       : arguments[@"localizedFallbackTitle"];
 
   if ([context canEvaluatePolicy:LAPolicyDeviceOwnerAuthentication error:&authError]) {
     [context evaluatePolicy:kLAPolicyDeviceOwnerAuthentication
@@ -176,6 +180,7 @@
       case LAErrorPasscodeNotSet:
       case LAErrorTouchIDNotAvailable:
       case LAErrorTouchIDNotEnrolled:
+      case LAErrorUserFallback:
       case LAErrorTouchIDLockout:
         [self handleErrors:error flutterArguments:arguments withFlutterResult:result];
         return;
diff --git a/packages/local_auth/local_auth/lib/auth_strings.dart b/packages/local_auth/local_auth/lib/auth_strings.dart
index 537340b..3e34659 100644
--- a/packages/local_auth/local_auth/lib/auth_strings.dart
+++ b/packages/local_auth/local_auth/lib/auth_strings.dart
@@ -68,12 +68,14 @@
     this.goToSettingsButton,
     this.goToSettingsDescription,
     this.cancelButton,
+    this.localizedFallbackTitle,
   });
 
   final String? lockOut;
   final String? goToSettingsButton;
   final String? goToSettingsDescription;
   final String? cancelButton;
+  final String? localizedFallbackTitle;
 
   Map<String, String> get args {
     return <String, String>{
@@ -82,6 +84,8 @@
       'goToSettingDescriptionIOS':
           goToSettingsDescription ?? iOSGoToSettingsDescription,
       'okButton': cancelButton ?? iOSOkButton,
+      if (localizedFallbackTitle != null)
+        'localizedFallbackTitle': localizedFallbackTitle!,
     };
   }
 }
diff --git a/packages/local_auth/local_auth/pubspec.yaml b/packages/local_auth/local_auth/pubspec.yaml
index cd9d0d9..78c79f4 100644
--- a/packages/local_auth/local_auth/pubspec.yaml
+++ b/packages/local_auth/local_auth/pubspec.yaml
@@ -3,7 +3,7 @@
   authentication via fingerprint, touch ID, face ID, passcode, pin, or pattern.
 repository: https://github.com/flutter/plugins/tree/main/packages/local_auth/local_auth
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+local_auth%22
-version: 1.1.10
+version: 1.1.11
 
 environment:
   sdk: ">=2.14.0 <3.0.0"
diff --git a/packages/local_auth/local_auth/test/local_auth_test.dart b/packages/local_auth/local_auth/test/local_auth_test.dart
index 758b9ce..3de9758 100644
--- a/packages/local_auth/local_auth/test/local_auth_test.dart
+++ b/packages/local_auth/local_auth/test/local_auth_test.dart
@@ -40,14 +40,28 @@
         expect(
           log,
           <Matcher>[
-            isMethodCall('authenticate',
-                arguments: <String, dynamic>{
-                  'localizedReason': 'Needs secure',
-                  'useErrorDialogs': true,
-                  'stickyAuth': false,
-                  'sensitiveTransaction': true,
-                  'biometricOnly': true,
-                }..addAll(const AndroidAuthMessages().args)),
+            isMethodCall(
+              'authenticate',
+              arguments: <String, dynamic>{
+                'localizedReason': 'Needs secure',
+                'useErrorDialogs': true,
+                'stickyAuth': false,
+                'sensitiveTransaction': true,
+                'biometricOnly': true,
+                'biometricHint': androidBiometricHint,
+                'biometricNotRecognized': androidBiometricNotRecognized,
+                'biometricSuccess': androidBiometricSuccess,
+                'biometricRequired': androidBiometricRequiredTitle,
+                'cancelButton': androidCancelButton,
+                'deviceCredentialsRequired':
+                    androidDeviceCredentialsRequiredTitle,
+                'deviceCredentialsSetupDescription':
+                    androidDeviceCredentialsSetupDescription,
+                'goToSetting': goToSettings,
+                'goToSettingDescription': androidGoToSettingsDescription,
+                'signInTitle': androidSignInTitle,
+              },
+            ),
           ],
         );
       });
@@ -61,14 +75,45 @@
         expect(
           log,
           <Matcher>[
-            isMethodCall('authenticate',
-                arguments: <String, dynamic>{
-                  'localizedReason': 'Needs secure',
-                  'useErrorDialogs': true,
-                  'stickyAuth': false,
-                  'sensitiveTransaction': true,
-                  'biometricOnly': true,
-                }..addAll(const IOSAuthMessages().args)),
+            isMethodCall('authenticate', arguments: <String, dynamic>{
+              'localizedReason': 'Needs secure',
+              'useErrorDialogs': true,
+              'stickyAuth': false,
+              'sensitiveTransaction': true,
+              'biometricOnly': true,
+              'lockOut': iOSLockOut,
+              'goToSetting': goToSettings,
+              'goToSettingDescriptionIOS': iOSGoToSettingsDescription,
+              'okButton': iOSOkButton,
+            }),
+          ],
+        );
+      });
+
+      test('authenticate with `localizedFallbackTitle` on iOS.', () async {
+        const IOSAuthMessages iosAuthMessages =
+            IOSAuthMessages(localizedFallbackTitle: 'Enter PIN');
+        setMockPathProviderPlatform(FakePlatform(operatingSystem: 'ios'));
+        await localAuthentication.authenticate(
+          localizedReason: 'Needs secure',
+          biometricOnly: true,
+          iOSAuthStrings: iosAuthMessages,
+        );
+        expect(
+          log,
+          <Matcher>[
+            isMethodCall('authenticate', arguments: <String, dynamic>{
+              'localizedReason': 'Needs secure',
+              'useErrorDialogs': true,
+              'stickyAuth': false,
+              'sensitiveTransaction': true,
+              'biometricOnly': true,
+              'lockOut': iOSLockOut,
+              'goToSetting': goToSettings,
+              'goToSettingDescriptionIOS': iOSGoToSettingsDescription,
+              'okButton': iOSOkButton,
+              'localizedFallbackTitle': 'Enter PIN',
+            }),
           ],
         );
       });
@@ -95,14 +140,25 @@
         expect(
           log,
           <Matcher>[
-            isMethodCall('authenticate',
-                arguments: <String, dynamic>{
-                  'localizedReason': 'Insecure',
-                  'useErrorDialogs': false,
-                  'stickyAuth': false,
-                  'sensitiveTransaction': false,
-                  'biometricOnly': true,
-                }..addAll(const AndroidAuthMessages().args)),
+            isMethodCall('authenticate', arguments: <String, dynamic>{
+              'localizedReason': 'Insecure',
+              'useErrorDialogs': false,
+              'stickyAuth': false,
+              'sensitiveTransaction': false,
+              'biometricOnly': true,
+              'biometricHint': androidBiometricHint,
+              'biometricNotRecognized': androidBiometricNotRecognized,
+              'biometricSuccess': androidBiometricSuccess,
+              'biometricRequired': androidBiometricRequiredTitle,
+              'cancelButton': androidCancelButton,
+              'deviceCredentialsRequired':
+                  androidDeviceCredentialsRequiredTitle,
+              'deviceCredentialsSetupDescription':
+                  androidDeviceCredentialsSetupDescription,
+              'goToSetting': goToSettings,
+              'goToSettingDescription': androidGoToSettingsDescription,
+              'signInTitle': androidSignInTitle,
+            }),
           ],
         );
       });
@@ -117,14 +173,25 @@
         expect(
           log,
           <Matcher>[
-            isMethodCall('authenticate',
-                arguments: <String, dynamic>{
-                  'localizedReason': 'Needs secure',
-                  'useErrorDialogs': true,
-                  'stickyAuth': false,
-                  'sensitiveTransaction': true,
-                  'biometricOnly': false,
-                }..addAll(const AndroidAuthMessages().args)),
+            isMethodCall('authenticate', arguments: <String, dynamic>{
+              'localizedReason': 'Needs secure',
+              'useErrorDialogs': true,
+              'stickyAuth': false,
+              'sensitiveTransaction': true,
+              'biometricOnly': false,
+              'biometricHint': androidBiometricHint,
+              'biometricNotRecognized': androidBiometricNotRecognized,
+              'biometricSuccess': androidBiometricSuccess,
+              'biometricRequired': androidBiometricRequiredTitle,
+              'cancelButton': androidCancelButton,
+              'deviceCredentialsRequired':
+                  androidDeviceCredentialsRequiredTitle,
+              'deviceCredentialsSetupDescription':
+                  androidDeviceCredentialsSetupDescription,
+              'goToSetting': goToSettings,
+              'goToSettingDescription': androidGoToSettingsDescription,
+              'signInTitle': androidSignInTitle,
+            }),
           ],
         );
       });
@@ -137,14 +204,17 @@
         expect(
           log,
           <Matcher>[
-            isMethodCall('authenticate',
-                arguments: <String, dynamic>{
-                  'localizedReason': 'Needs secure',
-                  'useErrorDialogs': true,
-                  'stickyAuth': false,
-                  'sensitiveTransaction': true,
-                  'biometricOnly': false,
-                }..addAll(const IOSAuthMessages().args)),
+            isMethodCall('authenticate', arguments: <String, dynamic>{
+              'localizedReason': 'Needs secure',
+              'useErrorDialogs': true,
+              'stickyAuth': false,
+              'sensitiveTransaction': true,
+              'biometricOnly': false,
+              'lockOut': iOSLockOut,
+              'goToSetting': goToSettings,
+              'goToSettingDescriptionIOS': iOSGoToSettingsDescription,
+              'okButton': iOSOkButton,
+            }),
           ],
         );
       });
@@ -159,14 +229,25 @@
         expect(
           log,
           <Matcher>[
-            isMethodCall('authenticate',
-                arguments: <String, dynamic>{
-                  'localizedReason': 'Insecure',
-                  'useErrorDialogs': false,
-                  'stickyAuth': false,
-                  'sensitiveTransaction': false,
-                  'biometricOnly': false,
-                }..addAll(const AndroidAuthMessages().args)),
+            isMethodCall('authenticate', arguments: <String, dynamic>{
+              'localizedReason': 'Insecure',
+              'useErrorDialogs': false,
+              'stickyAuth': false,
+              'sensitiveTransaction': false,
+              'biometricOnly': false,
+              'biometricHint': androidBiometricHint,
+              'biometricNotRecognized': androidBiometricNotRecognized,
+              'biometricSuccess': androidBiometricSuccess,
+              'biometricRequired': androidBiometricRequiredTitle,
+              'cancelButton': androidCancelButton,
+              'deviceCredentialsRequired':
+                  androidDeviceCredentialsRequiredTitle,
+              'deviceCredentialsSetupDescription':
+                  androidDeviceCredentialsSetupDescription,
+              'goToSetting': goToSettings,
+              'goToSettingDescription': androidGoToSettingsDescription,
+              'signInTitle': androidSignInTitle,
+            }),
           ],
         );
       });