diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index d101f60cf041..9af23011ec50 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.6 + +* Adds audio access permission handling logic on iOS to fix an issue with `prepareForVideoRecording` not awaiting for the audio permission request result. + ## 0.9.5+1 * Suppresses warnings for pre-iOS-11 codepaths. diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md index 6b2ed7a6b687..ec9d7379c60b 100644 --- a/packages/camera/camera/README.md +++ b/packages/camera/camera/README.md @@ -88,10 +88,16 @@ Here is a list of all permission error codes that can be thrown: - `CameraAccessDenied`: Thrown when user denies the camera access permission. -- `CameraAccessDeniedWithoutPrompt`: iOS only for now. Thrown when user has previously denied the permission. iOS does not allow prompting alert dialog a second time. Users will have to go to Settings > Privacy in order to enable camera access. +- `CameraAccessDeniedWithoutPrompt`: iOS only for now. Thrown when user has previously denied the permission. iOS does not allow prompting alert dialog a second time. Users will have to go to Settings > Privacy > Camera in order to enable camera access. - `CameraAccessRestricted`: iOS only for now. Thrown when camera access is restricted and users cannot grant permission (parental control). +- `AudioAccessDenied`: Thrown when user denies the audio access permission. + +- `AudioAccessDeniedWithoutPrompt`: iOS only for now. Thrown when user has previously denied the permission. iOS does not allow prompting alert dialog a second time. Users will have to go to Settings > Privacy > Microphone in order to enable audio access. + +- `AudioAccessRestricted`: iOS only for now. Thrown when audio access is restricted and users cannot grant permission (parental control). + - `cameraPermission`: Android and Web only. A legacy error code for all kinds of camera permission errors. ### Example diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraPermissionTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraPermissionTests.m index 961b931b7704..541e0288254c 100644 --- a/packages/camera/camera/example/ios/RunnerTests/CameraPermissionTests.m +++ b/packages/camera/camera/example/ios/RunnerTests/CameraPermissionTests.m @@ -15,6 +15,8 @@ @interface CameraPermissionTests : XCTestCase @implementation CameraPermissionTests +#pragma mark - camera permissions + - (void)testRequestCameraPermission_completeWithoutErrorIfPrevoiuslyAuthorized { XCTestExpectation *expectation = [self expectationWithDescription: @@ -120,4 +122,110 @@ - (void)testRequestCameraPermission_completeWithErrorIfUserDenyAccess { [self waitForExpectationsWithTimeout:1 handler:nil]; } +#pragma mark - audio permissions + +- (void)testRequestAudioPermission_completeWithoutErrorIfPrevoiuslyAuthorized { + XCTestExpectation *expectation = + [self expectationWithDescription: + @"Must copmlete without error if audio access was previously authorized."]; + + id mockDevice = OCMClassMock([AVCaptureDevice class]); + OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeAudio]) + .andReturn(AVAuthorizationStatusAuthorized); + + FLTRequestAudioPermissionWithCompletionHandler(^(FlutterError *error) { + if (error == nil) { + [expectation fulfill]; + } + }); + [self waitForExpectationsWithTimeout:1 handler:nil]; +} +- (void)testRequestAudioPermission_completeWithErrorIfPreviouslyDenied { + XCTestExpectation *expectation = + [self expectationWithDescription: + @"Must complete with error if audio access was previously denied."]; + FlutterError *expectedError = + [FlutterError errorWithCode:@"AudioAccessDeniedWithoutPrompt" + message:@"User has previously denied the audio access request. Go to " + @"Settings to enable audio access." + details:nil]; + + id mockDevice = OCMClassMock([AVCaptureDevice class]); + OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeAudio]) + .andReturn(AVAuthorizationStatusDenied); + FLTRequestAudioPermissionWithCompletionHandler(^(FlutterError *error) { + if ([error isEqual:expectedError]) { + [expectation fulfill]; + } + }); + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testRequestAudioPermission_completeWithErrorIfRestricted { + XCTestExpectation *expectation = + [self expectationWithDescription:@"Must complete with error if audio access is restricted."]; + FlutterError *expectedError = [FlutterError errorWithCode:@"AudioAccessRestricted" + message:@"Audio access is restricted. " + details:nil]; + + id mockDevice = OCMClassMock([AVCaptureDevice class]); + OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeAudio]) + .andReturn(AVAuthorizationStatusRestricted); + + FLTRequestAudioPermissionWithCompletionHandler(^(FlutterError *error) { + if ([error isEqual:expectedError]) { + [expectation fulfill]; + } + }); + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testRequestAudioPermission_completeWithoutErrorIfUserGrantAccess { + XCTestExpectation *grantedExpectation = [self + expectationWithDescription:@"Must complete without error if user choose to grant access"]; + + id mockDevice = OCMClassMock([AVCaptureDevice class]); + OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeAudio]) + .andReturn(AVAuthorizationStatusNotDetermined); + // Mimic user choosing "allow" in permission dialog. + OCMStub([mockDevice requestAccessForMediaType:AVMediaTypeAudio + completionHandler:[OCMArg checkWithBlock:^BOOL(void (^block)(BOOL)) { + block(YES); + return YES; + }]]); + + FLTRequestAudioPermissionWithCompletionHandler(^(FlutterError *error) { + if (error == nil) { + [grantedExpectation fulfill]; + } + }); + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testRequestAudioPermission_completeWithErrorIfUserDenyAccess { + XCTestExpectation *expectation = + [self expectationWithDescription:@"Must complete with error if user choose to deny access"]; + FlutterError *expectedError = [FlutterError errorWithCode:@"AudioAccessDenied" + message:@"User denied the audio access request." + details:nil]; + + id mockDevice = OCMClassMock([AVCaptureDevice class]); + OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeAudio]) + .andReturn(AVAuthorizationStatusNotDetermined); + + // Mimic user choosing "deny" in permission dialog. + OCMStub([mockDevice requestAccessForMediaType:AVMediaTypeAudio + completionHandler:[OCMArg checkWithBlock:^BOOL(void (^block)(BOOL)) { + block(NO); + return YES; + }]]); + FLTRequestAudioPermissionWithCompletionHandler(^(FlutterError *error) { + if ([error isEqual:expectedError]) { + [expectation fulfill]; + } + }); + + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + @end diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 34942ba5aa77..c0181a5d36a1 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -697,6 +697,17 @@ class _CameraExampleHomeState extends State // iOS only showInSnackBar('Camera access is restricted.'); break; + case 'AudioAccessDenied': + showInSnackBar('You have denied audio access.'); + break; + case 'AudioAccessDeniedWithoutPrompt': + // iOS only + showInSnackBar('Please go to Settings app to enable audio access.'); + break; + case 'AudioAccessRestricted': + // iOS only + showInSnackBar('Audio access is restricted.'); + break; case 'cameraPermission': // Android & web only showInSnackBar('Unknown permission error.'); diff --git a/packages/camera/camera/ios/Classes/CameraPermissionUtils.h b/packages/camera/camera/ios/Classes/CameraPermissionUtils.h index 80f55db7be32..5cbbab055f34 100644 --- a/packages/camera/camera/ios/Classes/CameraPermissionUtils.h +++ b/packages/camera/camera/ios/Classes/CameraPermissionUtils.h @@ -18,3 +18,15 @@ typedef void (^FLTCameraPermissionRequestCompletionHandler)(FlutterError *); /// called on an arbitrary dispatch queue. extern void FLTRequestCameraPermissionWithCompletionHandler( FLTCameraPermissionRequestCompletionHandler handler); + +/// Requests audio access permission. +/// +/// If it is the first time requesting audio access, a permission dialog will show up on the +/// screen. Otherwise AVFoundation simply returns the user's previous choice, and in this case the +/// user will have to update the choice in Settings app. +/// +/// @param handler if access permission is (or was previously) granted, completion handler will be +/// called without error; Otherwise completion handler will be called with error. Handler can be +/// called on an arbitrary dispatch queue. +extern void FLTRequestAudioPermissionWithCompletionHandler( + FLTCameraPermissionRequestCompletionHandler handler); diff --git a/packages/camera/camera/ios/Classes/CameraPermissionUtils.m b/packages/camera/camera/ios/Classes/CameraPermissionUtils.m index 6318338ea6a2..098265a6b74d 100644 --- a/packages/camera/camera/ios/Classes/CameraPermissionUtils.m +++ b/packages/camera/camera/ios/Classes/CameraPermissionUtils.m @@ -5,35 +5,83 @@ @import AVFoundation; #import "CameraPermissionUtils.h" -void FLTRequestCameraPermissionWithCompletionHandler( - FLTCameraPermissionRequestCompletionHandler handler) { - switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) { +void FLTRequestPermission(BOOL forAudio, FLTCameraPermissionRequestCompletionHandler handler) { + AVMediaType mediaType; + if (forAudio) { + mediaType = AVMediaTypeAudio; + } else { + mediaType = AVMediaTypeVideo; + } + + switch ([AVCaptureDevice authorizationStatusForMediaType:mediaType]) { case AVAuthorizationStatusAuthorized: handler(nil); break; - case AVAuthorizationStatusDenied: - handler([FlutterError errorWithCode:@"CameraAccessDeniedWithoutPrompt" - message:@"User has previously denied the camera access request. " - @"Go to Settings to enable camera access." - details:nil]); + case AVAuthorizationStatusDenied: { + FlutterError *flutterError; + if (forAudio) { + flutterError = + [FlutterError errorWithCode:@"AudioAccessDeniedWithoutPrompt" + message:@"User has previously denied the audio access request. " + @"Go to Settings to enable audio access." + details:nil]; + } else { + flutterError = + [FlutterError errorWithCode:@"CameraAccessDeniedWithoutPrompt" + message:@"User has previously denied the camera access request. " + @"Go to Settings to enable camera access." + details:nil]; + } + handler(flutterError); break; - case AVAuthorizationStatusRestricted: - handler([FlutterError errorWithCode:@"CameraAccessRestricted" - message:@"Camera access is restricted. " - details:nil]); + } + case AVAuthorizationStatusRestricted: { + FlutterError *flutterError; + if (forAudio) { + flutterError = [FlutterError errorWithCode:@"AudioAccessRestricted" + message:@"Audio access is restricted. " + details:nil]; + } else { + flutterError = [FlutterError errorWithCode:@"CameraAccessRestricted" + message:@"Camera access is restricted. " + details:nil]; + } + handler(flutterError); break; + } case AVAuthorizationStatusNotDetermined: { - [AVCaptureDevice - requestAccessForMediaType:AVMediaTypeVideo - completionHandler:^(BOOL granted) { - // handler can be invoked on an arbitrary dispatch queue. - handler(granted ? nil - : [FlutterError - errorWithCode:@"CameraAccessDenied" - message:@"User denied the camera access request." - details:nil]); - }]; + [AVCaptureDevice requestAccessForMediaType:mediaType + completionHandler:^(BOOL granted) { + // handler can be invoked on an arbitrary dispatch queue. + if (granted) { + handler(nil); + } else { + FlutterError *flutterError; + if (forAudio) { + flutterError = [FlutterError + errorWithCode:@"AudioAccessDenied" + message:@"User denied the audio access request." + details:nil]; + } else { + flutterError = [FlutterError + errorWithCode:@"CameraAccessDenied" + message:@"User denied the camera access request." + details:nil]; + } + handler(flutterError); + } + }]; break; } } } + +void FLTRequestCameraPermissionWithCompletionHandler( + FLTCameraPermissionRequestCompletionHandler handler) { + FLTRequestPermission(/*forAudio*/ NO, handler); +} + +void FLTRequestAudioPermissionWithCompletionHandler( + FLTCameraPermissionRequestCompletionHandler handler) { + FLTRequestPermission(/*forAudio*/ YES, handler); +} diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index 43d541e411b4..64952e8d70f1 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -132,14 +132,7 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call [result sendNotImplemented]; } } else if ([@"create" isEqualToString:call.method]) { - FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) { - // Create FLTCam only if granted camera access. - if (error) { - [result sendFlutterError:error]; - } else { - [self createCameraOnSessionQueueWithCreateMethodCall:call result:result]; - } - }); + [self handleCreateMethodCall:call result:result]; } else if ([@"startImageStream" isEqualToString:call.method]) { [_camera startImageStreamWithMessenger:_messenger]; [result sendSuccess]; @@ -194,7 +187,7 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call [_camera close]; [result sendSuccess]; } else if ([@"prepareForVideoRecording" isEqualToString:call.method]) { - [_camera setUpCaptureSessionForAudio]; + [self.camera setUpCaptureSessionForAudio]; [result sendSuccess]; } else if ([@"startVideoRecording" isEqualToString:call.method]) { [_camera startVideoRecordingWithResult:result]; @@ -258,6 +251,33 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call } } +- (void)handleCreateMethodCall:(FlutterMethodCall *)call + result:(FLTThreadSafeFlutterResult *)result { + // Create FLTCam only if granted camera access (and audio access if audio is enabled) + FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) { + if (error) { + [result sendFlutterError:error]; + } else { + // Request audio permission on `create` call with `enableAudio` argument instead of the + // `prepareForVideoRecording` call. This is because `prepareForVideoRecording` call is + // optional, and used as a workaround to fix a missing frame issue on iOS. + BOOL audioEnabled = [call.arguments[@"enableAudio"] boolValue]; + if (audioEnabled) { + // Setup audio capture session only if granted audio access. + FLTRequestAudioPermissionWithCompletionHandler(^(FlutterError *error) { + if (error) { + [result sendFlutterError:error]; + } else { + [self createCameraOnSessionQueueWithCreateMethodCall:call result:result]; + } + }); + } else { + [self createCameraOnSessionQueueWithCreateMethodCall:call result:result]; + } + } + }); +} + - (void)createCameraOnSessionQueueWithCreateMethodCall:(FlutterMethodCall *)createMethodCall result:(FLTThreadSafeFlutterResult *)result { dispatch_async(self.captureSessionQueue, ^{ diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 14acf32e2324..593e7b5bb978 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for controlling the camera. Supports previewing Dart. repository: https://github.com/flutter/plugins/tree/main/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.5+1 +version: 0.9.6 environment: sdk: ">=2.14.0 <3.0.0"