[camera] Request access permission for audio (#5766)
diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md
index d101f60..9af2301 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 6b2ed7a..ec9d737 100644
--- a/packages/camera/camera/README.md
+++ b/packages/camera/camera/README.md
@@ -88,10 +88,16 @@
- `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 961b931..541e028 100644
--- a/packages/camera/camera/example/ios/RunnerTests/CameraPermissionTests.m
+++ b/packages/camera/camera/example/ios/RunnerTests/CameraPermissionTests.m
@@ -15,6 +15,8 @@
@implementation CameraPermissionTests
+#pragma mark - camera permissions
+
- (void)testRequestCameraPermission_completeWithoutErrorIfPrevoiuslyAuthorized {
XCTestExpectation *expectation =
[self expectationWithDescription:
@@ -120,4 +122,110 @@
[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 34942ba..c0181a5 100644
--- a/packages/camera/camera/example/lib/main.dart
+++ b/packages/camera/camera/example/lib/main.dart
@@ -697,6 +697,17 @@
// 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 80f55db..5cbbab0 100644
--- a/packages/camera/camera/ios/Classes/CameraPermissionUtils.h
+++ b/packages/camera/camera/ios/Classes/CameraPermissionUtils.h
@@ -18,3 +18,15 @@
/// 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 6318338..098265a 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 43d541e..64952e8 100644
--- a/packages/camera/camera/ios/Classes/CameraPlugin.m
+++ b/packages/camera/camera/ios/Classes/CameraPlugin.m
@@ -132,14 +132,7 @@
[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 @@
[_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)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 14acf32..593e7b5 100644
--- a/packages/camera/camera/pubspec.yaml
+++ b/packages/camera/camera/pubspec.yaml
@@ -4,7 +4,7 @@
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"