diff --git a/packages/camera/camera_android_camerax/CHANGELOG.md b/packages/camera/camera_android_camerax/CHANGELOG.md index b85784c2364..c98dc3ffdb5 100644 --- a/packages/camera/camera_android_camerax/CHANGELOG.md +++ b/packages/camera/camera_android_camerax/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.22 + +* Implements `setDescriptionWhileRecording`. + ## 0.6.21+2 * Bumps com.google.guava:guava from 33.4.8-android to 33.5.0-android. diff --git a/packages/camera/camera_android_camerax/README.md b/packages/camera/camera_android_camerax/README.md index b6a61aa1cb7..5cf163e19a3 100644 --- a/packages/camera/camera_android_camerax/README.md +++ b/packages/camera/camera_android_camerax/README.md @@ -34,10 +34,6 @@ use cases, the plugin behaves according to the following: video recording and image streaming is supported, but concurrent video recording, image streaming, and image capture is not supported. -### `setDescriptionWhileRecording` is unimplemented [Issue #148013][148013] -`setDescriptionWhileRecording`, used to switch cameras while recording video, is currently unimplemented -due to this not currently being supported by CameraX. - ### 240p resolution configuration for video recording 240p resolution configuration for video recording is unsupported by CameraX, and thus, @@ -73,6 +69,12 @@ in the merged Android manifest of your app, then take the following steps to rem tools:node="remove" /> ``` +### Notes on video capture + +#### Setting description while recording +To avoid cancelling any active recording when calling `setDescriptionWhileRecording`, +you must start the recording with `startVideoCapturing` with `enablePersistentRecording` set to `true`. + ### Notes on image streaming #### Allowing image streaming in the background diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt index bae9bd93df6..a1b61c6daef 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt @@ -3652,6 +3652,22 @@ abstract class PigeonApiPendingRecording( initialMuted: Boolean ): androidx.camera.video.PendingRecording + /** + * Configures the recording to be a persistent recording. + * + * A persistent recording will only be stopped by explicitly calling [Recording.stop] or + * [Recording.close] and will ignore events that would normally cause recording to stop, such as + * lifecycle events or explicit unbinding of a [VideoCapture] use case that the recording's + * Recorder is attached to. + * + * To switch to a different camera stream while a recording is in progress, first create the + * recording as persistent recording, then rebind the [VideoCapture] it's associated with to a + * different camera. + */ + abstract fun asPersistentRecording( + pigeon_instance: androidx.camera.video.PendingRecording + ): androidx.camera.video.PendingRecording + /** Starts the recording, making it an active recording. */ abstract fun start( pigeon_instance: androidx.camera.video.PendingRecording, @@ -3685,6 +3701,28 @@ abstract class PigeonApiPendingRecording( channel.setMessageHandler(null) } } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.camera_android_camerax.PendingRecording.asPersistentRecording", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pigeon_instanceArg = args[0] as androidx.camera.video.PendingRecording + val wrapped: List = + try { + listOf(api.asPersistentRecording(pigeon_instanceArg)) + } catch (exception: Throwable) { + CameraXLibraryPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel( diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingProxyApi.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingProxyApi.java index 2c6b5208a9a..8cf28799b91 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingProxyApi.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingProxyApi.java @@ -7,6 +7,7 @@ import android.Manifest; import android.content.pm.PackageManager; import androidx.annotation.NonNull; +import androidx.camera.video.ExperimentalPersistentRecording; import androidx.camera.video.PendingRecording; import androidx.camera.video.Recording; import androidx.core.content.ContextCompat; @@ -27,6 +28,13 @@ public ProxyApiRegistrar getPigeonRegistrar() { return (ProxyApiRegistrar) super.getPigeonRegistrar(); } + @ExperimentalPersistentRecording + @NonNull + @Override + public PendingRecording asPersistentRecording(PendingRecording pigeonInstance) { + return pigeonInstance.asPersistentRecording(); + } + @NonNull @Override public PendingRecording withAudioEnabled(PendingRecording pigeonInstance, boolean initialMuted) { diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PendingRecordingTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PendingRecordingTest.java index e67c5bec6e5..883cc8aefcf 100644 --- a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PendingRecordingTest.java +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PendingRecordingTest.java @@ -83,6 +83,19 @@ public void withAudioEnabled_doesNotEnableAudioWhenAudioNotRequested() { verify(instance).withAudioEnabled(true); } + @Test + public void asPersistentRecording_returnsPersistentRecordingInstance() { + final PigeonApiPendingRecording api = + new TestProxyApiRegistrar().getPigeonApiPendingRecording(); + final PendingRecording instance = mock(PendingRecording.class); + final PendingRecording persistentInstance = mock(PendingRecording.class); + + when(instance.asPersistentRecording()).thenReturn(persistentInstance); + + assertEquals(persistentInstance, api.asPersistentRecording(instance)); + verify(instance).asPersistentRecording(); + } + @Test public void start_callsStartOnInstance() { final PigeonApiPendingRecording api = diff --git a/packages/camera/camera_android_camerax/example/integration_test/integration_test.dart b/packages/camera/camera_android_camerax/example/integration_test/integration_test.dart index e93b9a77ac0..904e7491e38 100644 --- a/packages/camera/camera_android_camerax/example/integration_test/integration_test.dart +++ b/packages/camera/camera_android_camerax/example/integration_test/integration_test.dart @@ -226,4 +226,49 @@ void main() { expect(duration, lessThan(recordingTime - timePaused)); }, skip: skipFor157181); + + testWidgets('Set description while recording captures full video', ( + WidgetTester tester, + ) async { + final List cameras = await availableCameras(); + if (cameras.length < 2) { + return; + } + + final CameraController controller = CameraController( + cameras[0], + mediaSettings: const MediaSettings( + resolutionPreset: ResolutionPreset.medium, + enableAudio: true, + ), + ); + await controller.initialize(); + await controller.prepareForVideoRecording(); + + await controller.startVideoRecording(); + + await controller.setDescription(cameras[1]); + + await tester.pumpAndSettle(const Duration(seconds: 4)); + + await controller.setDescription(cameras[0]); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final XFile file = await controller.stopVideoRecording(); + + final File videoFile = File(file.path); + final VideoPlayerController videoController = VideoPlayerController.file( + videoFile, + ); + await videoController.initialize(); + final int duration = videoController.value.duration.inMilliseconds; + await videoController.dispose(); + + expect( + duration, + greaterThanOrEqualTo(const Duration(seconds: 4).inMilliseconds), + ); + await controller.dispose(); + }); } diff --git a/packages/camera/camera_android_camerax/example/lib/camera_controller.dart b/packages/camera/camera_android_camerax/example/lib/camera_controller.dart index 1a6ef066e1d..d66f321538e 100644 --- a/packages/camera/camera_android_camerax/example/lib/camera_controller.dart +++ b/packages/camera/camera_android_camerax/example/lib/camera_controller.dart @@ -49,6 +49,7 @@ class CameraValue { required this.exposurePointSupported, required this.focusPointSupported, required this.deviceOrientation, + required this.description, this.lockedCaptureOrientation, this.recordingOrientation, this.isPreviewPaused = false, @@ -56,7 +57,7 @@ class CameraValue { }) : _isRecordingPaused = isRecordingPaused; /// Creates a new camera controller state for an uninitialized controller. - const CameraValue.uninitialized() + const CameraValue.uninitialized(CameraDescription description) : this( isInitialized: false, isRecordingVideo: false, @@ -70,6 +71,7 @@ class CameraValue { focusPointSupported: false, deviceOrientation: DeviceOrientation.portraitUp, isPreviewPaused: false, + description: description, ); /// True after [CameraController.initialize] has completed successfully. @@ -143,6 +145,9 @@ class CameraValue { /// The orientation of the currently running video recording. final DeviceOrientation? recordingOrientation; + /// The properties of the camera device controlled by this controller. + final CameraDescription description; + /// Creates a modified copy of the object. /// /// Explicitly specified fields get the specified value, all other fields get @@ -164,6 +169,7 @@ class CameraValue { Optional? lockedCaptureOrientation, Optional? recordingOrientation, bool? isPreviewPaused, + CameraDescription? description, Optional? previewPauseOrientation, }) { return CameraValue( @@ -190,6 +196,7 @@ class CameraValue { ? this.recordingOrientation : recordingOrientation.orNull, isPreviewPaused: isPreviewPaused ?? this.isPreviewPaused, + description: description ?? this.description, previewPauseOrientation: previewPauseOrientation == null ? this.previewPauseOrientation @@ -214,7 +221,8 @@ class CameraValue { 'lockedCaptureOrientation: $lockedCaptureOrientation, ' 'recordingOrientation: $recordingOrientation, ' 'isPreviewPaused: $isPreviewPaused, ' - 'previewPausedOrientation: $previewPauseOrientation)'; + 'previewPausedOrientation: $previewPauseOrientation, ' + 'description: $description)'; } } @@ -228,13 +236,13 @@ class CameraValue { class CameraController extends ValueNotifier { /// Creates a new camera controller in an uninitialized state. CameraController( - this.description, { + CameraDescription description, { this.mediaSettings, this.imageFormatGroup, - }) : super(const CameraValue.uninitialized()); + }) : super(CameraValue.uninitialized(description)); /// The properties of the camera device controlled by this controller. - final CameraDescription description; + CameraDescription get description => value.description; /// The media settings this controller is targeting. /// @@ -273,7 +281,12 @@ class CameraController extends ValueNotifier { /// Initializes the camera on the device. /// /// Throws a [CameraException] if the initialization fails. - Future initialize() async { + Future initialize() => _initializeWithDescription(description); + + /// Initializes the camera on the device with the specified description. + /// + /// Throws a [CameraException] if the initialization fails. + Future _initializeWithDescription(CameraDescription description) async { if (_isDisposed) { throw CameraException( 'Disposed CameraController', @@ -489,8 +502,14 @@ class CameraController extends ValueNotifier { /// /// The video is returned as a [XFile] after calling [stopVideoRecording]. /// Throws a [CameraException] if the capture fails. + /// + /// `enablePersistentRecording` parameter configures the recording to be a persistent recording. + /// A persistent recording will only be stopped by explicitly calling [stopVideoRecording] + /// and will ignore events that would normally cause recording to stop, + /// such as lifecycle events or explicit calls to [setDescription] while recording is in progress. Future startVideoRecording({ onLatestImageAvailable? onAvailable, + bool enablePersistentRecording = true, }) async { _throwIfNotInitialized('startVideoRecording'); if (value.isRecordingVideo) { @@ -509,7 +528,11 @@ class CameraController extends ValueNotifier { try { await CameraPlatform.instance.startVideoCapturing( - VideoCaptureOptions(_cameraId, streamCallback: streamCallback), + VideoCaptureOptions( + _cameraId, + streamCallback: streamCallback, + enablePersistentRecording: enablePersistentRecording, + ), ); value = value.copyWith( isRecordingVideo: true, @@ -592,6 +615,21 @@ class CameraController extends ValueNotifier { } } + /// Sets the description of the camera. + /// + /// To avoid cancelling any active recording when calling this method, + /// start the recording with [startVideoRecording] with `enablePersistentRecording` to `true`. + /// + /// Throws a [CameraException] if setting the description fails. + Future setDescription(CameraDescription description) async { + if (value.isRecordingVideo) { + await CameraPlatform.instance.setDescriptionWhileRecording(description); + value = value.copyWith(description: description); + } else { + await _initializeWithDescription(description); + } + } + /// Returns a widget showing a live camera preview. Widget buildPreview() { _throwIfNotInitialized('buildPreview'); diff --git a/packages/camera/camera_android_camerax/example/lib/main.dart b/packages/camera/camera_android_camerax/example/lib/main.dart index 2903cd57b1e..55a689cdeaf 100644 --- a/packages/camera/camera_android_camerax/example/lib/main.dart +++ b/packages/camera/camera_android_camerax/example/lib/main.dart @@ -123,7 +123,7 @@ class _CameraExampleHomeState extends State if (state == AppLifecycleState.inactive) { cameraController.dispose(); } else if (state == AppLifecycleState.resumed) { - onNewCameraSelected(cameraController.description); + _initializeCameraController(cameraController.description); } } // #enddocregion AppLifecycle @@ -611,10 +611,7 @@ class _CameraExampleHomeState extends State title: Icon(getCameraLensIcon(cameraDescription.lensDirection)), groupValue: controller?.description, value: cameraDescription, - onChanged: - controller != null && controller!.value.isRecordingVideo - ? null - : onChanged, + onChanged: onChanged, ), ), ); @@ -648,17 +645,16 @@ class _CameraExampleHomeState extends State } Future onNewCameraSelected(CameraDescription cameraDescription) async { - 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(); + if (controller != null) { + return controller!.setDescription(cameraDescription); + } else { + return _initializeCameraController(cameraDescription); } + } + Future _initializeCameraController( + CameraDescription cameraDescription, + ) async { final CameraController cameraController = CameraController( cameraDescription, mediaSettings: MediaSettings( diff --git a/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart b/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart index fb0b55434a0..0e0e9d7ebc9 100644 --- a/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart +++ b/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart @@ -927,11 +927,52 @@ class AndroidCameraCameraX extends CameraPlatform { /// Sets the active camera while recording. /// - /// Currently unsupported, so is a no-op. + /// To avoid cancelling any active recording when this method is called, + /// you must start the recording with [startVideoCapturing] + /// with `enablePersistentRecording` set to `true`. @override - Future setDescriptionWhileRecording(CameraDescription description) { - // TODO(camsim99): Implement this feature, see https://github.com/flutter/flutter/issues/148013. - return Future.value(); + Future setDescriptionWhileRecording( + CameraDescription description, + ) async { + if (recording == null) { + cameraErrorStreamController.add( + 'Camera description not set. No active video recording.', + ); + return; + } + final CameraInfo? chosenCameraInfo = _savedCameras[description.name]; + + // Save CameraSelector that matches cameraDescription. + final LensFacing cameraSelectorLensDirection = + _getCameraSelectorLensDirection(description.lensDirection); + cameraIsFrontFacing = cameraSelectorLensDirection == LensFacing.front; + cameraSelector = proxy.newCameraSelector( + cameraInfoForFilter: chosenCameraInfo, + ); + + // Unbind all use cases and rebind to new CameraSelector + final List useCases = [videoCapture!]; + if (!_previewIsPaused) { + useCases.add(preview!); + } + if (imageCapture != null && + await processCameraProvider!.isBound(imageCapture!)) { + useCases.add(imageCapture!); + } + if (imageAnalysis != null && + await processCameraProvider!.isBound(imageAnalysis!)) { + useCases.add(imageAnalysis!); + } + await processCameraProvider?.unbindAll(); + camera = await processCameraProvider?.bindToLifecycle( + cameraSelector!, + useCases, + ); + + // Retrieve info required for correcting the rotation of the camera preview + sensorOrientationDegrees = description.sensorOrientation.toDouble(); + + await _updateCameraInfoAndLiveCameraState(_flutterSurfaceTextureId); } /// Resume the paused preview for the selected camera. @@ -1140,6 +1181,10 @@ class AndroidCameraCameraX extends CameraPlatform { ); pendingRecording = await recorder!.prepareRecording(videoOutputPath!); + if (options.enablePersistentRecording) { + pendingRecording = await pendingRecording?.asPersistentRecording(); + } + // Enable/disable recording audio as requested. If enabling audio is requested // and permission was not granted when the camera was created, then recording // audio will be disabled to respect the denied permission. diff --git a/packages/camera/camera_android_camerax/lib/src/camerax_library.g.dart b/packages/camera/camera_android_camerax/lib/src/camerax_library.g.dart index e81c5ed2ce9..de14549c066 100644 --- a/packages/camera/camera_android_camerax/lib/src/camerax_library.g.dart +++ b/packages/camera/camera_android_camerax/lib/src/camerax_library.g.dart @@ -4404,6 +4404,50 @@ class PendingRecording extends PigeonInternalProxyApiBaseClass { } } + /// Configures the recording to be a persistent recording. + /// + /// A persistent recording will only be stopped by explicitly calling [Recording.stop] or [Recording.close] + /// and will ignore events that would normally cause recording to stop, such as lifecycle events + /// or explicit unbinding of a [VideoCapture] use case that the recording's Recorder is attached to. + /// + /// To switch to a different camera stream while a recording is in progress, + /// first create the recording as persistent recording, + /// then rebind the [VideoCapture] it's associated with to a different camera. + Future asPersistentRecording() async { + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = + _pigeonVar_codecPendingRecording; + final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; + const String pigeonVar_channelName = + 'dev.flutter.pigeon.camera_android_camerax.PendingRecording.asPersistentRecording'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [this], + ); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as PendingRecording?)!; + } + } + /// Starts the recording, making it an active recording. Future start(VideoRecordEventListener listener) async { final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = diff --git a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart index d0e78176022..35dacb169d5 100644 --- a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart +++ b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart @@ -523,6 +523,17 @@ abstract class PendingRecording { /// Enables/disables audio to be recorded for this recording. PendingRecording withAudioEnabled(bool initialMuted); + /// Configures the recording to be a persistent recording. + /// + /// A persistent recording will only be stopped by explicitly calling [Recording.stop] or [Recording.close] + /// and will ignore events that would normally cause recording to stop, such as lifecycle events + /// or explicit unbinding of a [VideoCapture] use case that the recording's Recorder is attached to. + /// + /// To switch to a different camera stream while a recording is in progress, + /// first create the recording as persistent recording, + /// then rebind the [VideoCapture] it's associated with to a different camera. + PendingRecording asPersistentRecording(); + /// Starts the recording, making it an active recording. Recording start(VideoRecordEventListener listener); } diff --git a/packages/camera/camera_android_camerax/pubspec.yaml b/packages/camera/camera_android_camerax/pubspec.yaml index 43f5dd87431..f8b1ca62620 100644 --- a/packages/camera/camera_android_camerax/pubspec.yaml +++ b/packages/camera/camera_android_camerax/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_android_camerax description: Android implementation of the camera plugin using the CameraX library. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android_camerax issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.6.21+2 +version: 0.6.22 environment: sdk: ^3.8.1 @@ -19,7 +19,7 @@ flutter: dependencies: async: ^2.5.0 - camera_platform_interface: ^2.9.0 + camera_platform_interface: ^2.11.0 flutter: sdk: flutter meta: ^1.7.0 diff --git a/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart b/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart index 6e4e2dd1597..d8cfbc44544 100644 --- a/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart +++ b/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart @@ -3346,6 +3346,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!enableAudio), ).thenAnswer((_) async => mockPendingRecordingWithAudio); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockPendingRecordingWithAudio.start(any), ).thenAnswer((_) async => mockRecording); @@ -3512,6 +3515,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!camera.enableRecordingAudio), ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockPendingRecording.start(any), ).thenAnswer((_) async => mockRecording); @@ -3567,6 +3573,7 @@ void main() { verifyNoMoreInteractions(camera.recorder); verify(mockPendingRecording.start(any)).called(1); verify(mockPendingRecording.withAudioEnabled(any)).called(1); + verify(mockPendingRecording.asPersistentRecording()).called(1); verifyNoMoreInteractions(mockPendingRecording); }, ); @@ -3701,6 +3708,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!camera.enableRecordingAudio), ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockProcessCameraProvider.bindToLifecycle(any, any), ).thenAnswer((_) => Future.value(camera.camera)); @@ -3850,6 +3860,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!camera.enableRecordingAudio), ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockPendingRecording.start(any), ).thenAnswer((_) async => mockRecording); @@ -3962,175 +3975,792 @@ void main() { final MockVideoCapture videoCapture = MockVideoCapture(); const String videoOutputPath = '/test/output/path'; - // Set directly for test versus calling createCamera and startVideoCapturing. - camera.processCameraProvider = processCameraProvider; - camera.recording = recording; - camera.videoCapture = videoCapture; - camera.videoOutputPath = videoOutputPath; + // Set directly for test versus calling createCamera and startVideoCapturing. + camera.processCameraProvider = processCameraProvider; + camera.recording = recording; + camera.videoCapture = videoCapture; + camera.videoOutputPath = videoOutputPath; + + // Tell plugin that videoCapture use case was bound to start recording. + when( + camera.processCameraProvider!.isBound(videoCapture), + ).thenAnswer((_) async => true); + + // Simulate video recording being finalized so stopVideoRecording completes. + AndroidCameraCameraX.videoRecordingEventStreamController.add( + VideoRecordEventFinalize.pigeon_detached( + pigeon_instanceManager: PigeonInstanceManager( + onWeakReferenceRemoved: (_) {}, + ), + ), + ); + + final XFile file = await camera.stopVideoRecording(0); + expect(file.path, videoOutputPath); + + // Verify that recording stops. + verify(recording.close()); + verifyNoMoreInteractions(recording); + }); + + test('stopVideoRecording throws a camera exception if ' + 'no recording is in progress', () async { + final AndroidCameraCameraX camera = AndroidCameraCameraX(); + const String videoOutputPath = '/test/output/path'; + + // Set directly for test versus calling startVideoCapturing. + camera.recording = null; + camera.videoOutputPath = videoOutputPath; + + await expectLater(() async { + await camera.stopVideoRecording(0); + }, throwsA(isA())); + }); + + test('stopVideoRecording throws a camera exception if ' + 'videoOutputPath is null, and sets recording to null', () async { + final AndroidCameraCameraX camera = AndroidCameraCameraX(); + final MockRecording mockRecording = MockRecording(); + final MockVideoCapture mockVideoCapture = MockVideoCapture(); + + // Set directly for test versus calling startVideoCapturing. + camera.processCameraProvider = MockProcessCameraProvider(); + camera.recording = mockRecording; + camera.videoOutputPath = null; + camera.videoCapture = mockVideoCapture; + + // Tell plugin that videoCapture use case was bound to start recording. + when( + camera.processCameraProvider!.isBound(mockVideoCapture), + ).thenAnswer((_) async => true); + + await expectLater(() async { + // Simulate video recording being finalized so stopVideoRecording completes. + AndroidCameraCameraX.videoRecordingEventStreamController.add( + VideoRecordEventFinalize.pigeon_detached( + pigeon_instanceManager: PigeonInstanceManager( + onWeakReferenceRemoved: (_) {}, + ), + ), + ); + await camera.stopVideoRecording(0); + }, throwsA(isA())); + expect(camera.recording, null); + }); + + test('calling stopVideoRecording twice stops the recording ' + 'and then throws a CameraException', () async { + final AndroidCameraCameraX camera = AndroidCameraCameraX(); + final MockRecording recording = MockRecording(); + final MockProcessCameraProvider processCameraProvider = + MockProcessCameraProvider(); + final MockVideoCapture videoCapture = MockVideoCapture(); + const String videoOutputPath = '/test/output/path'; + + // Set directly for test versus calling createCamera and startVideoCapturing. + camera.processCameraProvider = processCameraProvider; + camera.recording = recording; + camera.videoCapture = videoCapture; + camera.videoOutputPath = videoOutputPath; + + // Simulate video recording being finalized so stopVideoRecording completes. + AndroidCameraCameraX.videoRecordingEventStreamController.add( + VideoRecordEventFinalize.pigeon_detached( + pigeon_instanceManager: PigeonInstanceManager( + onWeakReferenceRemoved: (_) {}, + ), + ), + ); + + final XFile file = await camera.stopVideoRecording(0); + expect(file.path, videoOutputPath); + + await expectLater(() async { + await camera.stopVideoRecording(0); + }, throwsA(isA())); + }); + + test( + 'VideoCapture use case is unbound from lifecycle when video recording stops', + () async { + final AndroidCameraCameraX camera = AndroidCameraCameraX(); + final MockRecording recording = MockRecording(); + final MockProcessCameraProvider processCameraProvider = + MockProcessCameraProvider(); + final MockVideoCapture videoCapture = MockVideoCapture(); + const String videoOutputPath = '/test/output/path'; + + // Set directly for test versus calling createCamera and startVideoCapturing. + camera.processCameraProvider = processCameraProvider; + camera.recording = recording; + camera.videoCapture = videoCapture; + camera.videoOutputPath = videoOutputPath; + + // Tell plugin that videoCapture use case was bound to start recording. + when( + camera.processCameraProvider!.isBound(videoCapture), + ).thenAnswer((_) async => true); + + // Simulate video recording being finalized so stopVideoRecording completes. + AndroidCameraCameraX.videoRecordingEventStreamController.add( + VideoRecordEventFinalize.pigeon_detached( + pigeon_instanceManager: PigeonInstanceManager( + onWeakReferenceRemoved: (_) {}, + ), + ), + ); + + await camera.stopVideoRecording(90); + verify(processCameraProvider.unbind([videoCapture])); + + // Verify that recording stops. + verify(recording.close()); + verifyNoMoreInteractions(recording); + }, + ); + + test('setDescriptionWhileRecording changes the camera description', () async { + final AndroidCameraCameraX camera = AndroidCameraCameraX(); + final MockRecording mockRecording = MockRecording(); + final MockPendingRecording mockPendingRecording = MockPendingRecording(); + final MockRecorder mockRecorder = MockRecorder(); + + const int testSensorOrientation = 90; + const CameraDescription testBackCameraDescription = CameraDescription( + name: 'Camera 0', + lensDirection: CameraLensDirection.back, + sensorOrientation: testSensorOrientation, + ); + const CameraDescription testFrontCameraDescription = CameraDescription( + name: 'Camera 1', + lensDirection: CameraLensDirection.front, + sensorOrientation: testSensorOrientation, + ); + + // Mock/Detached objects for (typically attached) objects created by + // createCamera. + final MockProcessCameraProvider mockProcessCameraProvider = + MockProcessCameraProvider(); + final MockPreview mockPreview = MockPreview(); + final MockCamera mockCamera = MockCamera(); + final MockCamera newMockCamera = MockCamera(); + final MockLiveCameraState mockLiveCameraState = MockLiveCameraState(); + final MockLiveCameraState newMockLiveCameraState = MockLiveCameraState(); + final MockCameraInfo mockCameraInfo = MockCameraInfo(); + final MockCameraControl mockCameraControl = MockCameraControl(); + final MockImageCapture mockImageCapture = MockImageCapture(); + final MockImageAnalysis mockImageAnalysis = MockImageAnalysis(); + final MockVideoCapture mockVideoCapture = MockVideoCapture(); + final MockCameraSelector mockBackCameraSelector = MockCameraSelector(); + final MockCameraSelector mockFrontCameraSelector = MockCameraSelector(); + final MockCameraInfo mockFrontCameraInfo = MockCameraInfo(); + final MockCameraInfo mockBackCameraInfo = MockCameraInfo(); + final MockCameraCharacteristicsKey mockCameraCharacteristicsKey = + MockCameraCharacteristicsKey(); + + const String outputPath = 'file/output.mp4'; + + camera.proxy = CameraXProxy( + newPreview: + ({ + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + ResolutionSelector? resolutionSelector, + int? targetRotation, + }) { + when( + mockPreview.setSurfaceProvider(any), + ).thenAnswer((_) async => 19); + final ResolutionInfo testResolutionInfo = + ResolutionInfo.pigeon_detached(resolution: MockCameraSize()); + when( + mockPreview.surfaceProducerHandlesCropAndRotation(), + ).thenAnswer((_) async => false); + when( + mockPreview.resolutionSelector, + ).thenReturn(resolutionSelector); + when( + mockPreview.getResolutionInfo(), + ).thenAnswer((_) async => testResolutionInfo); + return mockPreview; + }, + newImageCapture: + ({ + CameraXFlashMode? flashMode, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + ResolutionSelector? resolutionSelector, + int? targetRotation, + }) { + return mockImageCapture; + }, + newRecorder: + ({ + int? aspectRatio, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + QualitySelector? qualitySelector, + int? targetVideoEncodingBitRate, + }) { + when( + mockRecorder.prepareRecording(outputPath), + ).thenAnswer((_) async => mockPendingRecording); + return mockRecorder; + }, + withOutputVideoCapture: + ({ + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + required VideoOutput videoOutput, + }) { + return mockVideoCapture; + }, + newImageAnalysis: + ({ + int? outputImageFormat, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + ResolutionSelector? resolutionSelector, + int? targetRotation, + }) { + return mockImageAnalysis; + }, + newCameraSelector: + ({ + LensFacing? requireLensFacing, + CameraInfo? cameraInfoForFilter, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + if (cameraInfoForFilter == mockFrontCameraInfo) { + return mockFrontCameraSelector; + } + return mockBackCameraSelector; + }, + newDeviceOrientationManager: + ({ + required void Function(DeviceOrientationManager, String) + onDeviceOrientationChanged, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + final MockDeviceOrientationManager manager = + MockDeviceOrientationManager(); + when(manager.getUiOrientation()).thenAnswer((_) async { + return 'PORTRAIT_UP'; + }); + return manager; + }, + fromCamera2CameraInfo: + ({ + required CameraInfo cameraInfo, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + final MockCamera2CameraInfo camera2cameraInfo = + MockCamera2CameraInfo(); + when( + camera2cameraInfo.getCameraCharacteristic(any), + ).thenAnswer((_) async => InfoSupportedHardwareLevel.limited); + return camera2cameraInfo; + }, + sensorOrientationCameraCharacteristics: () { + return mockCameraCharacteristicsKey; + }, + newObserver: + ({ + required void Function(Observer, T) onChanged, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + return Observer.detached( + onChanged: onChanged, + pigeon_instanceManager: PigeonInstanceManager( + onWeakReferenceRemoved: (_) {}, + ), + ); + }, + newSystemServicesManager: + ({ + required void Function(SystemServicesManager, String) + onCameraError, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + final MockSystemServicesManager mockSystemServicesManager = + MockSystemServicesManager(); + when( + mockSystemServicesManager.getTempFilePath( + camera.videoPrefix, + '.temp', + ), + ).thenAnswer((_) async => outputPath); + return mockSystemServicesManager; + }, + newVideoRecordEventListener: + ({ + required void Function(VideoRecordEventListener, VideoRecordEvent) + onEvent, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + return VideoRecordEventListener.pigeon_detached( + onEvent: onEvent, + pigeon_instanceManager: PigeonInstanceManager( + onWeakReferenceRemoved: (_) {}, + ), + ); + }, + infoSupportedHardwareLevelCameraCharacteristics: () { + return MockCameraCharacteristicsKey(); + }, + ); + + // mock functions + when(mockProcessCameraProvider.getAvailableCameraInfos()).thenAnswer( + (_) async => [mockBackCameraInfo, mockFrontCameraInfo], + ); + when( + mockProcessCameraProvider.bindToLifecycle(any, any), + ).thenAnswer((_) async => mockCamera); + when( + mockBackCameraSelector.filter([mockBackCameraInfo]), + ).thenAnswer((_) async => [mockBackCameraInfo]); + when( + mockBackCameraSelector.filter([mockFrontCameraInfo]), + ).thenAnswer((_) async => [mockFrontCameraInfo]); + when( + mockFrontCameraSelector.filter([mockBackCameraInfo]), + ).thenAnswer((_) async => [mockBackCameraInfo]); + when( + mockFrontCameraSelector.filter([mockFrontCameraInfo]), + ).thenAnswer((_) async => [mockFrontCameraInfo]); + + camera.processCameraProvider = mockProcessCameraProvider; + camera.liveCameraState = mockLiveCameraState; + camera.enableRecordingAudio = false; + when( + mockPendingRecording.withAudioEnabled(any), + ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.start(any), + ).thenAnswer((_) async => mockRecording); + when( + camera.processCameraProvider!.isBound(mockImageCapture), + ).thenAnswer((_) async => true); + when( + camera.processCameraProvider!.isBound(mockImageAnalysis), + ).thenAnswer((_) async => true); + when(mockCamera.getCameraInfo()).thenAnswer((_) async => mockCameraInfo); + when(mockCamera.cameraControl).thenAnswer((_) => mockCameraControl); + when( + camera.processCameraProvider?.bindToLifecycle( + mockFrontCameraSelector, + [ + mockVideoCapture, + mockPreview, + mockImageCapture, + mockImageAnalysis, + ], + ), + ).thenAnswer((_) async => newMockCamera); + when( + newMockCamera.getCameraInfo(), + ).thenAnswer((_) async => mockCameraInfo); + when(newMockCamera.cameraControl).thenReturn(mockCameraControl); + when( + mockCameraInfo.getCameraState(), + ).thenAnswer((_) async => newMockLiveCameraState); + + // Simulate video recording being started so startVideoRecording completes. + AndroidCameraCameraX.videoRecordingEventStreamController.add( + VideoRecordEventStart.pigeon_detached( + pigeon_instanceManager: PigeonInstanceManager( + onWeakReferenceRemoved: (_) {}, + ), + ), + ); + + await camera.availableCameras(); + + final int flutterSurfaceTextureId = await camera.createCameraWithSettings( + testBackCameraDescription, + const MediaSettings(enableAudio: true), + ); + await camera.initializeCamera(flutterSurfaceTextureId); + + await camera.startVideoCapturing( + VideoCaptureOptions(flutterSurfaceTextureId), + ); + await camera.setDescriptionWhileRecording(testFrontCameraDescription); + + //verify front camera selected and camera properties updated + verify(camera.processCameraProvider?.unbindAll()).called(2); + verify( + camera.processCameraProvider?.bindToLifecycle( + mockFrontCameraSelector, + [ + mockVideoCapture, + mockPreview, + mockImageCapture, + mockImageAnalysis, + ], + ), + ).called(1); + expect(camera.camera, equals(newMockCamera)); + expect(camera.cameraInfo, equals(mockCameraInfo)); + expect(camera.cameraControl, equals(mockCameraControl)); + verify(mockLiveCameraState.removeObservers()); + for (final dynamic observer in verify( + newMockLiveCameraState.observe(captureAny), + ).captured) { + expect( + await testCameraClosingObserver( + camera, + flutterSurfaceTextureId, + observer as Observer, + ), + isTrue, + ); + } + + //verify back camera selected + await camera.setDescriptionWhileRecording(testBackCameraDescription); + verify( + camera.processCameraProvider?.bindToLifecycle( + mockBackCameraSelector, + [ + mockVideoCapture, + mockPreview, + mockImageCapture, + mockImageAnalysis, + ], + ), + ).called(1); + }); + + test('setDescriptionWhileRecording does not resume paused preview', () async { + final AndroidCameraCameraX camera = AndroidCameraCameraX(); + final MockRecording mockRecording = MockRecording(); + final MockPendingRecording mockPendingRecording = MockPendingRecording(); + final MockRecorder mockRecorder = MockRecorder(); + + const int testSensorOrientation = 90; + const CameraDescription testBackCameraDescription = CameraDescription( + name: 'Camera 0', + lensDirection: CameraLensDirection.back, + sensorOrientation: testSensorOrientation, + ); + const CameraDescription testFrontCameraDescription = CameraDescription( + name: 'Camera 1', + lensDirection: CameraLensDirection.front, + sensorOrientation: testSensorOrientation, + ); + + // Mock/Detached objects for (typically attached) objects created by + // createCamera. + final MockProcessCameraProvider mockProcessCameraProvider = + MockProcessCameraProvider(); + final MockPreview mockPreview = MockPreview(); + final MockCamera mockCamera = MockCamera(); + final MockCameraInfo mockCameraInfo = MockCameraInfo(); + final MockCameraControl mockCameraControl = MockCameraControl(); + final MockImageCapture mockImageCapture = MockImageCapture(); + final MockImageAnalysis mockImageAnalysis = MockImageAnalysis(); + final MockVideoCapture mockVideoCapture = MockVideoCapture(); + final MockCameraSelector mockBackCameraSelector = MockCameraSelector(); + final MockCameraSelector mockFrontCameraSelector = MockCameraSelector(); + final MockCameraInfo mockFrontCameraInfo = MockCameraInfo(); + final MockCameraInfo mockBackCameraInfo = MockCameraInfo(); + final MockCameraCharacteristicsKey mockCameraCharacteristicsKey = + MockCameraCharacteristicsKey(); + + const String outputPath = 'file/output.mp4'; + + camera.proxy = CameraXProxy( + newPreview: + ({ + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + ResolutionSelector? resolutionSelector, + int? targetRotation, + }) { + when( + mockPreview.setSurfaceProvider(any), + ).thenAnswer((_) async => 19); + final ResolutionInfo testResolutionInfo = + ResolutionInfo.pigeon_detached(resolution: MockCameraSize()); + when( + mockPreview.surfaceProducerHandlesCropAndRotation(), + ).thenAnswer((_) async => false); + when( + mockPreview.resolutionSelector, + ).thenReturn(resolutionSelector); + when( + mockPreview.getResolutionInfo(), + ).thenAnswer((_) async => testResolutionInfo); + return mockPreview; + }, + newImageCapture: + ({ + CameraXFlashMode? flashMode, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + ResolutionSelector? resolutionSelector, + int? targetRotation, + }) { + return mockImageCapture; + }, + newRecorder: + ({ + int? aspectRatio, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + QualitySelector? qualitySelector, + int? targetVideoEncodingBitRate, + }) { + when( + mockRecorder.prepareRecording(outputPath), + ).thenAnswer((_) async => mockPendingRecording); + return mockRecorder; + }, + withOutputVideoCapture: + ({ + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + required VideoOutput videoOutput, + }) { + return mockVideoCapture; + }, + newImageAnalysis: + ({ + int? outputImageFormat, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + ResolutionSelector? resolutionSelector, + int? targetRotation, + }) { + return mockImageAnalysis; + }, + newCameraSelector: + ({ + LensFacing? requireLensFacing, + CameraInfo? cameraInfoForFilter, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + if (cameraInfoForFilter == mockFrontCameraInfo) { + return mockFrontCameraSelector; + } + return mockBackCameraSelector; + }, + newDeviceOrientationManager: + ({ + required void Function(DeviceOrientationManager, String) + onDeviceOrientationChanged, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + final MockDeviceOrientationManager manager = + MockDeviceOrientationManager(); + when(manager.getUiOrientation()).thenAnswer((_) async { + return 'PORTRAIT_UP'; + }); + return manager; + }, + fromCamera2CameraInfo: + ({ + required CameraInfo cameraInfo, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + final MockCamera2CameraInfo camera2cameraInfo = + MockCamera2CameraInfo(); + when( + camera2cameraInfo.getCameraCharacteristic(any), + ).thenAnswer((_) async => InfoSupportedHardwareLevel.limited); + return camera2cameraInfo; + }, + sensorOrientationCameraCharacteristics: () { + return mockCameraCharacteristicsKey; + }, + newObserver: + ({ + required void Function(Observer, T) onChanged, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + return Observer.detached( + onChanged: onChanged, + pigeon_instanceManager: PigeonInstanceManager( + onWeakReferenceRemoved: (_) {}, + ), + ); + }, + newSystemServicesManager: + ({ + required void Function(SystemServicesManager, String) + onCameraError, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + final MockSystemServicesManager mockSystemServicesManager = + MockSystemServicesManager(); + when( + mockSystemServicesManager.getTempFilePath( + camera.videoPrefix, + '.temp', + ), + ).thenAnswer((_) async => outputPath); + return mockSystemServicesManager; + }, + newVideoRecordEventListener: + ({ + required void Function(VideoRecordEventListener, VideoRecordEvent) + onEvent, + // ignore: non_constant_identifier_names + BinaryMessenger? pigeon_binaryMessenger, + // ignore: non_constant_identifier_names + PigeonInstanceManager? pigeon_instanceManager, + }) { + return VideoRecordEventListener.pigeon_detached( + onEvent: onEvent, + pigeon_instanceManager: PigeonInstanceManager( + onWeakReferenceRemoved: (_) {}, + ), + ); + }, + infoSupportedHardwareLevelCameraCharacteristics: () { + return MockCameraCharacteristicsKey(); + }, + ); + + // mock functions + when(mockProcessCameraProvider.getAvailableCameraInfos()).thenAnswer( + (_) async => [mockBackCameraInfo, mockFrontCameraInfo], + ); + when( + mockProcessCameraProvider.bindToLifecycle(any, any), + ).thenAnswer((_) async => mockCamera); + when( + mockBackCameraSelector.filter([mockBackCameraInfo]), + ).thenAnswer((_) async => [mockBackCameraInfo]); + when( + mockBackCameraSelector.filter([mockFrontCameraInfo]), + ).thenAnswer((_) async => [mockFrontCameraInfo]); + when( + mockFrontCameraSelector.filter([mockBackCameraInfo]), + ).thenAnswer((_) async => [mockBackCameraInfo]); + when( + mockFrontCameraSelector.filter([mockFrontCameraInfo]), + ).thenAnswer((_) async => [mockFrontCameraInfo]); - // Tell plugin that videoCapture use case was bound to start recording. + camera.processCameraProvider = mockProcessCameraProvider; + camera.enableRecordingAudio = false; when( - camera.processCameraProvider!.isBound(videoCapture), + mockPendingRecording.withAudioEnabled(any), + ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.start(any), + ).thenAnswer((_) async => mockRecording); + when( + camera.processCameraProvider!.isBound(mockImageCapture), ).thenAnswer((_) async => true); - - // Simulate video recording being finalized so stopVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventFinalize.pigeon_detached( - pigeon_instanceManager: PigeonInstanceManager( - onWeakReferenceRemoved: (_) {}, - ), - ), - ); - - final XFile file = await camera.stopVideoRecording(0); - expect(file.path, videoOutputPath); - - // Verify that recording stops. - verify(recording.close()); - verifyNoMoreInteractions(recording); - }); - - test('stopVideoRecording throws a camera exception if ' - 'no recording is in progress', () async { - final AndroidCameraCameraX camera = AndroidCameraCameraX(); - const String videoOutputPath = '/test/output/path'; - - // Set directly for test versus calling startVideoCapturing. - camera.recording = null; - camera.videoOutputPath = videoOutputPath; - - await expectLater(() async { - await camera.stopVideoRecording(0); - }, throwsA(isA())); - }); - - test('stopVideoRecording throws a camera exception if ' - 'videoOutputPath is null, and sets recording to null', () async { - final AndroidCameraCameraX camera = AndroidCameraCameraX(); - final MockRecording mockRecording = MockRecording(); - final MockVideoCapture mockVideoCapture = MockVideoCapture(); - - // Set directly for test versus calling startVideoCapturing. - camera.processCameraProvider = MockProcessCameraProvider(); - camera.recording = mockRecording; - camera.videoOutputPath = null; - camera.videoCapture = mockVideoCapture; - - // Tell plugin that videoCapture use case was bound to start recording. when( - camera.processCameraProvider!.isBound(mockVideoCapture), + camera.processCameraProvider!.isBound(mockImageAnalysis), ).thenAnswer((_) async => true); + when(mockCamera.getCameraInfo()).thenAnswer((_) async => mockCameraInfo); + when( + mockCameraInfo.getCameraState(), + ).thenAnswer((_) async => MockLiveCameraState()); + when( + mockCameraInfo.getCameraState(), + ).thenAnswer((_) async => MockLiveCameraState()); + when(mockCamera.cameraControl).thenAnswer((_) => mockCameraControl); - await expectLater(() async { - // Simulate video recording being finalized so stopVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventFinalize.pigeon_detached( - pigeon_instanceManager: PigeonInstanceManager( - onWeakReferenceRemoved: (_) {}, - ), - ), - ); - await camera.stopVideoRecording(0); - }, throwsA(isA())); - expect(camera.recording, null); - }); - - test('calling stopVideoRecording twice stops the recording ' - 'and then throws a CameraException', () async { - final AndroidCameraCameraX camera = AndroidCameraCameraX(); - final MockRecording recording = MockRecording(); - final MockProcessCameraProvider processCameraProvider = - MockProcessCameraProvider(); - final MockVideoCapture videoCapture = MockVideoCapture(); - const String videoOutputPath = '/test/output/path'; - - // Set directly for test versus calling createCamera and startVideoCapturing. - camera.processCameraProvider = processCameraProvider; - camera.recording = recording; - camera.videoCapture = videoCapture; - camera.videoOutputPath = videoOutputPath; - - // Simulate video recording being finalized so stopVideoRecording completes. + // Simulate video recording being started so startVideoRecording completes. AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventFinalize.pigeon_detached( + VideoRecordEventStart.pigeon_detached( pigeon_instanceManager: PigeonInstanceManager( onWeakReferenceRemoved: (_) {}, ), ), ); - final XFile file = await camera.stopVideoRecording(0); - expect(file.path, videoOutputPath); - - await expectLater(() async { - await camera.stopVideoRecording(0); - }, throwsA(isA())); - }); - - test( - 'VideoCapture use case is unbound from lifecycle when video recording stops', - () async { - final AndroidCameraCameraX camera = AndroidCameraCameraX(); - final MockRecording recording = MockRecording(); - final MockProcessCameraProvider processCameraProvider = - MockProcessCameraProvider(); - final MockVideoCapture videoCapture = MockVideoCapture(); - const String videoOutputPath = '/test/output/path'; - - // Set directly for test versus calling createCamera and startVideoCapturing. - camera.processCameraProvider = processCameraProvider; - camera.recording = recording; - camera.videoCapture = videoCapture; - camera.videoOutputPath = videoOutputPath; - - // Tell plugin that videoCapture use case was bound to start recording. - when( - camera.processCameraProvider!.isBound(videoCapture), - ).thenAnswer((_) async => true); - - // Simulate video recording being finalized so stopVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventFinalize.pigeon_detached( - pigeon_instanceManager: PigeonInstanceManager( - onWeakReferenceRemoved: (_) {}, - ), - ), - ); + await camera.availableCameras(); - await camera.stopVideoRecording(90); - verify(processCameraProvider.unbind([videoCapture])); + final int flutterSurfaceTextureId = await camera.createCameraWithSettings( + testBackCameraDescription, + const MediaSettings(enableAudio: true), + ); + await camera.initializeCamera(flutterSurfaceTextureId); - // Verify that recording stops. - verify(recording.close()); - verifyNoMoreInteractions(recording); - }, - ); + await camera.startVideoCapturing( + VideoCaptureOptions(flutterSurfaceTextureId), + ); - test( - 'setDescriptionWhileRecording does not make any calls involving starting video recording', - () async { - // TODO(camsim99): Modify test when implemented, see https://github.com/flutter/flutter/issues/148013. - final AndroidCameraCameraX camera = AndroidCameraCameraX(); + // pause the preview + await camera.pausePreview(flutterSurfaceTextureId); - // Set directly for test versus calling createCamera. - camera.processCameraProvider = MockProcessCameraProvider(); - camera.recorder = MockRecorder(); - camera.videoCapture = MockVideoCapture(); - camera.camera = MockCamera(); + await camera.setDescriptionWhileRecording(testFrontCameraDescription); - await camera.setDescriptionWhileRecording( - const CameraDescription( - name: 'fakeCameraName', - lensDirection: CameraLensDirection.back, - sensorOrientation: 90, - ), - ); - verifyNoMoreInteractions(camera.processCameraProvider); - verifyNoMoreInteractions(camera.recorder); - verifyNoMoreInteractions(camera.videoCapture); - verifyNoMoreInteractions(camera.camera); - }, - ); + // verify preview not bound to lifecycle + verify(camera.processCameraProvider?.unbindAll()).called(2); + verify( + camera.processCameraProvider?.bindToLifecycle( + mockFrontCameraSelector, + [mockVideoCapture, mockImageCapture, mockImageAnalysis], + ), + ).called(1); + }); }); test( @@ -6891,6 +7521,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!camera.enableRecordingAudio), ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockPendingRecording.start(any), ).thenAnswer((_) async => mockRecording); @@ -7032,6 +7665,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!camera.enableRecordingAudio), ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockPendingRecording.start(any), ).thenAnswer((_) async => mockRecording); @@ -7173,6 +7809,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!camera.enableRecordingAudio), ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockPendingRecording.start(any), ).thenAnswer((_) async => mockRecording); @@ -7334,6 +7973,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!camera.enableRecordingAudio), ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockPendingRecording.start(any), ).thenAnswer((_) async => mockRecording); @@ -7499,6 +8141,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!camera.enableRecordingAudio), ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockPendingRecording.start(any), ).thenAnswer((_) async => mockRecording); @@ -7656,6 +8301,9 @@ void main() { when( mockPendingRecording.withAudioEnabled(!camera.enableRecordingAudio), ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); when( mockPendingRecording.start(any), ).thenAnswer((_) async => mockRecording); diff --git a/packages/camera/camera_android_camerax/test/android_camera_camerax_test.mocks.dart b/packages/camera/camera_android_camerax/test/android_camera_camerax_test.mocks.dart index de4575d8041..1af9e9207f7 100644 --- a/packages/camera/camera_android_camerax/test/android_camera_camerax_test.mocks.dart +++ b/packages/camera/camera_android_camerax/test/android_camera_camerax_test.mocks.dart @@ -3029,6 +3029,25 @@ class MockPendingRecording extends _i1.Mock implements _i2.PendingRecording { ) as _i5.Future<_i2.PendingRecording>); + @override + _i5.Future<_i2.PendingRecording> asPersistentRecording() => + (super.noSuchMethod( + Invocation.method(#asPersistentRecording, []), + returnValue: _i5.Future<_i2.PendingRecording>.value( + _FakePendingRecording_39( + this, + Invocation.method(#asPersistentRecording, []), + ), + ), + returnValueForMissingStub: _i5.Future<_i2.PendingRecording>.value( + _FakePendingRecording_39( + this, + Invocation.method(#asPersistentRecording, []), + ), + ), + ) + as _i5.Future<_i2.PendingRecording>); + @override _i5.Future<_i2.Recording> start(_i2.VideoRecordEventListener? listener) => (super.noSuchMethod(