Skip to content

Commit 486071d

Browse files
authored
[camera]handle iOS camera access permission (flutter#5215)
1 parent 7dc808a commit 486071d

16 files changed

+330
-41
lines changed

packages/camera/camera/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.9.5
2+
3+
* Adds camera access permission handling logic on iOS to fix a related crash when using the camera for the first time.
4+
15
## 0.9.4+24
26

37
* Fixes preview orientation when pausing preview with locked orientation.

packages/camera/camera/README.md

+25
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ void didChangeAppLifecycleState(AppLifecycleState state) {
8080
}
8181
```
8282

83+
### Handling camera access permissions
84+
85+
Permission errors may be thrown when initializing the camera controller, and you are expected to handle them properly.
86+
87+
Here is a list of all permission error codes that can be thrown:
88+
89+
- `CameraAccessDenied`: Thrown when user denies the camera access permission.
90+
91+
- `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.
92+
93+
- `CameraAccessRestricted`: iOS only for now. Thrown when camera access is restricted and users cannot grant permission (parental control).
94+
95+
- `cameraPermission`: Android and Web only. A legacy error code for all kinds of camera permission errors.
96+
8397
### Example
8498

8599
Here is a small example flutter app displaying a full screen camera preview.
@@ -119,6 +133,17 @@ class _CameraAppState extends State<CameraApp> {
119133
return;
120134
}
121135
setState(() {});
136+
}).catchError((Object e) {
137+
if (e is CameraException) {
138+
switch (e.code) {
139+
case 'CameraAccessDenied':
140+
print('User denied camera access.');
141+
break;
142+
default:
143+
print('Handle other errors.');
144+
break;
145+
}
146+
}
122147
});
123148
}
124149

packages/camera/camera/example/ios/Runner.xcodeproj/project.pbxproj

+5-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
archiveVersion = 1;
44
classes = {
55
};
6-
objectVersion = 50;
6+
objectVersion = 46;
77
objects = {
88

99
/* Begin PBXBuildFile section */
@@ -26,6 +26,7 @@
2626
E04F108627A87CA600573D0C /* FLTSavePhotoDelegateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */; };
2727
E071CF7227B3061B006EF3BA /* FLTCamPhotoCaptureTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E071CF7127B3061B006EF3BA /* FLTCamPhotoCaptureTests.m */; };
2828
E071CF7427B31DE4006EF3BA /* FLTCamSampleBufferTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E071CF7327B31DE4006EF3BA /* FLTCamSampleBufferTests.m */; };
29+
E0B0D2BB27DFF2AF00E71E4B /* CameraPermissionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0B0D2BA27DFF2AF00E71E4B /* CameraPermissionTests.m */; };
2930
E0C6E2002770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */; };
3031
E0C6E2012770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */; };
3132
E0C6E2022770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0C6E1FF2770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m */; };
@@ -91,6 +92,7 @@
9192
E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTSavePhotoDelegateTests.m; sourceTree = "<group>"; };
9293
E071CF7127B3061B006EF3BA /* FLTCamPhotoCaptureTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTCamPhotoCaptureTests.m; sourceTree = "<group>"; };
9394
E071CF7327B31DE4006EF3BA /* FLTCamSampleBufferTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTCamSampleBufferTests.m; sourceTree = "<group>"; };
95+
E0B0D2BA27DFF2AF00E71E4B /* CameraPermissionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraPermissionTests.m; sourceTree = "<group>"; };
9496
E0C6E1FD2770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeMethodChannelTests.m; sourceTree = "<group>"; };
9597
E0C6E1FE2770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeTextureRegistryTests.m; sourceTree = "<group>"; };
9698
E0C6E1FF2770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThreadSafeEventChannelTests.m; sourceTree = "<group>"; };
@@ -136,6 +138,7 @@
136138
E04F108527A87CA600573D0C /* FLTSavePhotoDelegateTests.m */,
137139
E071CF7127B3061B006EF3BA /* FLTCamPhotoCaptureTests.m */,
138140
E071CF7327B31DE4006EF3BA /* FLTCamSampleBufferTests.m */,
141+
E0B0D2BA27DFF2AF00E71E4B /* CameraPermissionTests.m */,
139142
E01EE4A72799F3A5008C1950 /* QueueUtilsTests.m */,
140143
E0CDBAC027CD9729002561D9 /* CameraTestUtils.h */,
141144
E0CDBAC127CD9729002561D9 /* CameraTestUtils.m */,
@@ -422,6 +425,7 @@
422425
788A065A27B0E02900533D74 /* StreamingTest.m in Sources */,
423426
E0C6E2022770F01A00EA6AA3 /* ThreadSafeEventChannelTests.m in Sources */,
424427
E0C6E2012770F01A00EA6AA3 /* ThreadSafeTextureRegistryTests.m in Sources */,
428+
E0B0D2BB27DFF2AF00E71E4B /* CameraPermissionTests.m in Sources */,
425429
E0C6E2002770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m in Sources */,
426430
E01EE4A82799F3A5008C1950 /* QueueUtilsTests.m in Sources */,
427431
);

