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

Commit 3269492

Browse files
committed
[camera]handle access permission
1 parent ad146f1 commit 3269492

File tree

12 files changed

+263
-39
lines changed

12 files changed

+263
-39
lines changed

packages/camera/camera/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
## 0.9.5
2+
3+
* Adds camera access permission handling on iOS to fix a related crash when first time using the camera.
4+
15
## 0.9.4+19
26

3-
* Migrate deprecated Scaffold SnackBar methods to ScaffoldMessenger.
7+
* Migrates deprecated Scaffold SnackBar methods to ScaffoldMessenger.
48

59
## 0.9.4+18
610

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

Lines changed: 5 additions & 1 deletion
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 /* CameraAccessPermissionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E0B0D2BA27DFF2AF00E71E4B /* CameraAccessPermissionTests.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 /* CameraAccessPermissionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CameraAccessPermissionTests.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 /* CameraAccessPermissionTests.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 /* CameraAccessPermissionTests.m in Sources */,
425429
E0C6E2002770F01A00EA6AA3 /* ThreadSafeMethodChannelTests.m in Sources */,
426430
E01EE4A82799F3A5008C1950 /* QueueUtilsTests.m in Sources */,
427431
);
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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 CameraAccessPermissionTests : XCTestCase
13+
14+
@end
15+
16+
@implementation CameraAccessPermissionTests
17+
18+
- (void)testRequestCameraAccessPermission_completeWithoutErrorIfPrevoiuslyAuthorized {
19+
XCTestExpectation *expectation =
20+
[self expectationWithDescription:
21+
@"Must copmlete without error if camera access was previously authorized."];
22+
23+
CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil];
24+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
25+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
26+
.andReturn(AVAuthorizationStatusAuthorized);
27+
[camera requestCameraAccessPermissionWithCompletionHandler:^(FlutterError *error) {
28+
if (error == nil) {
29+
[expectation fulfill];
30+
}
31+
}];
32+
[self waitForExpectationsWithTimeout:1 handler:nil];
33+
}
34+
- (void)testRequestCameraAccessPermission_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+
CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil];
45+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
46+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
47+
.andReturn(AVAuthorizationStatusDenied);
48+
[camera requestCameraAccessPermissionWithCompletionHandler:^(FlutterError *error) {
49+
if ([error isEqual:expectedError]) {
50+
[expectation fulfill];
51+
}
52+
}];
53+
[self waitForExpectationsWithTimeout:1 handler:nil];
54+
}
55+
56+
- (void)testRequestCameraAccessPermission_completeWithErrorIfRestricted {
57+
XCTestExpectation *expectation =
58+
[self expectationWithDescription:@"Must complete with error if camera access is restricted."];
59+
FlutterError *expectedError = [FlutterError errorWithCode:@"CameraAccessRestricted"
60+
message:@"Camera access is restricted. "
61+
details:nil];
62+
63+
CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil];
64+
65+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
66+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
67+
.andReturn(AVAuthorizationStatusRestricted);
68+
[camera requestCameraAccessPermissionWithCompletionHandler:^(FlutterError *error) {
69+
if ([error isEqual:expectedError]) {
70+
[expectation fulfill];
71+
}
72+
}];
73+
[self waitForExpectationsWithTimeout:1 handler:nil];
74+
}
75+
76+
- (void)testRequestCameraAccessPermission_completeWithoutErrorIfUserGrantAccess {
77+
XCTestExpectation *grantedExpectation = [self
78+
expectationWithDescription:@"Must complete without error if user choose to grant access"];
79+
80+
CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil];
81+
82+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
83+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
84+
.andReturn(AVAuthorizationStatusNotDetermined);
85+
// Mimic user choosing "allow" in permission dialog.
86+
OCMStub([mockDevice requestAccessForMediaType:AVMediaTypeVideo
87+
completionHandler:[OCMArg checkWithBlock:^BOOL(void (^block)(BOOL)) {
88+
block(YES);
89+
return YES;
90+
}]]);
91+
92+
[camera requestCameraAccessPermissionWithCompletionHandler:^(FlutterError *error) {
93+
if (error == nil) {
94+
[grantedExpectation fulfill];
95+
}
96+
}];
97+
[self waitForExpectationsWithTimeout:1 handler:nil];
98+
}
99+
100+
- (void)testRequestCameraAccessPermission_completeWithErrorIfUserDenyAccess {
101+
XCTestExpectation *expectation =
102+
[self expectationWithDescription:@"Must complete with error if user choose to deny access"];
103+
CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil];
104+
FlutterError *expectedError =
105+
[FlutterError errorWithCode:@"CameraAccessDenied"
106+
message:@"User denied the camera access request."
107+
details:nil];
108+
109+
id mockDevice = OCMClassMock([AVCaptureDevice class]);
110+
OCMStub([mockDevice authorizationStatusForMediaType:AVMediaTypeVideo])
111+
.andReturn(AVAuthorizationStatusNotDetermined);
112+
113+
// Mimic user choosing "deny" in permission dialog.
114+
OCMStub([mockDevice requestAccessForMediaType:AVMediaTypeVideo
115+
completionHandler:[OCMArg checkWithBlock:^BOOL(void (^block)(BOOL)) {
116+
block(NO);
117+
return YES;
118+
}]]);
119+
[camera requestCameraAccessPermissionWithCompletionHandler:^(FlutterError *error) {
120+
if ([error isEqual:expectedError]) {
121+
[expectation fulfill];
122+
}
123+
}];
124+
125+
[self waitForExpectationsWithTimeout:1 handler:nil];
126+
}
127+
128+
@end

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

