diff --git a/packages/local_auth/local_auth/CHANGELOG.md b/packages/local_auth/local_auth/CHANGELOG.md index d17eb4a97a21..9387540d6795 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 dc409da9f57c..3572524d8991 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 @@ - (void)testFailedAuthWithoutBiometrics { [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 c2dc9db25fc8..70113efa00a0 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 @@ - (void)authenticateWithBiometrics:(NSDictionary *)arguments 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 @@ - (void)authenticate:(NSDictionary *)arguments withFlutterResult:(FlutterResult) 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 @@ - (void)handleAuthReplyWithSuccess:(BOOL)success 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 537340b79d4e..3e34659b8dad 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 @@ class IOSAuthMessages { this.goToSettingsButton, this.goToSettingsDescription, this.cancelButton, + this.localizedFallbackTitle, }); final String? lockOut; final String? goToSettingsButton; final String? goToSettingsDescription; final String? cancelButton; + final String? localizedFallbackTitle; Map get args { return { @@ -82,6 +84,8 @@ class IOSAuthMessages { '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 cd9d0d9760f4..78c79f4abce4 100644 --- a/packages/local_auth/local_auth/pubspec.yaml +++ b/packages/local_auth/local_auth/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Android and iOS devices to allow local 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 758b9cec0e97..3de9758f9d0c 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 @@ void main() { expect( log, [ - isMethodCall('authenticate', - arguments: { - 'localizedReason': 'Needs secure', - 'useErrorDialogs': true, - 'stickyAuth': false, - 'sensitiveTransaction': true, - 'biometricOnly': true, - }..addAll(const AndroidAuthMessages().args)), + isMethodCall( + 'authenticate', + arguments: { + '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 @@ void main() { expect( log, [ - isMethodCall('authenticate', - arguments: { - 'localizedReason': 'Needs secure', - 'useErrorDialogs': true, - 'stickyAuth': false, - 'sensitiveTransaction': true, - 'biometricOnly': true, - }..addAll(const IOSAuthMessages().args)), + isMethodCall('authenticate', arguments: { + '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, + [ + isMethodCall('authenticate', arguments: { + '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 @@ void main() { expect( log, [ - isMethodCall('authenticate', - arguments: { - 'localizedReason': 'Insecure', - 'useErrorDialogs': false, - 'stickyAuth': false, - 'sensitiveTransaction': false, - 'biometricOnly': true, - }..addAll(const AndroidAuthMessages().args)), + isMethodCall('authenticate', arguments: { + '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 @@ void main() { expect( log, [ - isMethodCall('authenticate', - arguments: { - 'localizedReason': 'Needs secure', - 'useErrorDialogs': true, - 'stickyAuth': false, - 'sensitiveTransaction': true, - 'biometricOnly': false, - }..addAll(const AndroidAuthMessages().args)), + isMethodCall('authenticate', arguments: { + '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 @@ void main() { expect( log, [ - isMethodCall('authenticate', - arguments: { - 'localizedReason': 'Needs secure', - 'useErrorDialogs': true, - 'stickyAuth': false, - 'sensitiveTransaction': true, - 'biometricOnly': false, - }..addAll(const IOSAuthMessages().args)), + isMethodCall('authenticate', arguments: { + 'localizedReason': 'Needs secure', + 'useErrorDialogs': true, + 'stickyAuth': false, + 'sensitiveTransaction': true, + 'biometricOnly': false, + 'lockOut': iOSLockOut, + 'goToSetting': goToSettings, + 'goToSettingDescriptionIOS': iOSGoToSettingsDescription, + 'okButton': iOSOkButton, + }), ], ); }); @@ -159,14 +229,25 @@ void main() { expect( log, [ - isMethodCall('authenticate', - arguments: { - 'localizedReason': 'Insecure', - 'useErrorDialogs': false, - 'stickyAuth': false, - 'sensitiveTransaction': false, - 'biometricOnly': false, - }..addAll(const AndroidAuthMessages().args)), + isMethodCall('authenticate', arguments: { + '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, + }), ], ); });