packages/camera/camera/example/ios/RunnerTests/CameraCaptureSessionQueueRaceConditionTests.m

+5-4
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ - (void)testFixForCaptureSessionQueueNullPointerCrashDueToRaceCondition {
2929
result:^(id _Nullable result) {
3030
[disposeExpectation fulfill];
3131
}];
32-
[camera handleMethodCall:createCall
33-
result:^(id _Nullable result) {
34-
[createExpectation fulfill];
35-
}];
32+
[camera createCameraOnSessionQueueWithCreateMethodCall:createCall
33+
result:[[FLTThreadSafeFlutterResult alloc]
34+
initWithResult:^(id _Nullable result) {
35+
[createExpectation fulfill];
36+
}]];
3637
[self waitForExpectationsWithTimeout:1 handler:nil];
3738
// `captureSessionQueue` must not be nil after `create` call. Otherwise a nil
3839
// `captureSessionQueue` passed into `AVCaptureVideoDataOutput::setSampleBufferDelegate:queue:`

packages/camera/camera/example/ios/RunnerTests/CameraMethodChannelTests.m

+3-3
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ @implementation CameraMethodChannelTests
1717
- (void)testCreate_ShouldCallResultOnMainThread {
1818
CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil];
1919

20-
XCTestExpectation *expectation =
21-
[[XCTestExpectation alloc] initWithDescription:@"Result finished"];
20+
XCTestExpectation *expectation = [self expectationWithDescription:@"Result finished"];
2221

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

40-
[camera handleMethodCallAsync:call result:resultObject];
39+
[camera createCameraOnSessionQueueWithCreateMethodCall:call result:resultObject];
40+
[self waitForExpectationsWithTimeout:1 handler:nil];
4141

4242
// Verify the result
4343
NSDictionary *dictionaryResult = (NSDictionary *)resultObject.receivedResult;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
@import camera;
6+
@import camera.Test;
7+
@import AVFoundation;
8+
@import XCTest;
9+
#import <OCMock/OCMock.h>
10+
#import "CameraTestUtils.h"
11+
12+
@interface CameraPermissionTests : XCTestCase
13+
14+
@end
15+
16+
@implementation CameraPermissionTests
17+
18+
- (void)testRequestCameraPermission_completeWithoutErrorIfPrevoiuslyAuthorized {
19+
XCTestExpectation *expectation =
20+
[self expectationWithDescription:
21+
@"Must copmlete without error if camera access was previously authorized."];
22+
23+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
24+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
25+
.andReturn(AVAuthorizationStatusAuthorized);
26+
27+
FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
28+
if (error == nil) {
29+
[expectation fulfill];
30+
}
31+
});
32+
[self waitForExpectationsWithTimeout:1 handler:nil];
33+
}
34+
- (void)testRequestCameraPermission_completeWithErrorIfPreviouslyDenied {
35+
XCTestExpectation *expectation =
36+
[self expectationWithDescription:
37+
@"Must complete with error if camera access was previously denied."];
38+
FlutterError *expectedError =
39+
[FlutterError errorWithCode:@"CameraAccessDeniedWithoutPrompt"
40+
message:@"User has previously denied the camera access request. Go to "
41+
@"Settings to enable camera access."
42+
details:nil];
43+
44+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
45+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
46+
.andReturn(AVAuthorizationStatusDenied);
47+
FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
48+
if ([error isEqual:expectedError]) {
49+
[expectation fulfill];
50+
}
51+
});
52+
[self waitForExpectationsWithTimeout:1 handler:nil];
53+
}
54+
55+
- (void)testRequestCameraPermission_completeWithErrorIfRestricted {
56+
XCTestExpectation *expectation =
57+
[self expectationWithDescription:@"Must complete with error if camera access is restricted."];
58+
FlutterError *expectedError = [FlutterError errorWithCode:@"CameraAccessRestricted"
59+
message:@"Camera access is restricted. "
60+
details:nil];
61+
62+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
63+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
64+
.andReturn(AVAuthorizationStatusRestricted);
65+
66+
FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
67+
if ([error isEqual:expectedError]) {
68+
[expectation fulfill];
69+
}
70+
});
71+
[self waitForExpectationsWithTimeout:1 handler:nil];
72+
}
73+
74+
- (void)testRequestCameraPermission_completeWithoutErrorIfUserGrantAccess {
75+
XCTestExpectation *grantedExpectation = [self
76+
expectationWithDescription:@"Must complete without error if user choose to grant access"];
77+
78+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
79+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
80+
.andReturn(AVAuthorizationStatusNotDetermined);
81+
// Mimic user choosing "allow" in permission dialog.
82+
OCMStub([mockDevice requestAccessForMediaType:AVMediaTypeVideo
83+
completionHandler:[OCMArg checkWithBlock:^BOOL(void (^block)(BOOL)) {
84+
block(YES);
85+
return YES;
86+
}]]);
87+
88+
FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
89+
if (error == nil) {
90+
[grantedExpectation fulfill];
91+
}
92+
});
93+
[self waitForExpectationsWithTimeout:1 handler:nil];
94+
}
95+
96+
- (void)testRequestCameraPermission_completeWithErrorIfUserDenyAccess {
97+
XCTestExpectation *expectation =
98+
[self expectationWithDescription:@"Must complete with error if user choose to deny access"];
99+
FlutterError *expectedError =
100+
[FlutterError errorWithCode:@"CameraAccessDenied"
101+
message:@"User denied the camera access request."
102+
details:nil];
103+
104+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
105+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
106+
.andReturn(AVAuthorizationStatusNotDetermined);
107+
108+
// Mimic user choosing "deny" in permission dialog.
109+
OCMStub([mockDevice requestAccessForMediaType:AVMediaTypeVideo
110+
completionHandler:[OCMArg checkWithBlock:^BOOL(void (^block)(BOOL)) {
111+
block(NO);
112+
return YES;
113+
}]]);
114+
FLTRequestCameraPermissionWithCompletionHandler(^(FlutterError *error) {
115+
if ([error isEqual:expectedError]) {
116+
[expectation fulfill];
117+
}
118+
});
119+
120+
[self waitForExpectationsWithTimeout:1 handler:nil];
121+
}
122+
123+
@end