Lines changed: 5 additions & 4 deletions
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 createCameraWithCreateMethodCall: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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ - (void)testCreate_ShouldCallResultOnMainThread {
3737
methodCallWithMethodName:@"create"
3838
arguments:@{@"resolutionPreset" : @"medium", @"enableAudio" : @(1)}];
3939

40-
[camera handleMethodCallAsync:call result:resultObject];
40+
[camera createCameraWithCreateMethodCall:call result:resultObject];
4141

4242
// Verify the result
4343
NSDictionary *dictionaryResult = (NSDictionary *)resultObject.receivedResult;

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,14 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
631631

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

637644
final CameraController cameraController = CameraController(

packages/camera/camera/ios/Classes/CameraPlugin.m

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -131,31 +131,18 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call
131131
[result sendNotImplemented];
132132
}
133133
} else if ([@"create" isEqualToString:call.method]) {
134-
NSString *cameraName = call.arguments[@"cameraName"];
135-
NSString *resolutionPreset = call.arguments[@"resolutionPreset"];
136-
NSNumber *enableAudio = call.arguments[@"enableAudio"];
137-
NSError *error;
138-
FLTCam *cam = [[FLTCam alloc] initWithCameraName:cameraName
139-
resolutionPreset:resolutionPreset
140-
enableAudio:[enableAudio boolValue]
141-
orientation:[[UIDevice currentDevice] orientation]
142-
captureSessionQueue:_captureSessionQueue
143-
error:&error];
144-
145-
if (error) {
146-
[result sendError:error];
147-
} else {
148-
if (_camera) {
149-
[_camera close];
134+
[self requestCameraAccessPermissionWithCompletionHandler:^(FlutterError *error) {
135+
// Create FLTCam only if granted camera access.
136+
if (error) {
137+
[result sendFlutterError:error];
138+
} else {
139+
// completionHandle may be called on an arbitrary dispatch queue.
140+
// Dispatch back to the capture session queue to create the camera.
141+
dispatch_async(self.captureSessionQueue, ^{
142+
[self createCameraWithCreateMethodCall:call result:result];
143+
});
150144
}
151-
_camera = cam;
152-
[self.registry registerTexture:cam
153-
completion:^(int64_t textureId) {
154-
[result sendSuccessWithData:@{
155-
@"cameraId" : @(textureId),
156-
}];
157-
}];
158-
}
145+
}];
159146
} else if ([@"startImageStream" isEqualToString:call.method]) {
160147
[_camera startImageStreamWithMessenger:_messenger];
161148
[result sendSuccess];
@@ -274,4 +261,65 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call
274261
}
275262
}
276263

