Skip to content

Commit

Permalink
[camera]handle iOS camera access permission (flutter#5215)
Browse files Browse the repository at this point in the history
  • Loading branch information
hellohuanlin authored and fkorotkov committed May 20, 2022
1 parent d44df7c commit 8ee7bd8
Show file tree
Hide file tree
Showing 16 changed files with 330 additions and 41 deletions.
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
123 changes: 123 additions & 0 deletions packages/camera/camera/example/ios/RunnerTests/CameraPermissionTests.m
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

0 comments on commit 8ee7bd8

Please sign in to comment.