Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

[camera] Request access permission for audio #5766

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/camera/camera/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
8 changes: 7 additions & 1 deletion packages/camera/camera/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So per your design doc edits this will also be implemented for Android?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, android also handles camera and audio permissions separately.


- `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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ @interface CameraPermissionTests : XCTestCase

@implementation CameraPermissionTests

#pragma mark - camera permissions

- (void)testRequestCameraPermission_completeWithoutErrorIfPrevoiuslyAuthorized {
XCTestExpectation *expectation =
[self expectationWithDescription:
Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions packages/camera/camera/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,17 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
// 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.');
Expand Down
12 changes: 12 additions & 0 deletions packages/camera/camera/ios/Classes/CameraPermissionUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
92 changes: 70 additions & 22 deletions packages/camera/camera/ios/Classes/CameraPermissionUtils.m
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know authorizationStatusForMediaType:mediaType was already used before, but should this be +requestAccessForMediaType:completionHandler: instead to actually request it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh we should always check the status first, and only request the permission if it's in .notDetermined status.

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);
}
38 changes: 29 additions & 9 deletions packages/camera/camera/ios/Classes/CameraPlugin.m
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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, ^{
Expand Down
2 changes: 1 addition & 1 deletion packages/camera/camera/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down