packages/camera/camera/example/lib/main.dart

+29-3
Original file line numberDiff line numberDiff line change
@@ -633,8 +633,15 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
633633
}
634634

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

640647
final CameraController cameraController = CameraController(
@@ -678,7 +685,26 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
678685
.then((double value) => _minAvailableZoom = value),
679686
]);
680687
} on CameraException catch (e) {
681-
_showCameraException(e);
688+
switch (e.code) {
689+
case 'CameraAccessDenied':
690+
showInSnackBar('You have denied camera access.');
691+
break;
692+
case 'CameraAccessDeniedWithoutPrompt':
693+
// iOS only
694+
showInSnackBar('Please go to Settings app to enable camera access.');
695+
break;
696+
case 'CameraAccessRestricted':
697+
// iOS only
698+
showInSnackBar('Camera access is restricted.');
699+
break;
700+
case 'cameraPermission':
701+
// Android & web only
702+
showInSnackBar('Unknown permission error.');
703+
break;
704+
default:
705+
_showCameraException(e);
706+
break;
707+
}
682708
}
683709

684710
if (mounted) {

packages/camera/camera/example/lib/readme_full_example.dart

+11
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ class _CameraAppState extends State<CameraApp> {
3636
return;
3737
}
3838
setState(() {});
39+
}).catchError((Object e) {
40+
if (e is CameraException) {
41+
switch (e.code) {
42+
case 'CameraAccessDenied':
43+
print('User denied camera access.');
44+
break;
45+
default:
46+
print('Handle other errors.');
47+
break;
48+
}
49+
}
3950
});
4051
}
4152

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
@import Foundation;
6+
#import <Flutter/Flutter.h>
7+
8+
typedef void (^FLTCameraPermissionRequestCompletionHandler)(FlutterError *);
9+
10+
/// Requests camera access permission.
11+
///
12+
/// If it is the first time requesting camera access, a permission dialog will show up on the
13+
/// screen. Otherwise AVFoundation simply returns the user's previous choice, and in this case the
14+
/// user will have to update the choice in Settings app.
15+
///
16+
/// @param handler if access permission is (or was previously) granted, completion handler will be
17+
/// called without error; Otherwise completion handler will be called with error. Handler can be
18+
/// called on an arbitrary dispatch queue.
19+
extern void FLTRequestCameraPermissionWithCompletionHandler(
20+
FLTCameraPermissionRequestCompletionHandler handler);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
@import AVFoundation;
6+
#import "CameraPermissionUtils.h"
7+
8+
void FLTRequestCameraPermissionWithCompletionHandler(
9+
FLTCameraPermissionRequestCompletionHandler handler) {
10+
switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) {
11+
case AVAuthorizationStatusAuthorized:
12+
handler(nil);
13+
break;
14+
case AVAuthorizationStatusDenied:
15+
handler([FlutterError errorWithCode:@"CameraAccessDeniedWithoutPrompt"
16+
message:@"User has previously denied the camera access request. "
17+
@"Go to Settings to enable camera access."
18+
details:nil]);
19+
break;
20+
case AVAuthorizationStatusRestricted:
21+
handler([FlutterError errorWithCode:@"CameraAccessRestricted"
22+
message:@"Camera access is restricted. "
23+
details:nil]);
24+
break;
25+
case AVAuthorizationStatusNotDetermined: {
26+
[AVCaptureDevice
27+
requestAccessForMediaType:AVMediaTypeVideo
28+
completionHandler:^(BOOL granted) {
29+
// handler can be invoked on an arbitrary dispatch queue.
30+
handler(granted ? nil
31+
: [FlutterError
32+
errorWithCode:@"CameraAccessDenied"
33+
message:@"User denied the camera access request."
34+
details:nil]);
35+
}];
36+
break;
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)