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

[camera]handle iOS camera access permission #5215

Merged
merged 6 commits into from
May 14, 2022
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.5

* Adds camera access permission handling logic on iOS to fix a related crash when using the camera for the first time.

## 0.9.4+24

* Fixes preview orientation when pausing preview with locked orientation.
Expand Down
25 changes: 25 additions & 0 deletions packages/camera/camera/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ void didChangeAppLifecycleState(AppLifecycleState state) {
}
```

### Handling camera access permissions

Permission errors may be thrown when initializing the camera controller, and you are expected to handle them properly.

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.

- `CameraAccessRestricted`: iOS only for now. Thrown when camera 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

Here is a small example flutter app displaying a full screen camera preview.
Expand Down Expand Up @@ -119,6 +133,17 @@ class _CameraAppState extends State<CameraApp> {
return;
}
setState(() {});
}).catchError((Object e) {
if (e is CameraException) {
switch (e.code) {
case 'CameraAccessDenied':
print('User denied camera access.');
break;
default:
print('Handle other errors.');
break;
}
}
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 50;
objectVersion = 46;
objects = {

/* Begin PBXBuildFile section */
Expand All @@ -26,6 +26,7 @@
E04F108627A87CA600573D0C /* FLTSavePhotoDelegateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */; };
E071CF7227B3061B006EF3BA /* FLTCamPhotoCaptureTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E071CF7127B3061B006EF3BA /* FLTCamPhotoCaptureTests.m */; };
E071CF7427B31DE4006EF3BA /* FLTCamSampleBufferTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E071CF7327B31DE4006EF3BA /* FLTCamSampleBufferTests.m */; };
E0B0D2BB27DFF2AF00E71E4B /* CameraPermissionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0B0D2BA27DFF2AF00E71E4B /* CameraPermissionTests.m */; };
E0C6E2002770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */; };
E0C6E2012770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */; };
E0C6E2022770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FF2770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m */; };
Expand Down Expand Up @@ -91,6 +92,7 @@
E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTSavePhotoDelegateTests.m; sourceTree = "<group>"; };
E071CF7127B3061B006EF3BA /* FLTCamPhotoCaptureTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTCamPhotoCaptureTests.m; sourceTree = "<group>"; };
E071CF7327B31DE4006EF3BA /* FLTCamSampleBufferTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTCamSampleBufferTests.m; sourceTree = "<group>"; };
E0B0D2BA27DFF2AF00E71E4B /* CameraPermissionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraPermissionTests.m; sourceTree = "<group>"; };
E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeMethodChannelTests.m; sourceTree = "<group>"; };
E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeTextureRegistryTests.m; sourceTree = "<group>"; };
E0C6E1FF2770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeEventChannelTests.m; sourceTree = "<group>"; };
Expand Down Expand Up @@ -136,6 +138,7 @@
E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */,
E071CF7127B3061B006EF3BA /* FLTCamPhotoCaptureTests.m */,
E071CF7327B31DE4006EF3BA /* FLTCamSampleBufferTests.m */,
E0B0D2BA27DFF2AF00E71E4B /* CameraPermissionTests.m */,
E01EE4A72799F3A5008C1950 /* QueueUtilsTests.m */,
E0CDBAC027CD9729002561D9 /* CameraTestUtils.h */,
E0CDBAC127CD9729002561D9 /* CameraTestUtils.m */,
Expand Down Expand Up @@ -422,6 +425,7 @@
788A065A27B0E02900533D74 /* StreamingTest.m in Sources */,
E0C6E2022770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m in Sources */,
E0C6E2012770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m in Sources */,
E0B0D2BB27DFF2AF00E71E4B /* CameraPermissionTests.m in Sources */,
E0C6E2002770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m in Sources */,
E01EE4A82799F3A5008C1950 /* QueueUtilsTests.m in Sources */,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ - (void)testFixForCaptureSessionQueueNullPointerCrashDueToRaceCondition {
result:^(id _Nullable result) {
[disposeExpectation fulfill];
}];
[camera handleMethodCall:createCall
result:^(id _Nullable result) {
[createExpectation fulfill];
}];
[camera createCameraOnSessionQueueWithCreateMethodCall:createCall
result:[[FLTThreadSafeFlutterResult alloc]
initWithResult:^(id _Nullable result) {
[createExpectation fulfill];
}]];
[self waitForExpectationsWithTimeout:1 handler:nil];
// `captureSessionQueue` must not be nil after `create` call. Otherwise a nil
// `captureSessionQueue` passed into `AVCaptureVideoDataOutput::setSampleBufferDelegate:queue:`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ @implementation CameraMethodChannelTests
- (void)testCreate_ShouldCallResultOnMainThread {
CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil];

XCTestExpectation *expectation =
[[XCTestExpectation alloc] initWithDescription:@"Result finished"];
XCTestExpectation *expectation = [self expectationWithDescription:@"Result finished"];

// Set up mocks for initWithCameraName method
id avCaptureDeviceInputMock = OCMClassMock([AVCaptureDeviceInput class]);
Expand All @@ -37,7 +36,8 @@ - (void)testCreate_ShouldCallResultOnMainThread {
methodCallWithMethodName:@"create"
arguments:@{@"resolutionPreset" : @"medium", @"enableAudio" : @(1)}];

[camera handleMethodCallAsync:call result:resultObject];
[camera createCameraOnSessionQueueWithCreateMethodCall:call result:resultObject];
[self waitForExpectationsWithTimeout:1 handler:nil];

// Verify the result
NSDictionary *dictionaryResult = (NSDictionary *)resultObject.receivedResult;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

@import camera;
@import camera.Test;
@import AVFoundation;
@import XCTest;
#import <OCMock/OCMock.h>
#import "CameraTestUtils.h"

@interface CameraPermissionTests : XCTestCase

@end

@implementation CameraPermissionTests

- (void)testRequestCameraPermission_completeWithoutErrorIfPrevoiuslyAuthorized {
XCTestExpectation *expectation =
[self expectationWithDescription:
@"Must copmlete without error if camera access was previously authorized."];

id mockDevice = OCMClassMock([AVCaptureDevice class]);
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
.andReturn(AVAuthorizationStatusAuthorized);

FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
if (error == nil) {
[expectation fulfill];
}
});
[self waitForExpectationsWithTimeout:1 handler:nil];
}
- (void)testRequestCameraPermission_completeWithErrorIfPreviouslyDenied {
XCTestExpectation *expectation =
[self expectationWithDescription:
@"Must complete with error if camera access was previously denied."];
FlutterError *expectedError =
[FlutterError errorWithCode:@"CameraAccessDeniedWithoutPrompt"
message:@"User has previously denied the camera access request. Go to "
@"Settings to enable camera access."
details:nil];

id mockDevice = OCMClassMock([AVCaptureDevice class]);
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
.andReturn(AVAuthorizationStatusDenied);
FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
if ([error isEqual:expectedError]) {
[expectation fulfill];
}
});
[self waitForExpectationsWithTimeout:1 handler:nil];
}

- (void)testRequestCameraPermission_completeWithErrorIfRestricted {
XCTestExpectation *expectation =
[self expectationWithDescription:@"Must complete with error if camera access is restricted."];
FlutterError *expectedError = [FlutterError errorWithCode:@"CameraAccessRestricted"
message:@"Camera access is restricted. "
details:nil];

id mockDevice = OCMClassMock([AVCaptureDevice class]);
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
.andReturn(AVAuthorizationStatusRestricted);

FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
if ([error isEqual:expectedError]) {
[expectation fulfill];
}
});
[self waitForExpectationsWithTimeout:1 handler:nil];
}

- (void)testRequestCameraPermission_completeWithoutErrorIfUserGrantAccess {
XCTestExpectation *grantedExpectation = [self
expectationWithDescription:@"Must complete without error if user choose to grant access"];

id mockDevice = OCMClassMock([AVCaptureDevice class]);
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
.andReturn(AVAuthorizationStatusNotDetermined);
// Mimic user choosing "allow" in permission dialog.
OCMStub([mockDevice requestAccessForMediaType:AVMediaTypeVideo
completionHandler:[OCMArg checkWithBlock:^BOOL(void (^block)(BOOL)) {
block(YES);
return YES;
}]]);

FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
if (error == nil) {
[grantedExpectation fulfill];
}
});
[self waitForExpectationsWithTimeout:1 handler:nil];
}

- (void)testRequestCameraPermission_completeWithErrorIfUserDenyAccess {
XCTestExpectation *expectation =
[self expectationWithDescription:@"Must complete with error if user choose to deny access"];
FlutterError *expectedError =
[FlutterError errorWithCode:@"CameraAccessDenied"
message:@"User denied the camera access request."
details:nil];

id mockDevice = OCMClassMock([AVCaptureDevice class]);
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
.andReturn(AVAuthorizationStatusNotDetermined);

// Mimic user choosing "deny" in permission dialog.
OCMStub([mockDevice requestAccessForMediaType:AVMediaTypeVideo
completionHandler:[OCMArg checkWithBlock:^BOOL(void (^block)(BOOL)) {
block(NO);
return YES;
}]]);
FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
if ([error isEqual:expectedError]) {
[expectation fulfill];
}
});

[self waitForExpectationsWithTimeout:1 handler:nil];
}

@end
32 changes: 29 additions & 3 deletions packages/camera/camera/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -633,8 +633,15 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
}

Future<void> onNewCameraSelected(CameraDescription cameraDescription) async {
if (controller != null) {
await controller!.dispose();
final CameraController? oldController = controller;
if (oldController != null) {
// `controller` needs to be set to null before getting disposed,
// to avoid a race condition when we use the controller that is being
// disposed. This happens when camera permission dialog shows up,
// which triggers `didChangeAppLifecycleState`, which disposes and
// re-creates the controller.
controller = null;
await oldController.dispose();
}

final CameraController cameraController = CameraController(
Expand Down Expand Up @@ -678,7 +685,26 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
.then((double value) => _minAvailableZoom = value),
]);
} on CameraException catch (e) {
_showCameraException(e);
switch (e.code) {
case 'CameraAccessDenied':
showInSnackBar('You have denied camera access.');
break;
case 'CameraAccessDeniedWithoutPrompt':
// iOS only
showInSnackBar('Please go to Settings app to enable camera access.');
break;
case 'CameraAccessRestricted':
// iOS only
showInSnackBar('Camera access is restricted.');
break;
case 'cameraPermission':
// Android & web only
showInSnackBar('Unknown permission error.');
break;
default:
_showCameraException(e);
break;
}
}

if (mounted) {
Expand Down
11 changes: 11 additions & 0 deletions packages/camera/camera/example/lib/readme_full_example.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ class _CameraAppState extends State<CameraApp> {
return;
}
setState(() {});
}).catchError((Object e) {
if (e is CameraException) {
switch (e.code) {
case 'CameraAccessDenied':
print('User denied camera access.');
break;
default:
print('Handle other errors.');
break;
}
}
});
}

Expand Down
20 changes: 20 additions & 0 deletions packages/camera/camera/ios/Classes/CameraPermissionUtils.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

@import Foundation;
#import <Flutter/Flutter.h>

typedef void (^FLTCameraPermissionRequestCompletionHandler)(FlutterError *);

/// Requests camera access permission.
///
/// If it is the first time requesting camera 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 FLTRequestCameraPermissionWithCompletionHandler(
FLTCameraPermissionRequestCompletionHandler handler);
39 changes: 39 additions & 0 deletions packages/camera/camera/ios/Classes/CameraPermissionUtils.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

@import AVFoundation;
#import "CameraPermissionUtils.h"

void FLTRequestCameraPermissionWithCompletionHandler(
FLTCameraPermissionRequestCompletionHandler handler) {
switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) {
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]);
break;
case AVAuthorizationStatusRestricted:
handler([FlutterError errorWithCode:@"CameraAccessRestricted"
message:@"Camera access is restricted. "
details:nil]);
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]);
}];
break;
}
}
}
Loading