diff --git a/packages/local_auth/local_auth_ios/CHANGELOG.md b/packages/local_auth/local_auth_ios/CHANGELOG.md index 466aeb50ed6c..0fc716e19f25 100644 --- a/packages/local_auth/local_auth_ios/CHANGELOG.md +++ b/packages/local_auth/local_auth_ios/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.0.4 + +* Fixes `deviceSupportsBiometrics` to return true when biometric hardware + is available but not enrolled. + ## 1.0.3 * Adopts `Object.hash`. diff --git a/packages/local_auth/local_auth_ios/example/ios/Runner.xcodeproj/project.pbxproj b/packages/local_auth/local_auth_ios/example/ios/Runner.xcodeproj/project.pbxproj index f8b4056d135d..cbf16eef4060 100644 --- a/packages/local_auth/local_auth_ios/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/local_auth/local_auth_ios/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 50; objects = { /* Begin PBXBuildFile section */ diff --git a/packages/local_auth/local_auth_ios/example/ios/RunnerTests/FLTLocalAuthPluginTests.m b/packages/local_auth/local_auth_ios/example/ios/RunnerTests/FLTLocalAuthPluginTests.m index 744d9124f7b1..50dbb1a6907b 100644 --- a/packages/local_auth/local_auth_ios/example/ios/RunnerTests/FLTLocalAuthPluginTests.m +++ b/packages/local_auth/local_auth_ios/example/ios/RunnerTests/FLTLocalAuthPluginTests.m @@ -269,4 +269,209 @@ - (void)testSkippedLocalizedFallbackTitle { [self waitForExpectationsWithTimeout:kTimeout handler:nil]; } +- (void)testDeviceSupportsBiometrics_withEnrolledHardware { + FLTLocalAuthPlugin *plugin = [[FLTLocalAuthPlugin alloc] init]; + id mockAuthContext = OCMClassMock([LAContext class]); + plugin.authContextOverrides = @[ mockAuthContext ]; + + const LAPolicy policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics; + OCMStub([mockAuthContext canEvaluatePolicy:policy error:[OCMArg setTo:nil]]).andReturn(YES); + + FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"deviceSupportsBiometrics" + arguments:@{}]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Result is called"]; + [plugin handleMethodCall:call + result:^(id _Nullable result) { + XCTAssertTrue([NSThread isMainThread]); + XCTAssertTrue([result isKindOfClass:[NSNumber class]]); + XCTAssertTrue([result boolValue]); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:kTimeout handler:nil]; +} + +- (void)testDeviceSupportsBiometrics_withNonEnrolledHardware_iOS11 { + if (@available(iOS 11, *)) { + FLTLocalAuthPlugin *plugin = [[FLTLocalAuthPlugin alloc] init]; + id mockAuthContext = OCMClassMock([LAContext class]); + plugin.authContextOverrides = @[ mockAuthContext ]; + + const LAPolicy policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics; + void (^canEvaluatePolicyHandler)(NSInvocation *) = ^(NSInvocation *invocation) { + // Write error + NSError *__autoreleasing *authError; + [invocation getArgument:&authError atIndex:3]; + *authError = [NSError errorWithDomain:@"error" code:LAErrorBiometryNotEnrolled userInfo:nil]; + // Write return value + BOOL returnValue = NO; + NSValue *nsReturnValue = [NSValue valueWithBytes:&returnValue objCType:@encode(BOOL)]; + [invocation setReturnValue:&nsReturnValue]; + }; + OCMStub([mockAuthContext canEvaluatePolicy:policy + error:(NSError * __autoreleasing *)[OCMArg anyPointer]]) + .andDo(canEvaluatePolicyHandler); + + FlutterMethodCall *call = + [FlutterMethodCall methodCallWithMethodName:@"deviceSupportsBiometrics" arguments:@{}]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Result is called"]; + [plugin handleMethodCall:call + result:^(id _Nullable result) { + XCTAssertTrue([NSThread isMainThread]); + XCTAssertTrue([result isKindOfClass:[NSNumber class]]); + XCTAssertTrue([result boolValue]); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:kTimeout handler:nil]; + } +} + +- (void)testDeviceSupportsBiometrics_withNoBiometricHardware { + FLTLocalAuthPlugin *plugin = [[FLTLocalAuthPlugin alloc] init]; + id mockAuthContext = OCMClassMock([LAContext class]); + plugin.authContextOverrides = @[ mockAuthContext ]; + + const LAPolicy policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics; + void (^canEvaluatePolicyHandler)(NSInvocation *) = ^(NSInvocation *invocation) { + // Write error + NSError *__autoreleasing *authError; + [invocation getArgument:&authError atIndex:3]; + *authError = [NSError errorWithDomain:@"error" code:0 userInfo:nil]; + // Write return value + BOOL returnValue = NO; + NSValue *nsReturnValue = [NSValue valueWithBytes:&returnValue objCType:@encode(BOOL)]; + [invocation setReturnValue:&nsReturnValue]; + }; + OCMStub([mockAuthContext canEvaluatePolicy:policy + error:(NSError * __autoreleasing *)[OCMArg anyPointer]]) + .andDo(canEvaluatePolicyHandler); + + FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"deviceSupportsBiometrics" + arguments:@{}]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Result is called"]; + [plugin handleMethodCall:call + result:^(id _Nullable result) { + XCTAssertTrue([NSThread isMainThread]); + XCTAssertTrue([result isKindOfClass:[NSNumber class]]); + XCTAssertFalse([result boolValue]); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:kTimeout handler:nil]; +} + +- (void)testGetEnrolledBiometrics_withFaceID_iOS11 { + if (@available(iOS 11, *)) { + FLTLocalAuthPlugin *plugin = [[FLTLocalAuthPlugin alloc] init]; + id mockAuthContext = OCMClassMock([LAContext class]); + plugin.authContextOverrides = @[ mockAuthContext ]; + + const LAPolicy policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics; + OCMStub([mockAuthContext canEvaluatePolicy:policy error:[OCMArg setTo:nil]]).andReturn(YES); + OCMStub([mockAuthContext biometryType]).andReturn(LABiometryTypeFaceID); + + FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"getEnrolledBiometrics" + arguments:@{}]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Result is called"]; + [plugin handleMethodCall:call + result:^(id _Nullable result) { + XCTAssertTrue([NSThread isMainThread]); + XCTAssertTrue([result isKindOfClass:[NSArray class]]); + XCTAssertEqual([result count], 1); + XCTAssertEqualObjects(result[0], @"face"); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:kTimeout handler:nil]; + } +} + +- (void)testGetEnrolledBiometrics_withTouchID_iOS11 { + if (@available(iOS 11, *)) { + FLTLocalAuthPlugin *plugin = [[FLTLocalAuthPlugin alloc] init]; + id mockAuthContext = OCMClassMock([LAContext class]); + plugin.authContextOverrides = @[ mockAuthContext ]; + + const LAPolicy policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics; + OCMStub([mockAuthContext canEvaluatePolicy:policy error:[OCMArg setTo:nil]]).andReturn(YES); + OCMStub([mockAuthContext biometryType]).andReturn(LABiometryTypeTouchID); + + FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"getEnrolledBiometrics" + arguments:@{}]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Result is called"]; + [plugin handleMethodCall:call + result:^(id _Nullable result) { + XCTAssertTrue([NSThread isMainThread]); + XCTAssertTrue([result isKindOfClass:[NSArray class]]); + XCTAssertEqual([result count], 1); + XCTAssertEqualObjects(result[0], @"fingerprint"); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:kTimeout handler:nil]; + } +} + +- (void)testGetEnrolledBiometrics_withTouchID_preIOS11 { + if (@available(iOS 11, *)) { + return; + } + FLTLocalAuthPlugin *plugin = [[FLTLocalAuthPlugin alloc] init]; + id mockAuthContext = OCMClassMock([LAContext class]); + plugin.authContextOverrides = @[ mockAuthContext ]; + + const LAPolicy policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics; + OCMStub([mockAuthContext canEvaluatePolicy:policy error:[OCMArg setTo:nil]]).andReturn(YES); + + FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"getEnrolledBiometrics" + arguments:@{}]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Result is called"]; + [plugin handleMethodCall:call + result:^(id _Nullable result) { + XCTAssertTrue([NSThread isMainThread]); + XCTAssertTrue([result isKindOfClass:[NSArray class]]); + XCTAssertEqual([result count], 1); + XCTAssertEqualObjects(result[0], @"fingerprint"); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:kTimeout handler:nil]; +} + +- (void)testGetEnrolledBiometrics_withoutEnrolledHardware_iOS11 { + if (@available(iOS 11, *)) { + FLTLocalAuthPlugin *plugin = [[FLTLocalAuthPlugin alloc] init]; + id mockAuthContext = OCMClassMock([LAContext class]); + plugin.authContextOverrides = @[ mockAuthContext ]; + + const LAPolicy policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics; + void (^canEvaluatePolicyHandler)(NSInvocation *) = ^(NSInvocation *invocation) { + // Write error + NSError *__autoreleasing *authError; + [invocation getArgument:&authError atIndex:3]; + *authError = [NSError errorWithDomain:@"error" code:LAErrorBiometryNotEnrolled userInfo:nil]; + // Write return value + BOOL returnValue = NO; + NSValue *nsReturnValue = [NSValue valueWithBytes:&returnValue objCType:@encode(BOOL)]; + [invocation setReturnValue:&nsReturnValue]; + }; + OCMStub([mockAuthContext canEvaluatePolicy:policy + error:(NSError * __autoreleasing *)[OCMArg anyPointer]]) + .andDo(canEvaluatePolicyHandler); + + FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"getEnrolledBiometrics" + arguments:@{}]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Result is called"]; + [plugin handleMethodCall:call + result:^(id _Nullable result) { + XCTAssertTrue([NSThread isMainThread]); + XCTAssertTrue([result isKindOfClass:[NSArray class]]); + XCTAssertEqual([result count], 0); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:kTimeout handler:nil]; + } +} @end diff --git a/packages/local_auth/local_auth_ios/example/lib/main.dart b/packages/local_auth/local_auth_ios/example/lib/main.dart index 9346be10fd78..c966d67ec9c6 100644 --- a/packages/local_auth/local_auth_ios/example/lib/main.dart +++ b/packages/local_auth/local_auth_ios/example/lib/main.dart @@ -25,7 +25,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { _SupportState _supportState = _SupportState.unknown; bool? _canCheckBiometrics; - List? _availableBiometrics; + List? _enrolledBiometrics; String _authorized = 'Not Authorized'; bool _isAuthenticating = false; @@ -40,12 +40,12 @@ class _MyAppState extends State { } Future _checkBiometrics() async { - late bool canCheckBiometrics; + late bool deviceSupportsBiometrics; try { - canCheckBiometrics = - (await LocalAuthPlatform.instance.getEnrolledBiometrics()).isNotEmpty; + deviceSupportsBiometrics = + await LocalAuthPlatform.instance.deviceSupportsBiometrics(); } on PlatformException catch (e) { - canCheckBiometrics = false; + deviceSupportsBiometrics = false; print(e); } if (!mounted) { @@ -53,17 +53,17 @@ class _MyAppState extends State { } setState(() { - _canCheckBiometrics = canCheckBiometrics; + _canCheckBiometrics = deviceSupportsBiometrics; }); } Future _getEnrolledBiometrics() async { - late List availableBiometrics; + late List enrolledBiometrics; try { - availableBiometrics = + enrolledBiometrics = await LocalAuthPlatform.instance.getEnrolledBiometrics(); } on PlatformException catch (e) { - availableBiometrics = []; + enrolledBiometrics = []; print(e); } if (!mounted) { @@ -71,7 +71,7 @@ class _MyAppState extends State { } setState(() { - _availableBiometrics = availableBiometrics; + _enrolledBiometrics = enrolledBiometrics; }); } @@ -173,15 +173,15 @@ class _MyAppState extends State { else const Text('This device is not supported'), const Divider(height: 100), - Text('Can check biometrics: $_canCheckBiometrics\n'), + Text('Device supports biometrics: $_canCheckBiometrics\n'), ElevatedButton( child: const Text('Check biometrics'), onPressed: _checkBiometrics, ), const Divider(height: 100), - Text('Available biometrics: $_availableBiometrics\n'), + Text('Enrolled biometrics: $_enrolledBiometrics\n'), ElevatedButton( - child: const Text('Get available biometrics'), + child: const Text('Get enrolled biometrics'), onPressed: _getEnrolledBiometrics, ), const Divider(height: 100), diff --git a/packages/local_auth/local_auth_ios/ios/Classes/FLTLocalAuthPlugin.m b/packages/local_auth/local_auth_ios/ios/Classes/FLTLocalAuthPlugin.m index ffd1dd2a869d..eb7f637f7850 100644 --- a/packages/local_auth/local_auth_ios/ios/Classes/FLTLocalAuthPlugin.m +++ b/packages/local_auth/local_auth_ios/ios/Classes/FLTLocalAuthPlugin.m @@ -35,8 +35,10 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } else { [self authenticate:call.arguments withFlutterResult:result]; } - } else if ([@"getAvailableBiometrics" isEqualToString:call.method]) { - [self getAvailableBiometrics:result]; + } else if ([@"getEnrolledBiometrics" isEqualToString:call.method]) { + [self getEnrolledBiometrics:result]; + } else if ([@"deviceSupportsBiometrics" isEqualToString:call.method]) { + [self deviceSupportsBiometrics:result]; } else if ([@"isDeviceSupported" isEqualToString:call.method]) { result(@YES); } else { @@ -93,14 +95,41 @@ - (void)alertMessage:(NSString *)message completion:nil]; } -- (void)getAvailableBiometrics:(FlutterResult)result { +- (void)deviceSupportsBiometrics:(FlutterResult)result { + LAContext *context = self.createAuthContext; + NSError *authError = nil; + // Check if authentication with biometrics is possible. + if ([context canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics + error:&authError]) { + if (authError == nil) { + result(@YES); + return; + } + } + // If not, check if it is because no biometrics are enrolled (but still present). + if (authError != nil) { + if (@available(iOS 11, *)) { + if (authError.code == LAErrorBiometryNotEnrolled) { + result(@YES); + return; + } + } else if (authError.code == LAErrorTouchIDNotEnrolled) { + result(@YES); + return; + } + } + + result(@NO); +} + +- (void)getEnrolledBiometrics:(FlutterResult)result { LAContext *context = self.createAuthContext; NSError *authError = nil; NSMutableArray *biometrics = [[NSMutableArray alloc] init]; if ([context canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&authError]) { if (authError == nil) { - if (@available(iOS 11.0.1, *)) { + if (@available(iOS 11, *)) { if (context.biometryType == LABiometryTypeFaceID) { [biometrics addObject:@"face"]; } else if (context.biometryType == LABiometryTypeTouchID) { @@ -110,8 +139,6 @@ - (void)getAvailableBiometrics:(FlutterResult)result { [biometrics addObject:@"fingerprint"]; } } - } else if (authError.code == LAErrorTouchIDNotEnrolled) { - [biometrics addObject:@"undefined"]; } result(biometrics); } diff --git a/packages/local_auth/local_auth_ios/lib/local_auth_ios.dart b/packages/local_auth/local_auth_ios/lib/local_auth_ios.dart index d92d076d79fb..ae25bd50ef5e 100644 --- a/packages/local_auth/local_auth_ios/lib/local_auth_ios.dart +++ b/packages/local_auth/local_auth_ios/lib/local_auth_ios.dart @@ -49,13 +49,14 @@ class LocalAuthIOS extends LocalAuthPlatform { @override Future deviceSupportsBiometrics() async { - return (await getEnrolledBiometrics()).isNotEmpty; + return (await _channel.invokeMethod('deviceSupportsBiometrics')) ?? + false; } @override Future> getEnrolledBiometrics() async { final List result = (await _channel.invokeListMethod( - 'getAvailableBiometrics', + 'getEnrolledBiometrics', )) ?? []; final List biometrics = []; @@ -70,8 +71,6 @@ class LocalAuthIOS extends LocalAuthPlatform { case 'iris': biometrics.add(BiometricType.iris); break; - case 'undefined': - break; } } return biometrics; diff --git a/packages/local_auth/local_auth_ios/pubspec.yaml b/packages/local_auth/local_auth_ios/pubspec.yaml index 06d972a01522..77ab74d383c8 100644 --- a/packages/local_auth/local_auth_ios/pubspec.yaml +++ b/packages/local_auth/local_auth_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: local_auth_ios description: iOS implementation of the local_auth plugin. repository: https://github.com/flutter/plugins/tree/master/packages/local_auth/local_auth_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+local_auth%22 -version: 1.0.3 +version: 1.0.4 environment: sdk: ">=2.14.0 <3.0.0" diff --git a/packages/local_auth/local_auth_ios/test/local_auth_test.dart b/packages/local_auth/local_auth_ios/test/local_auth_test.dart index aa94b8aa48b8..192d69ca8ec4 100644 --- a/packages/local_auth/local_auth_ios/test/local_auth_test.dart +++ b/packages/local_auth/local_auth_ios/test/local_auth_test.dart @@ -24,7 +24,7 @@ void main() { channel.setMockMethodCallHandler((MethodCall methodCall) { log.add(methodCall); switch (methodCall.method) { - case 'getAvailableBiometrics': + case 'getEnrolledBiometrics': return Future>.value( ['face', 'fingerprint', 'iris', 'undefined']); default: @@ -35,13 +35,13 @@ void main() { log.clear(); }); - test('deviceSupportsBiometrics calls getEnrolledBiometrics', () async { + test('deviceSupportsBiometrics calls platform', () async { final bool result = await localAuthentication.deviceSupportsBiometrics(); expect( log, [ - isMethodCall('getAvailableBiometrics', arguments: null), + isMethodCall('deviceSupportsBiometrics', arguments: null), ], ); expect(result, true); @@ -54,7 +54,7 @@ void main() { expect( log, [ - isMethodCall('getAvailableBiometrics', arguments: null), + isMethodCall('getEnrolledBiometrics', arguments: null), ], ); expect(result, [