264+
- (void)requestCameraAccessPermissionWithCompletionHandler:(void (^)(FlutterError *))handler {
265+
switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) {
266+
case AVAuthorizationStatusAuthorized:
267+
handler(nil);
268+
break;
269+
case AVAuthorizationStatusNotDetermined: {
270+
[AVCaptureDevice
271+
requestAccessForMediaType:AVMediaTypeVideo
272+
completionHandler:^(BOOL granted) {
273+
// handler can be invoked on an arbitrary dispatch queue.
274+
handler(granted ? nil
275+
: [FlutterError
276+
errorWithCode:@"CameraAccessDenied"
277+
message:@"User denied the camera access request."
278+
details:nil]);
279+
}];
280+
break;
281+
}
282+
case AVAuthorizationStatusDenied:
283+
handler([FlutterError errorWithCode:@"CameraAccessDeniedWithoutPrompt"
284+
message:@"User has previously denied the camera access request. "
285+
@"Go to Settings to enable camera access."
286+
details:nil]);
287+
break;
288+
case AVAuthorizationStatusRestricted:
289+
handler([FlutterError errorWithCode:@"CameraAccessRestricted"
290+
message:@"Camera access is restricted. "
291+
details:nil]);
292+
break;
293+
}
294+
}
295+
296+
- (void)createCameraWithCreateMethodCall:(FlutterMethodCall *)createMethodCall
297+
result:(FLTThreadSafeFlutterResult *)result {
298+
NSString *cameraName = createMethodCall.arguments[@"cameraName"];
299+
NSString *resolutionPreset = createMethodCall.arguments[@"resolutionPreset"];
300+
NSNumber *enableAudio = createMethodCall.arguments[@"enableAudio"];
301+
NSError *error;
302+
FLTCam *cam = [[FLTCam alloc] initWithCameraName:cameraName
303+
resolutionPreset:resolutionPreset
304+
enableAudio:[enableAudio boolValue]
305+
orientation:[[UIDevice currentDevice] orientation]
306+
captureSessionQueue:_captureSessionQueue
307+
error:&error];
308+
309+
if (error) {
310+
[result sendError:error];
311+
} else {
312+
if (_camera) {
313+
[_camera close];
314+
}
315+
_camera = cam;
316+
[self.registry registerTexture:cam
317+
completion:^(int64_t textureId) {
318+
[result sendSuccessWithData:@{
319+
@"cameraId" : @(textureId),
320+
}];
321+
}];
322+
}
323+
}
324+
277325
@end

packages/camera/camera/ios/Classes/CameraPlugin_Test.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,22 @@
3838
/// that triggered the orientation change.
3939
- (void)orientationChanged:(NSNotification *)notification;
4040

41+
/// Creates FLTCam and reports the creation result. In production it should be called only after
42+
/// camera access has been granted by the user. Exposed for unit tests to skip the camera access
43+
/// permission dialog.
44+
/// @param createMethodCall the create method call
45+
/// @param result a thread safe flutter result wrapper object to report creation result.
46+
- (void)createCameraWithCreateMethodCall:(FlutterMethodCall *)createMethodCall
47+
result:(FLTThreadSafeFlutterResult *)result;
48+
49+
/// Requests camera access permission.
50+
/// If it is the first time requesting camera access, a permission dialog will show up on the
51+
/// screen. Otherwise AVFoundation simply returns the user's previous choice, and in this case the
52+
/// user will have to update the choice in Settings app.
53+
/// @param handler if access permission is (or was previously) granted, completion handler will be
54+
/// called without error; Otherwise completion handler will be called with error. Handler can be
55+
/// called on an arbitrary dispatch queue.
56+
- (void)requestCameraAccessPermissionWithCompletionHandler:
57+
(void (^_Nonnull)(FlutterError *))handler;
58+
4159
@end

0 commit comments

Comments
 (0)