From 1ab2f5b813388daebede1f9905c89c28cfdb9efe Mon Sep 17 00:00:00 2001 From: Camille Simon <43054281+camsim99@users.noreply.github.com> Date: Fri, 10 May 2024 16:46:24 -0400 Subject: [PATCH] [camerax] Make fixes required to swap camera_android_camerax for camera_android (#6697) Makes changes needed to land https://github.com/flutter/packages/pull/6629. Specifically: - Fixes timing issue with `stopVideoRecording` such that the `Future` it returns will only complete when CameraX reports that the recording is finalized (via listening for the [finalized video recording event](https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize)) - Modifies `startVideoCapturing` such that the `Future` it returns will only complete when CameraX reports that video capturing has started (via listening for the [started video recording event](https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Start)) - Adds empty implementation and TODO for implementing `setDescriptionWhileRecording` --- .../camera_android_camerax/CHANGELOG.md | 8 ++ .../camera/camera_android_camerax/README.md | 13 +- .../camerax/GeneratedCameraXLibrary.java | 112 +++++++++++++++++- .../PendingRecordingFlutterApiImpl.java | 12 ++ .../camerax/PendingRecordingHostApiImpl.java | 12 +- .../plugins/camerax/PendingRecordingTest.java | 28 +++++ .../integration_test/integration_test.dart | 78 ++++++++++++ .../lib/src/android_camera_camerax.dart | 48 ++++++-- .../lib/src/camerax_library.g.dart | 82 ++++++++++++- .../lib/src/pending_recording.dart | 12 ++ .../pigeons/camerax_library.dart | 11 ++ .../camera_android_camerax/pubspec.yaml | 2 +- .../test/android_camera_camerax_test.dart | 110 +++++++++++++++-- .../test/test_camerax_library.g.dart | 5 + 14 files changed, 501 insertions(+), 32 deletions(-) diff --git a/packages/camera/camera_android_camerax/CHANGELOG.md b/packages/camera/camera_android_camerax/CHANGELOG.md index 17c007087abf..cfc8400a712f 100644 --- a/packages/camera/camera_android_camerax/CHANGELOG.md +++ b/packages/camera/camera_android_camerax/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.6.5 + +* Modifies `stopVideoRecording` to ensure that the method only returns when CameraX reports that the + recorded video finishes saving to a file. +* Modifies `startVideoCapturing` to ensure that the method only returns when CameraX reports that + video recording has started. +* Adds empty implementation for `setDescriptionWhileRecording` and leaves a todo to add this feature. + ## 0.6.4+1 * Adds empty implementation for `prepareForVideoRecording` since this optimization is not used on Android. diff --git a/packages/camera/camera_android_camerax/README.md b/packages/camera/camera_android_camerax/README.md index d7e31e92e03d..d68537ef1607 100644 --- a/packages/camera/camera_android_camerax/README.md +++ b/packages/camera/camera_android_camerax/README.md @@ -37,6 +37,10 @@ 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, @@ -64,11 +68,4 @@ For more information on contributing to this plugin, see [`CONTRIBUTING.md`](CON [6]: https://developer.android.com/media/camera/camerax/architecture#combine-use-cases [7]: https://developer.android.com/reference/android/hardware/camera2/CameraMetadata#INFO_SUPPORTED_HARDWARE_LEVEL_3 [8]: https://developer.android.com/reference/android/hardware/camera2/CameraMetadata#INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED -[120462]: https://github.com/flutter/flutter/issues/120462 -[125915]: https://github.com/flutter/flutter/issues/125915 -[120715]: https://github.com/flutter/flutter/issues/120715 -[120468]: https://github.com/flutter/flutter/issues/120468 -[120467]: https://github.com/flutter/flutter/issues/120467 -[125371]: https://github.com/flutter/flutter/issues/125371 -[126477]: https://github.com/flutter/flutter/issues/126477 -[127896]: https://github.com/flutter/flutter/issues/127896 +[148013]: https://github.com/flutter/flutter/issues/148013 diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/GeneratedCameraXLibrary.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/GeneratedCameraXLibrary.java index 41094fd858aa..cbfc36f35cbd 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/GeneratedCameraXLibrary.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/GeneratedCameraXLibrary.java @@ -146,6 +146,22 @@ private VideoResolutionFallbackRule(final int index) { } } + /** + * Video recording status. + * + *

See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent. + */ + public enum VideoRecordEvent { + START(0), + FINALIZE(1); + + final int index; + + private VideoRecordEvent(final int index) { + this.index = index; + } + } + /** * The types of capture request options this plugin currently supports. * @@ -558,6 +574,55 @@ ArrayList toList() { } } + /** Generated class from Pigeon that represents data sent in messages. */ + public static final class VideoRecordEventData { + private @NonNull VideoRecordEvent value; + + public @NonNull VideoRecordEvent getValue() { + return value; + } + + public void setValue(@NonNull VideoRecordEvent setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"value\" is null."); + } + this.value = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + VideoRecordEventData() {} + + public static final class Builder { + + private @Nullable VideoRecordEvent value; + + public @NonNull Builder setValue(@NonNull VideoRecordEvent setterArg) { + this.value = setterArg; + return this; + } + + public @NonNull VideoRecordEventData build() { + VideoRecordEventData pigeonReturn = new VideoRecordEventData(); + pigeonReturn.setValue(value); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList(1); + toListResult.add(value == null ? null : value.index); + return toListResult; + } + + static @NonNull VideoRecordEventData fromList(@NonNull ArrayList list) { + VideoRecordEventData pigeonResult = new VideoRecordEventData(); + Object value = list.get(0); + pigeonResult.setValue(value == null ? null : VideoRecordEvent.values()[(int) value]); + return pigeonResult; + } + } + /** * Convenience class for building [FocusMeteringAction]s with multiple metering points. * @@ -2118,6 +2183,34 @@ static void setup( } } } + + private static class PendingRecordingFlutterApiCodec extends StandardMessageCodec { + public static final PendingRecordingFlutterApiCodec INSTANCE = + new PendingRecordingFlutterApiCodec(); + + private PendingRecordingFlutterApiCodec() {} + + @Override + protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { + switch (type) { + case (byte) 128: + return VideoRecordEventData.fromList((ArrayList) readValue(buffer)); + default: + return super.readValueOfType(type, buffer); + } + } + + @Override + protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { + if (value instanceof VideoRecordEventData) { + stream.write(128); + writeValue(stream, ((VideoRecordEventData) value).toList()); + } else { + super.writeValue(stream, value); + } + } + } + /** Generated class from Pigeon that represents Flutter messages that can be called from Java. */ public static class PendingRecordingFlutterApi { private final @NonNull BinaryMessenger binaryMessenger; @@ -2133,7 +2226,7 @@ public interface Reply { } /** The codec used by PendingRecordingFlutterApi. */ static @NonNull MessageCodec getCodec() { - return new StandardMessageCodec(); + return PendingRecordingFlutterApiCodec.INSTANCE; } public void create(@NonNull Long identifierArg, @NonNull Reply callback) { @@ -2144,6 +2237,18 @@ public void create(@NonNull Long identifierArg, @NonNull Reply callback) { new ArrayList(Collections.singletonList(identifierArg)), channelReply -> callback.reply(null)); } + + public void onVideoRecordingEvent( + @NonNull VideoRecordEventData eventArg, @NonNull Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.PendingRecordingFlutterApi.onVideoRecordingEvent", + getCodec()); + channel.send( + new ArrayList(Collections.singletonList(eventArg)), + channelReply -> callback.reply(null)); + } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ public interface RecordingHostApi { @@ -4027,6 +4132,8 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { return ResolutionInfo.fromList((ArrayList) readValue(buffer)); case (byte) 134: return VideoQualityData.fromList((ArrayList) readValue(buffer)); + case (byte) 135: + return VideoRecordEventData.fromList((ArrayList) readValue(buffer)); default: return super.readValueOfType(type, buffer); } @@ -4055,6 +4162,9 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { } else if (value instanceof VideoQualityData) { stream.write(134); writeValue(stream, ((VideoQualityData) value).toList()); + } else if (value instanceof VideoRecordEventData) { + stream.write(135); + writeValue(stream, ((VideoRecordEventData) value).toList()); } else { super.writeValue(stream, value); } diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingFlutterApiImpl.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingFlutterApiImpl.java index 9b4f71080562..b3c46769ad98 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingFlutterApiImpl.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingFlutterApiImpl.java @@ -9,6 +9,8 @@ import androidx.camera.video.PendingRecording; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugins.camerax.GeneratedCameraXLibrary.PendingRecordingFlutterApi; +import io.flutter.plugins.camerax.GeneratedCameraXLibrary.VideoRecordEvent; +import io.flutter.plugins.camerax.GeneratedCameraXLibrary.VideoRecordEventData; public class PendingRecordingFlutterApiImpl extends PendingRecordingFlutterApi { private final InstanceManager instanceManager; @@ -22,4 +24,14 @@ public PendingRecordingFlutterApiImpl( void create(@NonNull PendingRecording pendingRecording, @Nullable Reply reply) { create(instanceManager.addHostCreatedInstance(pendingRecording), reply); } + + void sendVideoRecordingFinalizedEvent(@NonNull Reply reply) { + super.onVideoRecordingEvent( + new VideoRecordEventData.Builder().setValue(VideoRecordEvent.FINALIZE).build(), reply); + } + + void sendVideoRecordingStartedEvent(@NonNull Reply reply) { + super.onVideoRecordingEvent( + new VideoRecordEventData.Builder().setValue(VideoRecordEvent.START).build(), reply); + } } diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingHostApiImpl.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingHostApiImpl.java index a1d661d1d9c1..93aa39c56bee 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingHostApiImpl.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PendingRecordingHostApiImpl.java @@ -24,6 +24,8 @@ public class PendingRecordingHostApiImpl implements PendingRecordingHostApi { @VisibleForTesting @NonNull public CameraXProxy cameraXProxy = new CameraXProxy(); + @VisibleForTesting PendingRecordingFlutterApiImpl pendingRecordingFlutterApi; + @VisibleForTesting SystemServicesFlutterApiImpl systemServicesFlutterApi; @VisibleForTesting RecordingFlutterApiImpl recordingFlutterApi; @@ -37,6 +39,8 @@ public PendingRecordingHostApiImpl( this.context = context; systemServicesFlutterApi = cameraXProxy.createSystemServicesFlutterApiImpl(binaryMessenger); recordingFlutterApi = new RecordingFlutterApiImpl(binaryMessenger, instanceManager); + pendingRecordingFlutterApi = + new PendingRecordingFlutterApiImpl(binaryMessenger, instanceManager); } /** Sets the context, which is used to get the {@link Executor} needed to start the recording. */ @@ -73,10 +77,16 @@ public Executor getExecutor() { /** * Handles {@link VideoRecordEvent}s that come in during video recording. Sends any errors * encountered using {@link SystemServicesFlutterApiImpl}. + * + *

Currently only sends {@link VideoRecordEvent.Start} and {@link VideoRecordEvent.Finalize} + * events to the Dart side. */ @VisibleForTesting public void handleVideoRecordEvent(@NonNull VideoRecordEvent event) { - if (event instanceof VideoRecordEvent.Finalize) { + if (event instanceof VideoRecordEvent.Start) { + pendingRecordingFlutterApi.sendVideoRecordingStartedEvent(reply -> {}); + } else if (event instanceof VideoRecordEvent.Finalize) { + pendingRecordingFlutterApi.sendVideoRecordingFinalizedEvent(reply -> {}); VideoRecordEvent.Finalize castedEvent = (VideoRecordEvent.Finalize) event; if (castedEvent.hasError()) { String cameraErrorMessage; 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 92415d5381a0..f25a17ed5d91 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 @@ -41,6 +41,7 @@ public class PendingRecordingTest { @Mock public RecordingFlutterApiImpl mockRecordingFlutterApi; @Mock public Context mockContext; @Mock public SystemServicesFlutterApiImpl mockSystemServicesFlutterApi; + @Mock public PendingRecordingFlutterApiImpl mockPendingRecordingFlutterApi; @Mock public VideoRecordEvent.Finalize event; @Mock public Throwable throwable; @@ -80,6 +81,7 @@ public void testHandleVideoRecordEventSendsError() { PendingRecordingHostApiImpl pendingRecordingHostApi = new PendingRecordingHostApiImpl(mockBinaryMessenger, testInstanceManager, mockContext); pendingRecordingHostApi.systemServicesFlutterApi = mockSystemServicesFlutterApi; + pendingRecordingHostApi.pendingRecordingFlutterApi = mockPendingRecordingFlutterApi; final String eventMessage = "example failure message"; when(event.hasError()).thenReturn(true); @@ -89,9 +91,35 @@ public void testHandleVideoRecordEventSendsError() { pendingRecordingHostApi.handleVideoRecordEvent(event); + verify(mockPendingRecordingFlutterApi).sendVideoRecordingFinalizedEvent(any()); verify(mockSystemServicesFlutterApi).sendCameraError(eq(eventMessage), any()); } + @Test + public void handleVideoRecordEvent_SendsVideoRecordingFinalizedEvent() { + PendingRecordingHostApiImpl pendingRecordingHostApi = + new PendingRecordingHostApiImpl(mockBinaryMessenger, testInstanceManager, mockContext); + pendingRecordingHostApi.pendingRecordingFlutterApi = mockPendingRecordingFlutterApi; + + when(event.hasError()).thenReturn(false); + + pendingRecordingHostApi.handleVideoRecordEvent(event); + + verify(mockPendingRecordingFlutterApi).sendVideoRecordingFinalizedEvent(any()); + } + + @Test + public void handleVideoRecordEvent_SendsVideoRecordingStartedEvent() { + PendingRecordingHostApiImpl pendingRecordingHostApi = + new PendingRecordingHostApiImpl(mockBinaryMessenger, testInstanceManager, mockContext); + pendingRecordingHostApi.pendingRecordingFlutterApi = mockPendingRecordingFlutterApi; + VideoRecordEvent.Start mockStartEvent = mock(VideoRecordEvent.Start.class); + + pendingRecordingHostApi.handleVideoRecordEvent(mockStartEvent); + + verify(mockPendingRecordingFlutterApi).sendVideoRecordingStartedEvent(any()); + } + @Test public void flutterApiCreateTest() { final PendingRecordingFlutterApiImpl spyPendingRecordingFlutterApi = 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 83d20b3585b4..915f522d239e 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 @@ -13,6 +13,7 @@ import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:video_player/video_player.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -178,4 +179,81 @@ void main() { } } }); + + testWidgets('Video capture records valid video', (WidgetTester tester) async { + final List cameras = await availableCameras(); + if (cameras.isEmpty) { + return; + } + + final CameraController controller = CameraController(cameras[0], + mediaSettings: + const MediaSettings(resolutionPreset: ResolutionPreset.low)); + await controller.initialize(); + await controller.prepareForVideoRecording(); + + await controller.startVideoRecording(); + final int recordingStart = DateTime.now().millisecondsSinceEpoch; + + sleep(const Duration(seconds: 2)); + + final XFile file = await controller.stopVideoRecording(); + final int postStopTime = + DateTime.now().millisecondsSinceEpoch - recordingStart; + + 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, lessThan(postStopTime)); + }); + + testWidgets('Pause and resume video recording', (WidgetTester tester) async { + final List cameras = await availableCameras(); + if (cameras.isEmpty) { + return; + } + + final CameraController controller = CameraController(cameras[0], + mediaSettings: + const MediaSettings(resolutionPreset: ResolutionPreset.low)); + await controller.initialize(); + await controller.prepareForVideoRecording(); + + int startPause; + int timePaused = 0; + const int pauseIterations = 2; + + await controller.startVideoRecording(); + final int recordingStart = DateTime.now().millisecondsSinceEpoch; + sleep(const Duration(milliseconds: 500)); + + for (int i = 0; i < pauseIterations; i++) { + await controller.pauseVideoRecording(); + startPause = DateTime.now().millisecondsSinceEpoch; + sleep(const Duration(milliseconds: 500)); + await controller.resumeVideoRecording(); + timePaused += DateTime.now().millisecondsSinceEpoch - startPause; + + sleep(const Duration(milliseconds: 500)); + } + + final XFile file = await controller.stopVideoRecording(); + final int recordingTime = + DateTime.now().millisecondsSinceEpoch - recordingStart; + + 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, lessThan(recordingTime - timePaused)); + }); } 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 5ced7ccccb38..edf24bab37a0 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 @@ -113,6 +113,12 @@ class AndroidCameraCameraX extends CameraPlatform { @visibleForTesting String? videoOutputPath; + /// Stream queue to pick up finalized viceo recording events in + /// [stopVideoRecording]. + final StreamQueue videoRecordingEventStreamQueue = + StreamQueue( + PendingRecording.videoRecordingEventStreamController.stream); + /// Whether or not [preview] has been bound to the lifecycle of the camera by /// [createCamera]. @visibleForTesting @@ -122,7 +128,7 @@ class AndroidCameraCameraX extends CameraPlatform { /// The prefix used to create the filename for video recording files. @visibleForTesting - final String videoPrefix = 'MOV'; + final String videoPrefix = 'REC'; /// The [ImageCapture] instance that can be configured to capture a still image. @visibleForTesting @@ -777,6 +783,15 @@ class AndroidCameraCameraX extends CameraPlatform { await _unbindUseCaseFromLifecycle(preview!); } + /// Sets the active camera while recording. + /// + /// Currently unsupported, so is a no-op. + @override + Future setDescriptionWhileRecording(CameraDescription description) { + // TODO(camsim99): Implement this feature, see https://github.com/flutter/flutter/issues/148013. + return Future.value(); + } + /// Resume the paused preview for the selected camera. /// /// [cameraId] not used. @@ -963,6 +978,12 @@ class AndroidCameraCameraX extends CameraPlatform { if (streamCallback != null) { onStreamedFrameAvailable(options.cameraId).listen(streamCallback); } + + // Wait for video recording to start. + VideoRecordEvent event = await videoRecordingEventStreamQueue.next; + while (event != VideoRecordEvent.start) { + event = await videoRecordingEventStreamQueue.next; + } } /// Stops the video recording and returns the file where it was saved. @@ -979,23 +1000,30 @@ class AndroidCameraCameraX extends CameraPlatform { 'Attempting to stop a ' 'video recording while no recording is in progress.'); } + + /// Stop the active recording and wait for the video recording to be finalized. + await recording!.close(); + VideoRecordEvent event = await videoRecordingEventStreamQueue.next; + while (event != VideoRecordEvent.finalize) { + event = await videoRecordingEventStreamQueue.next; + } + recording = null; + pendingRecording = null; + if (videoOutputPath == null) { - // Stop the current active recording as we will be unable to complete it - // in this error case. - await recording!.close(); - recording = null; - pendingRecording = null; + // Handle any errors with finalizing video recording. throw CameraException( 'INVALID_PATH', 'The platform did not return a path ' 'while reporting success. The platform should always ' 'return a valid path or report an error.'); } - await recording!.close(); - recording = null; - pendingRecording = null; + await _unbindUseCaseFromLifecycle(videoCapture!); - return XFile(videoOutputPath!); + final XFile videoFile = XFile(videoOutputPath!); + cameraEventStreamController + .add(VideoRecordedEvent(cameraId, videoFile, /* duration */ null)); + return videoFile; } /// Pause the current video recording if it is not null. 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 550854fba3e1..e63a7a6afaf9 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 @@ -68,6 +68,14 @@ enum VideoResolutionFallbackRule { lowerQualityThan, } +/// Video recording status. +/// +/// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent. +enum VideoRecordEvent { + start, + finalize, +} + /// The types of capture request options this plugin currently supports. /// /// If you need to add another option to support, ensure the following is done @@ -232,6 +240,27 @@ class VideoQualityData { } } +class VideoRecordEventData { + VideoRecordEventData({ + required this.value, + }); + + VideoRecordEvent value; + + Object encode() { + return [ + value.index, + ]; + } + + static VideoRecordEventData decode(Object result) { + result as List; + return VideoRecordEventData( + value: VideoRecordEvent.values[result[0]! as int], + ); + } +} + /// Convenience class for building [FocusMeteringAction]s with multiple metering /// points. class MeteringPointInfo { @@ -1580,11 +1609,36 @@ class PendingRecordingHostApi { } } +class _PendingRecordingFlutterApiCodec extends StandardMessageCodec { + const _PendingRecordingFlutterApiCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is VideoRecordEventData) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return VideoRecordEventData.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + abstract class PendingRecordingFlutterApi { - static const MessageCodec codec = StandardMessageCodec(); + static const MessageCodec codec = _PendingRecordingFlutterApiCodec(); void create(int identifier); + void onVideoRecordingEvent(VideoRecordEventData event); + static void setup(PendingRecordingFlutterApi? api, {BinaryMessenger? binaryMessenger}) { { @@ -1606,6 +1660,27 @@ abstract class PendingRecordingFlutterApi { }); } } + { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.PendingRecordingFlutterApi.onVideoRecordingEvent', + codec, + binaryMessenger: binaryMessenger); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.PendingRecordingFlutterApi.onVideoRecordingEvent was null.'); + final List args = (message as List?)!; + final VideoRecordEventData? arg_event = + (args[0] as VideoRecordEventData?); + assert(arg_event != null, + 'Argument for dev.flutter.pigeon.PendingRecordingFlutterApi.onVideoRecordingEvent was null, expected non-null VideoRecordEventData.'); + api.onVideoRecordingEvent(arg_event!); + return; + }); + } + } } } @@ -3222,6 +3297,9 @@ class _CaptureRequestOptionsHostApiCodec extends StandardMessageCodec { } else if (value is VideoQualityData) { buffer.putUint8(134); writeValue(buffer, value.encode()); + } else if (value is VideoRecordEventData) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -3244,6 +3322,8 @@ class _CaptureRequestOptionsHostApiCodec extends StandardMessageCodec { return ResolutionInfo.decode(readValue(buffer)!); case 134: return VideoQualityData.decode(readValue(buffer)!); + case 135: + return VideoRecordEventData.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } diff --git a/packages/camera/camera_android_camerax/lib/src/pending_recording.dart b/packages/camera/camera_android_camerax/lib/src/pending_recording.dart index 971ef49390ac..7dcb19e48c5d 100644 --- a/packages/camera/camera_android_camerax/lib/src/pending_recording.dart +++ b/packages/camera/camera_android_camerax/lib/src/pending_recording.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/services.dart' show BinaryMessenger; import 'package:meta/meta.dart' show immutable; @@ -30,6 +32,11 @@ class PendingRecording extends JavaObject { late final PendingRecordingHostApiImpl _api; + /// Stream that emits an event when the corresponding video recording is finalized. + static final StreamController + videoRecordingEventStreamController = + StreamController.broadcast(); + /// Starts the recording, making it an active recording. Future start() { return _api.startFromInstance(this); @@ -100,4 +107,9 @@ class PendingRecordingFlutterApiImpl extends PendingRecordingFlutterApi { ); }); } + + @override + void onVideoRecordingEvent(VideoRecordEventData event) { + PendingRecording.videoRecordingEventStreamController.add(event.value); + } } diff --git a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart index f51eae8c306a..872c3a622390 100644 --- a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart +++ b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart @@ -126,6 +126,15 @@ enum VideoResolutionFallbackRule { lowerQualityThan, } +/// Video recording status. +/// +/// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent. +enum VideoRecordEvent { start, finalize } + +class VideoRecordEventData { + late VideoRecordEvent value; +} + /// Convenience class for building [FocusMeteringAction]s with multiple metering /// points. class MeteringPointInfo { @@ -325,6 +334,8 @@ abstract class PendingRecordingHostApi { @FlutterApi() abstract class PendingRecordingFlutterApi { void create(int identifier); + + void onVideoRecordingEvent(VideoRecordEventData event); } @HostApi(dartHostTestHandler: 'TestRecordingHostApi') diff --git a/packages/camera/camera_android_camerax/pubspec.yaml b/packages/camera/camera_android_camerax/pubspec.yaml index cabd26b66a2d..9d4191b98b2e 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.4+1 +version: 0.6.5 environment: sdk: ^3.1.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 23af67c5ca18..aa33254ac14a 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 @@ -1290,7 +1290,7 @@ void main() { Future.value(mockCamera2CameraInfo)); const int cameraId = 17; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; // Mock method calls. when(mockSystemServicesApi.getTempFilePath(camera.videoPrefix, '.temp')) @@ -1312,6 +1312,10 @@ void main() { when(mockCamera2CameraInfo.getSupportedHardwareLevel()).thenAnswer( (_) async => CameraMetadata.infoSupportedHardwareLevelLimited); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); // Verify VideoCapture UseCase is bound and camera & its properties @@ -1369,7 +1373,7 @@ void main() { Future.value(mockCamera2CameraInfo)); const int cameraId = 17; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; // Mock method calls. when(mockSystemServicesApi.getTempFilePath(camera.videoPrefix, '.temp')) @@ -1389,6 +1393,10 @@ void main() { when(mockCamera2CameraInfo.getSupportedHardwareLevel()).thenAnswer( (_) async => CameraMetadata.infoSupportedHardwareLevelLimited); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verify(camera.processCameraProvider!.bindToLifecycle( @@ -1448,7 +1456,7 @@ void main() { : MockCamera2CameraInfo()); const int cameraId = 17; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; final Completer imageDataCompleter = Completer(); final VideoCaptureOptions videoCaptureOptions = VideoCaptureOptions( @@ -1472,6 +1480,10 @@ void main() { when(mockCamera2CameraInfo.getSupportedHardwareLevel()) .thenAnswer((_) async => CameraMetadata.infoSupportedHardwareLevel3); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + await camera.startVideoCapturing(videoCaptureOptions); final CameraImageData mockCameraImageData = MockCameraImageData(); @@ -1516,7 +1528,7 @@ void main() { : MockCamera2CameraInfo()); const int cameraId = 87; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; // Mock method calls. when(mockSystemServicesApi.getTempFilePath(camera.videoPrefix, '.temp')) @@ -1529,12 +1541,20 @@ void main() { when(camera.processCameraProvider!.isBound(camera.imageAnalysis!)) .thenAnswer((_) async => false); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + // Orientation is unlocked and plugin does not need to set default target // rotation manually. camera.recording = null; await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verifyNever(mockVideoCapture.setTargetRotation(any)); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + // Orientation is locked and plugin does not need to set default target // rotation manually. camera.recording = null; @@ -1542,6 +1562,10 @@ void main() { await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verifyNever(mockVideoCapture.setTargetRotation(any)); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + // Orientation is locked and plugin does need to set default target // rotation manually. camera.recording = null; @@ -1550,6 +1574,10 @@ void main() { await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verifyNever(mockVideoCapture.setTargetRotation(any)); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + // Orientation is unlocked and plugin does need to set default target // rotation manually. camera.recording = null; @@ -1601,6 +1629,10 @@ void main() { when(camera.processCameraProvider!.isBound(videoCapture)) .thenAnswer((_) async => true); + // Simulate video recording being finalized so stopVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.finalize); + final XFile file = await camera.stopVideoRecording(0); expect(file.path, videoOutputPath); @@ -1642,6 +1674,9 @@ void main() { .thenAnswer((_) async => true); await expectLater(() async { + // Simulate video recording being finalized so stopVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.finalize); await camera.stopVideoRecording(0); }, throwsA(isA())); expect(camera.recording, null); @@ -1663,6 +1698,10 @@ void main() { camera.videoCapture = videoCapture; camera.videoOutputPath = videoOutputPath; + // Simulate video recording being finalized so stopVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.finalize); + final XFile file = await camera.stopVideoRecording(0); expect(file.path, videoOutputPath); @@ -1691,6 +1730,10 @@ void main() { when(camera.processCameraProvider!.isBound(videoCapture)) .thenAnswer((_) async => true); + // Simulate video recording being finalized so stopVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.finalize); + await camera.stopVideoRecording(90); verify(processCameraProvider.unbind([videoCapture])); @@ -1698,6 +1741,28 @@ void main() { verify(recording.close()); verifyNoMoreInteractions(recording); }); + + 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(); + + // Set directly for test versus calling createCamera. + camera.processCameraProvider = MockProcessCameraProvider(); + camera.recorder = MockRecorder(); + camera.videoCapture = MockVideoCapture(); + camera.camera = MockCamera(); + + await camera.setDescriptionWhileRecording(const CameraDescription( + name: 'fakeCameraName', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90)); + verifyNoMoreInteractions(camera.processCameraProvider); + verifyNoMoreInteractions(camera.recorder); + verifyNoMoreInteractions(camera.videoCapture); + verifyNoMoreInteractions(camera.camera); + }); }); test( @@ -3695,7 +3760,7 @@ void main() { Future.value(mockCamera2CameraInfo)); const int cameraId = 7; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; // Mock method calls. when(mockSystemServicesApi.getTempFilePath(camera.videoPrefix, '.temp')) @@ -3717,6 +3782,10 @@ void main() { when(mockCamera2CameraInfo.getSupportedHardwareLevel()) .thenAnswer((_) async => CameraMetadata.infoSupportedHardwareLevelFull); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verify( @@ -3756,7 +3825,7 @@ void main() { Future.value(mockCamera2CameraInfo)); const int cameraId = 77; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; // Mock method calls. when(mockSystemServicesApi.getTempFilePath(camera.videoPrefix, '.temp')) @@ -3778,6 +3847,10 @@ void main() { when(mockCamera2CameraInfo.getSupportedHardwareLevel()) .thenAnswer((_) async => CameraMetadata.infoSupportedHardwareLevel3); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verify( @@ -3817,7 +3890,7 @@ void main() { Future.value(mockCamera2CameraInfo)); const int cameraId = 87; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; // Mock method calls. when(mockSystemServicesApi.getTempFilePath(camera.videoPrefix, '.temp')) @@ -3839,6 +3912,10 @@ void main() { when(mockCamera2CameraInfo.getSupportedHardwareLevel()).thenAnswer( (_) async => CameraMetadata.infoSupportedHardwareLevelExternal); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + await camera.startVideoCapturing(VideoCaptureOptions(cameraId, streamCallback: (CameraImageData image) {})); verify( @@ -3882,7 +3959,7 @@ void main() { Future.value(mockCamera2CameraInfo)); const int cameraId = 107; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; // Mock method calls. when(mockSystemServicesApi.getTempFilePath(camera.videoPrefix, '.temp')) @@ -3906,6 +3983,10 @@ void main() { when(mockCamera2CameraInfo.getSupportedHardwareLevel()) .thenAnswer((_) async => CameraMetadata.infoSupportedHardwareLevel3); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + await camera.startVideoCapturing(VideoCaptureOptions(cameraId, streamCallback: (CameraImageData image) {})); verify( @@ -3947,7 +4028,7 @@ void main() { Future.value(mockCamera2CameraInfo)); const int cameraId = 97; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; // Mock method calls. when(mockSystemServicesApi.getTempFilePath(camera.videoPrefix, '.temp')) @@ -3966,6 +4047,11 @@ void main() { .thenAnswer((_) async => MockLiveCameraState()); await camera.pausePreview(cameraId); + + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verifyNever( @@ -4009,7 +4095,7 @@ void main() { Future.value(mockCamera2CameraInfo)); const int cameraId = 44; - const String outputPath = '/temp/MOV123.temp'; + const String outputPath = '/temp/REC123.temp'; // Mock method calls. when(mockSystemServicesApi.getTempFilePath(camera.videoPrefix, '.temp')) @@ -4033,6 +4119,10 @@ void main() { when(mockCamera2CameraInfo.getSupportedHardwareLevel()).thenAnswer( (_) async => CameraMetadata.infoSupportedHardwareLevelLegacy); + // Simulate video recording being started so startVideoRecording completes. + PendingRecording.videoRecordingEventStreamController + .add(VideoRecordEvent.start); + await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verify( diff --git a/packages/camera/camera_android_camerax/test/test_camerax_library.g.dart b/packages/camera/camera_android_camerax/test/test_camerax_library.g.dart index 8a659e460cd7..105447e52689 100644 --- a/packages/camera/camera_android_camerax/test/test_camerax_library.g.dart +++ b/packages/camera/camera_android_camerax/test/test_camerax_library.g.dart @@ -2230,6 +2230,9 @@ class _TestCaptureRequestOptionsHostApiCodec extends StandardMessageCodec { } else if (value is VideoQualityData) { buffer.putUint8(134); writeValue(buffer, value.encode()); + } else if (value is VideoRecordEventData) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -2252,6 +2255,8 @@ class _TestCaptureRequestOptionsHostApiCodec extends StandardMessageCodec { return ResolutionInfo.decode(readValue(buffer)!); case 134: return VideoQualityData.decode(readValue(buffer)!); + case 135: + return VideoRecordEventData.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); }