From 2fcb6f3e2181c0fc2e113052f2bead58a0691b53 Mon Sep 17 00:00:00 2001 From: Stuart Morgan Date: Tue, 16 Apr 2024 15:35:26 -0400 Subject: [PATCH 1/3] [camera] Initial iOS Pigeon conversion Converts one platform channel method to Pigeon, setting up all the Pigeon plumbing and test scaffolding. The Camera API surface is relatively large, so this lays a foundation for incremental conversion, minimizing the mixing of Pigeon setup with the individual method conversions. Part of https://github.com/flutter/flutter/issues/117905 --- .../camera/camera_avfoundation/CHANGELOG.md | 4 + .../ios/RunnerTests/AvailableCamerasTest.m | 53 +++--- .../ios/Classes/CameraPlugin.h | 4 +- .../ios/Classes/CameraPlugin.m | 37 +++-- .../ios/Classes/messages.g.h | 60 +++++++ .../ios/Classes/messages.g.m | 155 ++++++++++++++++++ .../lib/src/avfoundation_camera.dart | 28 ++-- .../lib/src/messages.g.dart | 128 +++++++++++++++ .../camera_avfoundation/lib/src/utils.dart | 30 ++-- .../camera_avfoundation/pigeons/copyright.txt | 3 + .../camera_avfoundation/pigeons/messages.dart | 50 ++++++ .../camera/camera_avfoundation/pubspec.yaml | 6 +- .../test/avfoundation_camera_test.dart | 71 +++----- .../test/avfoundation_camera_test.mocks.dart | 42 +++++ .../camera_avfoundation/test/utils_test.dart | 20 +-- 15 files changed, 558 insertions(+), 133 deletions(-) create mode 100644 packages/camera/camera_avfoundation/ios/Classes/messages.g.h create mode 100644 packages/camera/camera_avfoundation/ios/Classes/messages.g.m create mode 100644 packages/camera/camera_avfoundation/lib/src/messages.g.dart create mode 100644 packages/camera/camera_avfoundation/pigeons/copyright.txt create mode 100644 packages/camera/camera_avfoundation/pigeons/messages.dart create mode 100644 packages/camera/camera_avfoundation/test/avfoundation_camera_test.mocks.dart diff --git a/packages/camera/camera_avfoundation/CHANGELOG.md b/packages/camera/camera_avfoundation/CHANGELOG.md index 260df0c751c3..19b43a688511 100644 --- a/packages/camera/camera_avfoundation/CHANGELOG.md +++ b/packages/camera/camera_avfoundation/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.15+2 + +* Converts camera query to Pigeon. + ## 0.9.15+1 * Simplifies internal handling of method channel responses. diff --git a/packages/camera/camera_avfoundation/example/ios/RunnerTests/AvailableCamerasTest.m b/packages/camera/camera_avfoundation/example/ios/RunnerTests/AvailableCamerasTest.m index 341d532483e5..af7855afa857 100644 --- a/packages/camera/camera_avfoundation/example/ios/RunnerTests/AvailableCamerasTest.m +++ b/packages/camera/camera_avfoundation/example/ios/RunnerTests/AvailableCamerasTest.m @@ -15,8 +15,7 @@ @implementation AvailableCamerasTest - (void)testAvailableCamerasShouldReturnAllCamerasOnMultiCameraIPhone { CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil]; - XCTestExpectation *expectation = - [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Result finished"]; // iPhone 13 Cameras: AVCaptureDevice *wideAngleCamera = OCMClassMock([AVCaptureDevice class]); @@ -55,29 +54,26 @@ - (void)testAvailableCamerasShouldReturnAllCamerasOnMultiCameraIPhone { } OCMStub([discoverySessionMock devices]).andReturn([NSArray arrayWithArray:cameras]); - // Set up method call - FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"availableCameras" - arguments:nil]; - - __block id resultValue; - [camera handleMethodCallAsync:call - result:^(id _Nullable result) { - resultValue = result; - [expectation fulfill]; - }]; + __block NSArray *resultValue; + [camera + availableCamerasWithCompletion:^(NSArray *_Nullable result, + FlutterError *_Nullable error) { + XCTAssertNil(error); + resultValue = result; + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; // Verify the result - NSDictionary *dictionaryResult = (NSDictionary *)resultValue; if (@available(iOS 13.0, *)) { - XCTAssertTrue([dictionaryResult count] == 4); + XCTAssertEqual(resultValue.count, 4); } else { - XCTAssertTrue([dictionaryResult count] == 3); + XCTAssertEqual(resultValue.count, 3); } } - (void)testAvailableCamerasShouldReturnOneCameraOnSingleCameraIPhone { CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil]; - XCTestExpectation *expectation = - [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Result finished"]; // iPhone 8 Cameras: AVCaptureDevice *wideAngleCamera = OCMClassMock([AVCaptureDevice class]); @@ -105,20 +101,19 @@ - (void)testAvailableCamerasShouldReturnOneCameraOnSingleCameraIPhone { [cameras addObjectsFromArray:@[ wideAngleCamera, frontFacingCamera ]]; OCMStub([discoverySessionMock devices]).andReturn([NSArray arrayWithArray:cameras]); - // Set up method call - FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"availableCameras" - arguments:nil]; - - __block id resultValue; - [camera handleMethodCallAsync:call - result:^(id _Nullable result) { - resultValue = result; - [expectation fulfill]; - }]; + __block NSArray *resultValue; + [camera + availableCamerasWithCompletion:^(NSArray *_Nullable result, + FlutterError *_Nullable error) { + XCTAssertNil(error); + resultValue = result; + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:30 handler:nil]; // Verify the result - NSDictionary *dictionaryResult = (NSDictionary *)resultValue; - XCTAssertTrue([dictionaryResult count] == 2); + XCTAssertEqual(resultValue.count, 2); + ; } @end diff --git a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.h b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.h index f13d810445bc..586b2fc87085 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.h +++ b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.h @@ -4,5 +4,7 @@ #import -@interface CameraPlugin : NSObject +#import "messages.g.h" + +@interface CameraPlugin : NSObject @end diff --git a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m index ba29dbef75b3..2da42b7d9b6d 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m @@ -14,6 +14,7 @@ #import "FLTThreadSafeMethodChannel.h" #import "FLTThreadSafeTextureRegistry.h" #import "QueueUtils.h" +#import "messages.g.h" static FlutterError *FlutterErrorFromNSError(NSError *error) { return [FlutterError errorWithCode:[NSString stringWithFormat:@"Error %d", (int)error.code] @@ -35,6 +36,7 @@ + (void)registerWithRegistrar:(NSObject *)registrar { CameraPlugin *instance = [[CameraPlugin alloc] initWithRegistry:[registrar textures] messenger:[registrar messenger]]; [registrar addMethodCallDelegate:instance channel:channel]; + SetUpFCPCameraApi([registrar messenger], instance); } - (instancetype)initWithRegistry:(NSObject *)registry @@ -104,8 +106,12 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result }); } -- (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)result { - if ([@"availableCameras" isEqualToString:call.method]) { +- (void)availableCamerasWithCompletion: + (nonnull void (^)(NSArray *_Nullable, + FlutterError *_Nullable))completion { + // This doesn't interact with FLTCam, so can use an arbitrary thread rather than + // captureSessionQueue. It should still not be done on the main thread, however. + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSMutableArray *discoveryDevices = [@[ AVCaptureDeviceTypeBuiltInWideAngleCamera, AVCaptureDeviceTypeBuiltInTelephotoCamera ] mutableCopy]; @@ -117,29 +123,30 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re mediaType:AVMediaTypeVideo position:AVCaptureDevicePositionUnspecified]; NSArray *devices = discoverySession.devices; - NSMutableArray *> *reply = + NSMutableArray *reply = [[NSMutableArray alloc] initWithCapacity:devices.count]; for (AVCaptureDevice *device in devices) { - NSString *lensFacing; - switch ([device position]) { + FCPPlatformCameraLensDirection lensFacing; + switch (device.position) { case AVCaptureDevicePositionBack: - lensFacing = @"back"; + lensFacing = FCPPlatformCameraLensDirectionBack; break; case AVCaptureDevicePositionFront: - lensFacing = @"front"; + lensFacing = FCPPlatformCameraLensDirectionFront; break; case AVCaptureDevicePositionUnspecified: - lensFacing = @"external"; + lensFacing = FCPPlatformCameraLensDirectionExternal; break; } - [reply addObject:@{ - @"name" : [device uniqueID], - @"lensFacing" : lensFacing, - @"sensorOrientation" : @90, - }]; + [reply addObject:[FCPPlatformCameraDescription makeWithName:device.uniqueID + lensDirection:lensFacing]]; } - result(reply); - } else if ([@"create" isEqualToString:call.method]) { + completion(reply, nil); + }); +} + +- (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)result { + if ([@"create" isEqualToString:call.method]) { [self handleCreateMethodCall:call result:result]; } else if ([@"startImageStream" isEqualToString:call.method]) { [_camera startImageStreamWithMessenger:_messenger]; diff --git a/packages/camera/camera_avfoundation/ios/Classes/messages.g.h b/packages/camera/camera_avfoundation/ios/Classes/messages.g.h new file mode 100644 index 000000000000..219aef19a665 --- /dev/null +++ b/packages/camera/camera_avfoundation/ios/Classes/messages.g.h @@ -0,0 +1,60 @@ +// 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. +// Autogenerated from Pigeon (v18.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#import + +@protocol FlutterBinaryMessenger; +@protocol FlutterMessageCodec; +@class FlutterError; +@class FlutterStandardTypedData; + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSUInteger, FCPPlatformCameraLensDirection) { + /// Front facing camera (a user looking at the screen is seen by the camera). + FCPPlatformCameraLensDirectionFront = 0, + /// Back facing camera (a user looking at the screen is not seen by the camera). + FCPPlatformCameraLensDirectionBack = 1, + /// External camera which may not be mounted to the device. + FCPPlatformCameraLensDirectionExternal = 2, +}; + +/// Wrapper for FCPPlatformCameraLensDirection to allow for nullability. +@interface FCPPlatformCameraLensDirectionBox : NSObject +@property(nonatomic, assign) FCPPlatformCameraLensDirection value; +- (instancetype)initWithValue:(FCPPlatformCameraLensDirection)value; +@end + +@class FCPPlatformCameraDescription; + +@interface FCPPlatformCameraDescription : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithName:(NSString *)name + lensDirection:(FCPPlatformCameraLensDirection)lensDirection; +/// The name of the camera device. +@property(nonatomic, copy) NSString *name; +/// The direction the camera is facing. +@property(nonatomic, assign) FCPPlatformCameraLensDirection lensDirection; +@end + +/// The codec used by FCPCameraApi. +NSObject *FCPCameraApiGetCodec(void); + +@protocol FCPCameraApi +/// Returns the list of available cameras. +- (void)availableCamerasWithCompletion:(void (^)(NSArray *_Nullable, + FlutterError *_Nullable))completion; +@end + +extern void SetUpFCPCameraApi(id binaryMessenger, + NSObject *_Nullable api); + +extern void SetUpFCPCameraApiWithSuffix(id binaryMessenger, + NSObject *_Nullable api, + NSString *messageChannelSuffix); + +NS_ASSUME_NONNULL_END diff --git a/packages/camera/camera_avfoundation/ios/Classes/messages.g.m b/packages/camera/camera_avfoundation/ios/Classes/messages.g.m new file mode 100644 index 000000000000..2e5063dcfc72 --- /dev/null +++ b/packages/camera/camera_avfoundation/ios/Classes/messages.g.m @@ -0,0 +1,155 @@ +// 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. +// Autogenerated from Pigeon (v18.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#import "messages.g.h" + +#if TARGET_OS_OSX +#import +#else +#import +#endif + +#if !__has_feature(objc_arc) +#error File requires ARC to be enabled. +#endif + +static NSArray *wrapResult(id result, FlutterError *error) { + if (error) { + return @[ + error.code ?: [NSNull null], error.message ?: [NSNull null], error.details ?: [NSNull null] + ]; + } + return @[ result ?: [NSNull null] ]; +} + +static id GetNullableObjectAtIndex(NSArray *array, NSInteger key) { + id result = array[key]; + return (result == [NSNull null]) ? nil : result; +} + +@implementation FCPPlatformCameraLensDirectionBox +- (instancetype)initWithValue:(FCPPlatformCameraLensDirection)value { + self = [super init]; + if (self) { + _value = value; + } + return self; +} +@end + +@interface FCPPlatformCameraDescription () ++ (FCPPlatformCameraDescription *)fromList:(NSArray *)list; ++ (nullable FCPPlatformCameraDescription *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@implementation FCPPlatformCameraDescription ++ (instancetype)makeWithName:(NSString *)name + lensDirection:(FCPPlatformCameraLensDirection)lensDirection { + FCPPlatformCameraDescription *pigeonResult = [[FCPPlatformCameraDescription alloc] init]; + pigeonResult.name = name; + pigeonResult.lensDirection = lensDirection; + return pigeonResult; +} ++ (FCPPlatformCameraDescription *)fromList:(NSArray *)list { + FCPPlatformCameraDescription *pigeonResult = [[FCPPlatformCameraDescription alloc] init]; + pigeonResult.name = GetNullableObjectAtIndex(list, 0); + pigeonResult.lensDirection = [GetNullableObjectAtIndex(list, 1) integerValue]; + return pigeonResult; +} ++ (nullable FCPPlatformCameraDescription *)nullableFromList:(NSArray *)list { + return (list) ? [FCPPlatformCameraDescription fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + self.name ?: [NSNull null], + @(self.lensDirection), + ]; +} +@end + +@interface FCPCameraApiCodecReader : FlutterStandardReader +@end +@implementation FCPCameraApiCodecReader +- (nullable id)readValueOfType:(UInt8)type { + switch (type) { + case 128: + return [FCPPlatformCameraDescription fromList:[self readValue]]; + default: + return [super readValueOfType:type]; + } +} +@end + +@interface FCPCameraApiCodecWriter : FlutterStandardWriter +@end +@implementation FCPCameraApiCodecWriter +- (void)writeValue:(id)value { + if ([value isKindOfClass:[FCPPlatformCameraDescription class]]) { + [self writeByte:128]; + [self writeValue:[value toList]]; + } else { + [super writeValue:value]; + } +} +@end + +@interface FCPCameraApiCodecReaderWriter : FlutterStandardReaderWriter +@end +@implementation FCPCameraApiCodecReaderWriter +- (FlutterStandardWriter *)writerWithData:(NSMutableData *)data { + return [[FCPCameraApiCodecWriter alloc] initWithData:data]; +} +- (FlutterStandardReader *)readerWithData:(NSData *)data { + return [[FCPCameraApiCodecReader alloc] initWithData:data]; +} +@end + +NSObject *FCPCameraApiGetCodec(void) { + static FlutterStandardMessageCodec *sSharedObject = nil; + static dispatch_once_t sPred = 0; + dispatch_once(&sPred, ^{ + FCPCameraApiCodecReaderWriter *readerWriter = [[FCPCameraApiCodecReaderWriter alloc] init]; + sSharedObject = [FlutterStandardMessageCodec codecWithReaderWriter:readerWriter]; + }); + return sSharedObject; +} + +void SetUpFCPCameraApi(id binaryMessenger, NSObject *api) { + SetUpFCPCameraApiWithSuffix(binaryMessenger, api, @""); +} + +void SetUpFCPCameraApiWithSuffix(id binaryMessenger, + NSObject *api, NSString *messageChannelSuffix) { + messageChannelSuffix = messageChannelSuffix.length > 0 + ? [NSString stringWithFormat:@".%@", messageChannelSuffix] + : @""; + /// Returns the list of available cameras. + { + FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.camera_avfoundation." + @"CameraApi.getAvailableCameras", + messageChannelSuffix] + binaryMessenger:binaryMessenger + codec:FCPCameraApiGetCodec()]; + if (api) { + NSCAssert( + [api respondsToSelector:@selector(availableCamerasWithCompletion:)], + @"FCPCameraApi api (%@) doesn't respond to @selector(availableCamerasWithCompletion:)", + api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + [api availableCamerasWithCompletion:^( + NSArray *_Nullable output, + FlutterError *_Nullable error) { + callback(wrapResult(output, error)); + }]; + }]; + } else { + [channel setMessageHandler:nil]; + } + } +} diff --git a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart index af56e64f5bef..6bd2e2a494f6 100644 --- a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart +++ b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart @@ -7,10 +7,12 @@ import 'dart:math'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:stream_transform/stream_transform.dart'; +import 'messages.g.dart'; import 'type_conversion.dart'; import 'utils.dart'; @@ -19,11 +21,17 @@ const MethodChannel _channel = /// An iOS implementation of [CameraPlatform] based on AVFoundation. class AVFoundationCamera extends CameraPlatform { + AVFoundationCamera({@visibleForTesting CameraApi? api}) + : _hostApi = api ?? CameraApi(); + /// Registers this class as the default instance of [CameraPlatform]. static void registerWith() { CameraPlatform.instance = AVFoundationCamera(); } + /// Interface for calling host-side code. + final CameraApi _hostApi; + final Map _channels = {}; /// The name of the channel that device events from the platform side are @@ -71,21 +79,11 @@ class AVFoundationCamera extends CameraPlatform { @override Future> availableCameras() async { try { - final List>? cameras = await _channel - .invokeListMethod>('availableCameras'); - - if (cameras == null) { - return []; - } - - return cameras.map((Map camera) { - return CameraDescription( - name: camera['name']! as String, - lensDirection: - parseCameraLensDirection(camera['lensFacing']! as String), - sensorOrientation: camera['sensorOrientation']! as int, - ); - }).toList(); + return (await _hostApi.getAvailableCameras()) + // See comment in messages.dart for why this is safe. + .map((PlatformCameraDescription? c) => c!) + .map(cameraDescriptionFromPlatform) + .toList(); } on PlatformException catch (e) { throw CameraException(e.code, e.message); } diff --git a/packages/camera/camera_avfoundation/lib/src/messages.g.dart b/packages/camera/camera_avfoundation/lib/src/messages.g.dart new file mode 100644 index 000000000000..34b9004976a0 --- /dev/null +++ b/packages/camera/camera_avfoundation/lib/src/messages.g.dart @@ -0,0 +1,128 @@ +// 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. +// Autogenerated from Pigeon (v18.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +enum PlatformCameraLensDirection { + /// Front facing camera (a user looking at the screen is seen by the camera). + front, + + /// Back facing camera (a user looking at the screen is not seen by the camera). + back, + + /// External camera which may not be mounted to the device. + external, +} + +class PlatformCameraDescription { + PlatformCameraDescription({ + required this.name, + required this.lensDirection, + }); + + /// The name of the camera device. + String name; + + /// The direction the camera is facing. + PlatformCameraLensDirection lensDirection; + + Object encode() { + return [ + name, + lensDirection.index, + ]; + } + + static PlatformCameraDescription decode(Object result) { + result as List; + return PlatformCameraDescription( + name: result[0]! as String, + lensDirection: PlatformCameraLensDirection.values[result[1]! as int], + ); + } +} + +class _CameraApiCodec extends StandardMessageCodec { + const _CameraApiCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is PlatformCameraDescription) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return PlatformCameraDescription.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class CameraApi { + /// Constructor for [CameraApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + CameraApi( + {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : __pigeon_binaryMessenger = binaryMessenger, + __pigeon_messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? __pigeon_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _CameraApiCodec(); + + final String __pigeon_messageChannelSuffix; + + /// Returns the list of available cameras. + Future> getAvailableCameras() async { + final String __pigeon_channelName = + 'dev.flutter.pigeon.camera_avfoundation.CameraApi.getAvailableCameras$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = + BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = + await __pigeon_channel.send(null) as List?; + if (__pigeon_replyList == null) { + throw _createConnectionError(__pigeon_channelName); + } else if (__pigeon_replyList.length > 1) { + throw PlatformException( + code: __pigeon_replyList[0]! as String, + message: __pigeon_replyList[1] as String?, + details: __pigeon_replyList[2], + ); + } else if (__pigeon_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (__pigeon_replyList[0] as List?)! + .cast(); + } + } +} diff --git a/packages/camera/camera_avfoundation/lib/src/utils.dart b/packages/camera/camera_avfoundation/lib/src/utils.dart index 8d58f7fe1297..7ebd84cb0da6 100644 --- a/packages/camera/camera_avfoundation/lib/src/utils.dart +++ b/packages/camera/camera_avfoundation/lib/src/utils.dart @@ -5,17 +5,25 @@ import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/services.dart'; -/// Parses a string into a corresponding CameraLensDirection. -CameraLensDirection parseCameraLensDirection(String string) { - switch (string) { - case 'front': - return CameraLensDirection.front; - case 'back': - return CameraLensDirection.back; - case 'external': - return CameraLensDirection.external; - } - throw ArgumentError('Unknown CameraLensDirection value'); +import 'messages.g.dart'; + +/// Creates a [CameraDescription] from a Pigeon [PlatformCameraDescription]. +CameraDescription cameraDescriptionFromPlatform( + PlatformCameraDescription camera) { + return CameraDescription( + name: camera.name, + lensDirection: cameraLensDirectionFromPlatform(camera.lensDirection), + sensorOrientation: 90); +} + +/// Converts a Pigeon [PlatformCameraLensDirection] to a [CameraLensDirection]. +CameraLensDirection cameraLensDirectionFromPlatform( + PlatformCameraLensDirection direction) { + return switch (direction) { + PlatformCameraLensDirection.front => CameraLensDirection.front, + PlatformCameraLensDirection.back => CameraLensDirection.back, + PlatformCameraLensDirection.external => CameraLensDirection.external, + }; } /// Returns the device orientation as a String. diff --git a/packages/camera/camera_avfoundation/pigeons/copyright.txt b/packages/camera/camera_avfoundation/pigeons/copyright.txt new file mode 100644 index 000000000000..1236b63caf3a --- /dev/null +++ b/packages/camera/camera_avfoundation/pigeons/copyright.txt @@ -0,0 +1,3 @@ +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. diff --git a/packages/camera/camera_avfoundation/pigeons/messages.dart b/packages/camera/camera_avfoundation/pigeons/messages.dart new file mode 100644 index 000000000000..f973e037576b --- /dev/null +++ b/packages/camera/camera_avfoundation/pigeons/messages.dart @@ -0,0 +1,50 @@ +// 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 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/messages.g.dart', + objcHeaderOut: 'ios/Classes/messages.g.h', + objcSourceOut: 'ios/Classes/messages.g.m', + objcOptions: ObjcOptions(prefix: 'FCP'), + copyrightHeader: 'pigeons/copyright.txt', +)) + +// Pigeon version of CameraLensDirection. +enum PlatformCameraLensDirection { + /// Front facing camera (a user looking at the screen is seen by the camera). + front, + + /// Back facing camera (a user looking at the screen is not seen by the camera). + back, + + /// External camera which may not be mounted to the device. + external, +} + +// Pigeon version of CameraDescription. +class PlatformCameraDescription { + PlatformCameraDescription({ + required this.name, + required this.lensDirection, + }); + + /// The name of the camera device. + final String name; + + /// The direction the camera is facing. + final PlatformCameraLensDirection lensDirection; +} + +@HostApi() +abstract class CameraApi { + /// Returns the list of available cameras. + // TODO(stuartmorgan): Make the generic type non-nullable once supported. + // https://github.com/flutter/flutter/issues/97848 + // The consuming code treats it as non-nullable. + @async + @ObjCSelector('availableCamerasWithCompletion') + List getAvailableCameras(); +} diff --git a/packages/camera/camera_avfoundation/pubspec.yaml b/packages/camera/camera_avfoundation/pubspec.yaml index 82b804b9ff05..a599285232ed 100644 --- a/packages/camera/camera_avfoundation/pubspec.yaml +++ b/packages/camera/camera_avfoundation/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_avfoundation description: iOS implementation of the camera plugin. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_avfoundation issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.15+1 +version: 0.9.15+2 environment: sdk: ^3.2.3 @@ -20,13 +20,15 @@ dependencies: camera_platform_interface: ^2.7.0 flutter: sdk: flutter + pigeon: ^18.0.0 stream_transform: ^2.0.0 dev_dependencies: async: ^2.5.0 + build_runner: ^2.4.9 flutter_test: sdk: flutter + mockito: 5.4.4 topics: - camera - diff --git a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart index 84e28287131a..9b8379eb08eb 100644 --- a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart +++ b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart @@ -7,16 +7,21 @@ import 'dart:math'; import 'package:async/async.dart'; import 'package:camera_avfoundation/src/avfoundation_camera.dart'; +import 'package:camera_avfoundation/src/messages.g.dart'; import 'package:camera_avfoundation/src/utils.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'avfoundation_camera_test.mocks.dart'; import 'method_channel_mock.dart'; const String _channelName = 'plugins.flutter.io/camera_avfoundation'; +@GenerateMocks([CameraApi]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -488,10 +493,12 @@ void main() { }); group('Function Tests', () { + late MockCameraApi mockApi; late AVFoundationCamera camera; late int cameraId; setUp(() async { + mockApi = MockCameraApi(); MethodChannelMock( channelName: _channelName, methods: { @@ -499,7 +506,7 @@ void main() { 'initialize': null }, ); - camera = AVFoundationCamera(); + camera = AVFoundationCamera(api: mockApi); cameraId = await camera.createCamera( const CameraDescription( name: 'Test', @@ -525,68 +532,42 @@ void main() { test('Should fetch CameraDescription instances for available cameras', () async { - // Arrange - // This deliberately uses 'dynamic' since that's what actual platform - // channel results will be, so using typed mock data could mask type - // handling bugs in the code under test. - final List returnData = [ - { - 'name': 'Test 1', - 'lensFacing': 'front', - 'sensorOrientation': 1 - }, - { - 'name': 'Test 2', - 'lensFacing': 'back', - 'sensorOrientation': 2 - } + final List returnData = + [ + PlatformCameraDescription( + name: 'Test 1', lensDirection: PlatformCameraLensDirection.front), + PlatformCameraDescription( + name: 'Test 2', lensDirection: PlatformCameraLensDirection.back), ]; - final MethodChannelMock channel = MethodChannelMock( - channelName: _channelName, - methods: {'availableCameras': returnData}, - ); + when(mockApi.getAvailableCameras()).thenAnswer((_) async => returnData); - // Act final List cameras = await camera.availableCameras(); - // Assert - expect(channel.log, [ - isMethodCall('availableCameras', arguments: null), - ]); expect(cameras.length, returnData.length); for (int i = 0; i < returnData.length; i++) { - final Map typedData = - (returnData[i] as Map).cast(); - final CameraDescription cameraDescription = CameraDescription( - name: typedData['name']! as String, - lensDirection: - parseCameraLensDirection(typedData['lensFacing']! as String), - sensorOrientation: typedData['sensorOrientation']! as int, - ); - expect(cameras[i], cameraDescription); + expect(cameras[i].name, returnData[i].name); + expect(cameras[i].lensDirection, + cameraLensDirectionFromPlatform(returnData[i].lensDirection)); + // This value isn't provided by the platform, so is hard-coded to 90. + expect(cameras[i].sensorOrientation, 90); } }); test( 'Should throw CameraException when availableCameras throws a PlatformException', () { - // Arrange - MethodChannelMock(channelName: _channelName, methods: { - 'availableCameras': PlatformException( - code: 'TESTING_ERROR_CODE', - message: 'Mock error message used during testing.', - ) - }); + const String code = 'TESTING_ERROR_CODE'; + const String message = 'Mock error message used during testing.'; + when(mockApi.getAvailableCameras()).thenAnswer( + (_) async => throw PlatformException(code: code, message: message)); - // Act expect( camera.availableCameras, throwsA( isA() + .having((CameraException e) => e.code, 'code', code) .having( - (CameraException e) => e.code, 'code', 'TESTING_ERROR_CODE') - .having((CameraException e) => e.description, 'description', - 'Mock error message used during testing.'), + (CameraException e) => e.description, 'description', message), ), ); }); diff --git a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.mocks.dart b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.mocks.dart new file mode 100644 index 000000000000..066815cf2668 --- /dev/null +++ b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.mocks.dart @@ -0,0 +1,42 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in camera_avfoundation/test/avfoundation_camera_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:camera_avfoundation/src/messages.g.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [CameraApi]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockCameraApi extends _i1.Mock implements _i2.CameraApi { + MockCameraApi() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future> getAvailableCameras() => + (super.noSuchMethod( + Invocation.method( + #getAvailableCameras, + [], + ), + returnValue: _i3.Future>.value( + <_i2.PlatformCameraDescription?>[]), + ) as _i3.Future>); +} diff --git a/packages/camera/camera_avfoundation/test/utils_test.dart b/packages/camera/camera_avfoundation/test/utils_test.dart index bd28abb0dc63..06babfb53b51 100644 --- a/packages/camera/camera_avfoundation/test/utils_test.dart +++ b/packages/camera/camera_avfoundation/test/utils_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:camera_avfoundation/src/messages.g.dart'; import 'package:camera_avfoundation/src/utils.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/services.dart'; @@ -9,32 +10,21 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('Utility methods', () { - test( - 'Should return CameraLensDirection when valid value is supplied when parsing camera lens direction', - () { + test('Should convert CameraLensDirection values correctly', () { expect( - parseCameraLensDirection('back'), + cameraLensDirectionFromPlatform(PlatformCameraLensDirection.back), CameraLensDirection.back, ); expect( - parseCameraLensDirection('front'), + cameraLensDirectionFromPlatform(PlatformCameraLensDirection.front), CameraLensDirection.front, ); expect( - parseCameraLensDirection('external'), + cameraLensDirectionFromPlatform(PlatformCameraLensDirection.external), CameraLensDirection.external, ); }); - test( - 'Should throw ArgumentException when invalid value is supplied when parsing camera lens direction', - () { - expect( - () => parseCameraLensDirection('test'), - throwsA(isArgumentError), - ); - }); - test('serializeDeviceOrientation() should serialize correctly', () { expect(serializeDeviceOrientation(DeviceOrientation.portraitUp), 'portraitUp'); From 47139876275eb356ce734676eae670cb7b250911 Mon Sep 17 00:00:00 2001 From: Stuart Morgan Date: Wed, 17 Apr 2024 14:45:48 -0400 Subject: [PATCH 2/3] Switch to capture session thread --- .../camera/camera_avfoundation/ios/Classes/CameraPlugin.m | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m index 2da42b7d9b6d..5aa8087ab4c4 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m @@ -109,9 +109,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result - (void)availableCamerasWithCompletion: (nonnull void (^)(NSArray *_Nullable, FlutterError *_Nullable))completion { - // This doesn't interact with FLTCam, so can use an arbitrary thread rather than - // captureSessionQueue. It should still not be done on the main thread, however. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + dispatch_async(self.captureSessionQueue, ^{ NSMutableArray *discoveryDevices = [@[ AVCaptureDeviceTypeBuiltInWideAngleCamera, AVCaptureDeviceTypeBuiltInTelephotoCamera ] mutableCopy]; From e4496242d53f2e97a891be1fcb7768164956e7df Mon Sep 17 00:00:00 2001 From: Stuart Morgan Date: Wed, 17 Apr 2024 14:46:43 -0400 Subject: [PATCH 3/3] analyzer warning --- .../camera/camera_avfoundation/lib/src/avfoundation_camera.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart index 6bd2e2a494f6..383f195507e3 100644 --- a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart +++ b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart @@ -21,6 +21,7 @@ const MethodChannel _channel = /// An iOS implementation of [CameraPlatform] based on AVFoundation. class AVFoundationCamera extends CameraPlatform { + /// Creates a new AVFoundation-based [CameraPlatform] implementation instance. AVFoundationCamera({@visibleForTesting CameraApi? api}) : _hostApi = api ?? CameraApi();