From 6e32dc666c69d9597fbb8e5eb0143111c2689049 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Mon, 19 Oct 2026 14:33:54 -0300 Subject: [PATCH 1/5] feat: implement CameraX RecordingStats and VideoRecordEvent APIs for video recording --- .../camera_android_camerax/CHANGELOG.md | 4 + .../plugins/camerax/CameraXLibrary.g.kt | 249 ++++++++++++++++- .../plugins/camerax/ProxyApiRegistrar.java | 18 ++ .../camerax/RecordingStatsProxyApi.java | 25 ++ .../VideoRecordEventFinalizeProxyApi.java | 25 ++ .../camerax/VideoRecordEventProxyApi.java | 22 ++ .../plugins/camerax/RecordingStatsTest.java | 34 +++ .../camerax/VideoRecordEventFinalizeTest.java | 36 +++ .../plugins/camerax/VideoRecordEventTest.java | 27 ++ .../integration_test/integration_test.dart | 38 ++- .../lib/src/android_camera_camerax.dart | 34 ++- .../lib/src/camerax_library.g.dart | 228 +++++++++++++++ .../pigeons/camerax_library.dart | 41 ++- .../camera_android_camerax/pubspec.yaml | 2 +- .../test/android_camera_camerax_test.dart | 259 ++++++++++++++--- .../android_camera_camerax_test.mocks.dart | 264 ++++++++++++++++-- 16 files changed, 1229 insertions(+), 77 deletions(-) create mode 100644 packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/RecordingStatsProxyApi.java create mode 100644 packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeProxyApi.java create mode 100644 packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventProxyApi.java create mode 100644 packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/RecordingStatsTest.java create mode 100644 packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeTest.java create mode 100644 packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventTest.java diff --git a/packages/camera/camera_android_camerax/CHANGELOG.md b/packages/camera/camera_android_camerax/CHANGELOG.md index 1a8295c4acf0..d757a3fda43a 100644 --- a/packages/camera/camera_android_camerax/CHANGELOG.md +++ b/packages/camera/camera_android_camerax/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.7.5+3 + +* Fixes video recording duration discrepancy by waiting for the first recorded frame before completing `startVideoCapturing`, and validates errors on `stopVideoRecording`. + ## 0.7.5+2 * Updates Robolectric to 4.17 and adds the JVM arguments it requires for unit tests. 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 1948a84a3fc2..6348e15a27ce 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 @@ -425,13 +425,17 @@ abstract class CameraXLibraryPigeonProxyApiRegistrar(val binaryMessenger: Binary */ abstract fun getPigeonApiCameraIntegerRange(): PigeonApiCameraIntegerRange + /** + * An implementation of [PigeonApiRecordingStats] used to add a new Dart instance of + * `RecordingStats` to the Dart `InstanceManager`. + */ + abstract fun getPigeonApiRecordingStats(): PigeonApiRecordingStats + /** * An implementation of [PigeonApiVideoRecordEvent] used to add a new Dart instance of * `VideoRecordEvent` to the Dart `InstanceManager`. */ - open fun getPigeonApiVideoRecordEvent(): PigeonApiVideoRecordEvent { - return PigeonApiVideoRecordEvent(this) - } + abstract fun getPigeonApiVideoRecordEvent(): PigeonApiVideoRecordEvent /** * An implementation of [PigeonApiVideoRecordEventStart] used to add a new Dart instance of @@ -441,13 +445,19 @@ abstract class CameraXLibraryPigeonProxyApiRegistrar(val binaryMessenger: Binary return PigeonApiVideoRecordEventStart(this) } + /** + * An implementation of [PigeonApiVideoRecordEventStatus] used to add a new Dart instance of + * `VideoRecordEventStatus` to the Dart `InstanceManager`. + */ + open fun getPigeonApiVideoRecordEventStatus(): PigeonApiVideoRecordEventStatus { + return PigeonApiVideoRecordEventStatus(this) + } + /** * An implementation of [PigeonApiVideoRecordEventFinalize] used to add a new Dart instance of * `VideoRecordEventFinalize` to the Dart `InstanceManager`. */ - open fun getPigeonApiVideoRecordEventFinalize(): PigeonApiVideoRecordEventFinalize { - return PigeonApiVideoRecordEventFinalize(this) - } + abstract fun getPigeonApiVideoRecordEventFinalize(): PigeonApiVideoRecordEventFinalize /** * An implementation of [PigeonApiMeteringPoint] used to add a new Dart instance of @@ -745,6 +755,9 @@ abstract class CameraXLibraryPigeonProxyApiRegistrar(val binaryMessenger: Binary PigeonApiCameraSize.setUpMessageHandlers(binaryMessenger, getPigeonApiCameraSize()) PigeonApiCameraIntegerRange.setUpMessageHandlers( binaryMessenger, getPigeonApiCameraIntegerRange()) + PigeonApiVideoRecordEvent.setUpMessageHandlers(binaryMessenger, getPigeonApiVideoRecordEvent()) + PigeonApiVideoRecordEventFinalize.setUpMessageHandlers( + binaryMessenger, getPigeonApiVideoRecordEventFinalize()) PigeonApiMeteringPoint.setUpMessageHandlers(binaryMessenger, getPigeonApiMeteringPoint()) PigeonApiObserver.setUpMessageHandlers(binaryMessenger, getPigeonApiObserver()) PigeonApiCameraInfo.setUpMessageHandlers(binaryMessenger, getPigeonApiCameraInfo()) @@ -800,6 +813,8 @@ abstract class CameraXLibraryPigeonProxyApiRegistrar(val binaryMessenger: Binary CameraXLibraryPigeonInstanceManagerApi.setUpMessageHandlers(binaryMessenger, null) PigeonApiCameraSize.setUpMessageHandlers(binaryMessenger, null) PigeonApiCameraIntegerRange.setUpMessageHandlers(binaryMessenger, null) + PigeonApiVideoRecordEvent.setUpMessageHandlers(binaryMessenger, null) + PigeonApiVideoRecordEventFinalize.setUpMessageHandlers(binaryMessenger, null) PigeonApiMeteringPoint.setUpMessageHandlers(binaryMessenger, null) PigeonApiObserver.setUpMessageHandlers(binaryMessenger, null) PigeonApiCameraInfo.setUpMessageHandlers(binaryMessenger, null) @@ -908,12 +923,24 @@ private class CameraXLibraryPigeonProxyApiBaseCodec( logNewInstanceFailure("CameraIntegerRange", value, it.exceptionOrNull()) } } + } else if (value is androidx.camera.video.RecordingStats) { + registrar.getPigeonApiRecordingStats().pigeon_newInstance(value) { + if (it.isFailure) { + logNewInstanceFailure("RecordingStats", value, it.exceptionOrNull()) + } + } } else if (value is androidx.camera.video.VideoRecordEvent.Start) { registrar.getPigeonApiVideoRecordEventStart().pigeon_newInstance(value) { if (it.isFailure) { logNewInstanceFailure("VideoRecordEventStart", value, it.exceptionOrNull()) } } + } else if (value is androidx.camera.video.VideoRecordEvent.Status) { + registrar.getPigeonApiVideoRecordEventStatus().pigeon_newInstance(value) { + if (it.isFailure) { + logNewInstanceFailure("VideoRecordEventStatus", value, it.exceptionOrNull()) + } + } } else if (value is androidx.camera.video.VideoRecordEvent.Finalize) { registrar.getPigeonApiVideoRecordEventFinalize().pigeon_newInstance(value) { if (it.isFailure) { @@ -1840,15 +1867,101 @@ abstract class PigeonApiCameraIntegerRange( } } } +/** + * RecordingStats provides recording statistics such as duration and bytes recorded. + * + * See https://developer.android.com/reference/androidx/camera/video/RecordingStats. + */ +@Suppress("UNCHECKED_CAST") +abstract class PigeonApiRecordingStats( + open val pigeonRegistrar: CameraXLibraryPigeonProxyApiRegistrar +) { + /** The recorded duration in nanoseconds. */ + abstract fun recordedDurationNanos(pigeon_instance: androidx.camera.video.RecordingStats): Long + + /** The number of bytes recorded. */ + abstract fun numBytesRecorded(pigeon_instance: androidx.camera.video.RecordingStats): Long + + @Suppress("LocalVariableName", "FunctionName") + /** Creates a Dart instance of RecordingStats and attaches it to [pigeon_instanceArg]. */ + fun pigeon_newInstance( + pigeon_instanceArg: androidx.camera.video.RecordingStats, + callback: (Result) -> Unit + ) { + if (pigeonRegistrar.ignoreCallsToDart) { + callback( + Result.failure( + CameraXError("ignore-calls-error", "Calls to Dart are being ignored.", ""))) + } else if (pigeonRegistrar.instanceManager.containsInstance(pigeon_instanceArg)) { + callback(Result.success(Unit)) + } else { + val pigeon_identifierArg = + pigeonRegistrar.instanceManager.addHostCreatedInstance(pigeon_instanceArg) + val recordedDurationNanosArg = recordedDurationNanos(pigeon_instanceArg) + val numBytesRecordedArg = numBytesRecorded(pigeon_instanceArg) + val binaryMessenger = pigeonRegistrar.binaryMessenger + val codec = pigeonRegistrar.codec + val channelName = + "dev.flutter.pigeon.camera_android_camerax.RecordingStats.pigeon_newInstance" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(pigeon_identifierArg, recordedDurationNanosArg, numBytesRecordedArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback( + Result.failure(CameraXError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(CameraXLibraryPigeonUtils.createConnectionError(channelName))) + } + } + } + } +} /** * VideoRecordEvent is used to report video recording events and status. * * See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent. */ @Suppress("UNCHECKED_CAST") -open class PigeonApiVideoRecordEvent( +abstract class PigeonApiVideoRecordEvent( open val pigeonRegistrar: CameraXLibraryPigeonProxyApiRegistrar ) { + /** The recording statistics for this event. */ + abstract fun getRecordingStats( + pigeon_instance: androidx.camera.video.VideoRecordEvent + ): androidx.camera.video.RecordingStats + + companion object { + @Suppress("LocalVariableName") + fun setUpMessageHandlers(binaryMessenger: BinaryMessenger, api: PigeonApiVideoRecordEvent?) { + val codec = api?.pigeonRegistrar?.codec ?: CameraXLibraryPigeonCodec() + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.camera_android_camerax.VideoRecordEvent.getRecordingStats", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pigeon_instanceArg = args[0] as androidx.camera.video.VideoRecordEvent + val wrapped: List = + try { + listOf(api.getRecordingStats(pigeon_instanceArg)) + } catch (exception: Throwable) { + CameraXLibraryPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } + @Suppress("LocalVariableName", "FunctionName") /** Creates a Dart instance of VideoRecordEvent and attaches it to [pigeon_instanceArg]. */ fun pigeon_newInstance( @@ -1934,15 +2047,135 @@ open class PigeonApiVideoRecordEventStart( return pigeonRegistrar.getPigeonApiVideoRecordEvent() } } +/** + * Indicates recording status updates. + * + * See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Status. + */ +@Suppress("UNCHECKED_CAST") +open class PigeonApiVideoRecordEventStatus( + open val pigeonRegistrar: CameraXLibraryPigeonProxyApiRegistrar +) { + @Suppress("LocalVariableName", "FunctionName") + /** Creates a Dart instance of VideoRecordEventStatus and attaches it to [pigeon_instanceArg]. */ + fun pigeon_newInstance( + pigeon_instanceArg: androidx.camera.video.VideoRecordEvent.Status, + callback: (Result) -> Unit + ) { + if (pigeonRegistrar.ignoreCallsToDart) { + callback( + Result.failure( + CameraXError("ignore-calls-error", "Calls to Dart are being ignored.", ""))) + } else if (pigeonRegistrar.instanceManager.containsInstance(pigeon_instanceArg)) { + callback(Result.success(Unit)) + } else { + val pigeon_identifierArg = + pigeonRegistrar.instanceManager.addHostCreatedInstance(pigeon_instanceArg) + val binaryMessenger = pigeonRegistrar.binaryMessenger + val codec = pigeonRegistrar.codec + val channelName = + "dev.flutter.pigeon.camera_android_camerax.VideoRecordEventStatus.pigeon_newInstance" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(pigeon_identifierArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback( + Result.failure(CameraXError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(CameraXLibraryPigeonUtils.createConnectionError(channelName))) + } + } + } + } + + @Suppress("FunctionName") + /** An implementation of [PigeonApiVideoRecordEvent] used to access callback methods */ + fun pigeon_getPigeonApiVideoRecordEvent(): PigeonApiVideoRecordEvent { + return pigeonRegistrar.getPigeonApiVideoRecordEvent() + } +} /** * Indicates the finalization of recording. * * See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize. */ @Suppress("UNCHECKED_CAST") -open class PigeonApiVideoRecordEventFinalize( +abstract class PigeonApiVideoRecordEventFinalize( open val pigeonRegistrar: CameraXLibraryPigeonProxyApiRegistrar ) { + /** + * The error code of the finalization. + * + * See + * https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#getError(). + */ + abstract fun getError(pigeon_instance: androidx.camera.video.VideoRecordEvent.Finalize): Long + + /** + * Whether the recording finalized with an error. + * + * See + * https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#hasError(). + */ + abstract fun hasError(pigeon_instance: androidx.camera.video.VideoRecordEvent.Finalize): Boolean + + companion object { + @Suppress("LocalVariableName") + fun setUpMessageHandlers( + binaryMessenger: BinaryMessenger, + api: PigeonApiVideoRecordEventFinalize? + ) { + val codec = api?.pigeonRegistrar?.codec ?: CameraXLibraryPigeonCodec() + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.camera_android_camerax.VideoRecordEventFinalize.getError", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pigeon_instanceArg = args[0] as androidx.camera.video.VideoRecordEvent.Finalize + val wrapped: List = + try { + listOf(api.getError(pigeon_instanceArg)) + } catch (exception: Throwable) { + CameraXLibraryPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.camera_android_camerax.VideoRecordEventFinalize.hasError", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pigeon_instanceArg = args[0] as androidx.camera.video.VideoRecordEvent.Finalize + val wrapped: List = + try { + listOf(api.hasError(pigeon_instanceArg)) + } catch (exception: Throwable) { + CameraXLibraryPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } + @Suppress("LocalVariableName", "FunctionName") /** * Creates a Dart instance of VideoRecordEventFinalize and attaches it to [pigeon_instanceArg]. diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ProxyApiRegistrar.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ProxyApiRegistrar.java index 3941932385c8..67d01be8d856 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ProxyApiRegistrar.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ProxyApiRegistrar.java @@ -250,6 +250,24 @@ public PigeonApiVideoRecordEventListener getPigeonApiVideoRecordEventListener() return new VideoRecordEventListenerProxyApi(this); } + @NonNull + @Override + public PigeonApiRecordingStats getPigeonApiRecordingStats() { + return new RecordingStatsProxyApi(this); + } + + @NonNull + @Override + public PigeonApiVideoRecordEvent getPigeonApiVideoRecordEvent() { + return new VideoRecordEventProxyApi(this); + } + + @NonNull + @Override + public PigeonApiVideoRecordEventFinalize getPigeonApiVideoRecordEventFinalize() { + return new VideoRecordEventFinalizeProxyApi(this); + } + @NonNull @Override public PigeonApiPendingRecording getPigeonApiPendingRecording() { diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/RecordingStatsProxyApi.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/RecordingStatsProxyApi.java new file mode 100644 index 000000000000..113f772c5fd4 --- /dev/null +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/RecordingStatsProxyApi.java @@ -0,0 +1,25 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camerax; + +import androidx.annotation.NonNull; +import androidx.camera.video.RecordingStats; + +/** ProxyApi implementation for {@link RecordingStats}. */ +class RecordingStatsProxyApi extends PigeonApiRecordingStats { + RecordingStatsProxyApi(@NonNull ProxyApiRegistrar pigeonRegistrar) { + super(pigeonRegistrar); + } + + @Override + public long recordedDurationNanos(@NonNull RecordingStats pigeonInstance) { + return pigeonInstance.getRecordedDurationNanos(); + } + + @Override + public long numBytesRecorded(@NonNull RecordingStats pigeonInstance) { + return pigeonInstance.getNumBytesRecorded(); + } +} diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeProxyApi.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeProxyApi.java new file mode 100644 index 000000000000..8ef6aa4bfd7b --- /dev/null +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeProxyApi.java @@ -0,0 +1,25 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camerax; + +import androidx.annotation.NonNull; +import androidx.camera.video.VideoRecordEvent; + +/** ProxyApi implementation for {@link VideoRecordEvent.Finalize}. */ +class VideoRecordEventFinalizeProxyApi extends PigeonApiVideoRecordEventFinalize { + VideoRecordEventFinalizeProxyApi(@NonNull ProxyApiRegistrar pigeonRegistrar) { + super(pigeonRegistrar); + } + + @Override + public long getError(@NonNull VideoRecordEvent.Finalize pigeonInstance) { + return (long) pigeonInstance.getError(); + } + + @Override + public boolean hasError(@NonNull VideoRecordEvent.Finalize pigeonInstance) { + return pigeonInstance.hasError(); + } +} diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventProxyApi.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventProxyApi.java new file mode 100644 index 000000000000..ebcd91beaa72 --- /dev/null +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventProxyApi.java @@ -0,0 +1,22 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camerax; + +import androidx.annotation.NonNull; +import androidx.camera.video.RecordingStats; +import androidx.camera.video.VideoRecordEvent; + +/** ProxyApi implementation for {@link VideoRecordEvent}. */ +class VideoRecordEventProxyApi extends PigeonApiVideoRecordEvent { + VideoRecordEventProxyApi(@NonNull ProxyApiRegistrar pigeonRegistrar) { + super(pigeonRegistrar); + } + + @NonNull + @Override + public RecordingStats getRecordingStats(@NonNull VideoRecordEvent pigeonInstance) { + return pigeonInstance.getRecordingStats(); + } +} diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/RecordingStatsTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/RecordingStatsTest.java new file mode 100644 index 000000000000..085ffb20ab77 --- /dev/null +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/RecordingStatsTest.java @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camerax; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import androidx.camera.video.RecordingStats; +import org.junit.Test; + +public class RecordingStatsTest { + @Test + public void recordedDurationNanos_returnsExpectedValue() { + final PigeonApiRecordingStats api = new TestProxyApiRegistrar().getPigeonApiRecordingStats(); + final RecordingStats instance = mock(RecordingStats.class); + + when(instance.getRecordedDurationNanos()).thenReturn(123456789L); + + assertEquals(123456789L, api.recordedDurationNanos(instance)); + } + + @Test + public void numBytesRecorded_returnsExpectedValue() { + final PigeonApiRecordingStats api = new TestProxyApiRegistrar().getPigeonApiRecordingStats(); + final RecordingStats instance = mock(RecordingStats.class); + + when(instance.getNumBytesRecorded()).thenReturn(987654L); + + assertEquals(987654L, api.numBytesRecorded(instance)); + } +} diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeTest.java new file mode 100644 index 000000000000..6fcfb068ce4b --- /dev/null +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeTest.java @@ -0,0 +1,36 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camerax; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import androidx.camera.video.VideoRecordEvent; +import org.junit.Test; + +public class VideoRecordEventFinalizeTest { + @Test + public void getError_returnsExpectedValue() { + final PigeonApiVideoRecordEventFinalize api = + new TestProxyApiRegistrar().getPigeonApiVideoRecordEventFinalize(); + final VideoRecordEvent.Finalize instance = mock(VideoRecordEvent.Finalize.class); + + when(instance.getError()).thenReturn(VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA); + + assertEquals((long) VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA, api.getError(instance)); + } + + @Test + public void hasError_returnsExpectedValue() { + final PigeonApiVideoRecordEventFinalize api = + new TestProxyApiRegistrar().getPigeonApiVideoRecordEventFinalize(); + final VideoRecordEvent.Finalize instance = mock(VideoRecordEvent.Finalize.class); + + when(instance.hasError()).thenReturn(true); + + assertEquals(true, api.hasError(instance)); + } +} diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventTest.java new file mode 100644 index 000000000000..61a70efff9a7 --- /dev/null +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventTest.java @@ -0,0 +1,27 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camerax; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import androidx.camera.video.RecordingStats; +import androidx.camera.video.VideoRecordEvent; +import org.junit.Test; + +public class VideoRecordEventTest { + @Test + public void getRecordingStats_returnsExpectedValue() { + final PigeonApiVideoRecordEvent api = + new TestProxyApiRegistrar().getPigeonApiVideoRecordEvent(); + final VideoRecordEvent instance = mock(VideoRecordEvent.class); + final RecordingStats stats = mock(RecordingStats.class); + + when(instance.getRecordingStats()).thenReturn(stats); + + assertEquals(stats, api.getRecordingStats(instance)); + } +} 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 89601306ddb4..44ed0b2707a9 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 @@ -157,7 +157,43 @@ void main() { final int duration = videoController.value.duration.inMilliseconds; await videoController.dispose(); - expect(duration, lessThan(postStopTime)); + expect(duration, greaterThan(1700)); + final int delta = (duration - 2000).abs(); + expect(delta, lessThan(350)); + expect(duration, lessThanOrEqualTo(postStopTime)); + await controller.dispose(); + }); + + testWidgets('Short video recording produces non-empty playable video', ( + WidgetTester tester, + ) async { + final List cameras = await availableCameras(); + if (cameras.isEmpty) { + return; + } + + final controller = CameraController( + cameras[0], + mediaSettings: const MediaSettings(resolutionPreset: ResolutionPreset.low), + ); + await controller.initialize(); + await controller.prepareForVideoRecording(); + + // A short recording (<300ms) previously caused ERROR_NO_VALID_DATA and 0-byte files + // if stop arrived before the first frame was encoded. + await controller.startVideoRecording(); + await Future.delayed(const Duration(milliseconds: 250)); + final XFile file = await controller.stopVideoRecording(); + + final int fileBytes = await file.length(); + expect(fileBytes, greaterThan(0)); + + final videoFile = File(file.path); + final videoController = VideoPlayerController.file(videoFile); + await videoController.initialize(); + expect(videoController.value.duration.inMilliseconds, greaterThan(0)); + await videoController.dispose(); + await controller.dispose(); }); testWidgets('Pause and resume video recording', (WidgetTester tester) async { 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 4293e13042c2..aa4aed85d1c7 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 @@ -1187,9 +1187,30 @@ class AndroidCameraCameraX extends CameraPlatform { onStreamedFrameAvailable(options.cameraId).listen(streamCallback); } - // Wait for video recording to start. + // Wait for video recording to start and for the first frame to be recorded. + await _waitForRecordingToStart(); + } + + /// Waits for the video recording to start and for the first frame to be recorded. + Future _waitForRecordingToStart() async { VideoRecordEvent event = await videoRecordingEventStreamQueue.next; - while (event is! VideoRecordEventStart) { + while (true) { + if (event is VideoRecordEventFinalize) { + final int errorCode = await event.getError(); + recording = null; + pendingRecording = null; + await _unbindUseCaseFromLifecycle(videoCapture!); + throw CameraException( + 'videoRecordingFailed', + 'Video recording finalized before recording started with error code $errorCode.', + ); + } + if (event is VideoRecordEventStatus) { + final RecordingStats stats = await event.getRecordingStats(); + if (stats.numBytesRecorded > 0 || stats.recordedDurationNanos > 0) { + break; + } + } event = await videoRecordingEventStreamQueue.next; } } @@ -1230,6 +1251,15 @@ class AndroidCameraCameraX extends CameraPlatform { } await _unbindUseCaseFromLifecycle(videoCapture!); + + if (await event.hasError()) { + final int errorCode = await event.getError(); + throw CameraException( + 'videoRecordingFailed', + 'Video recording finalized with error: $errorCode', + ); + } + final videoFile = XFile(videoOutputPath!); cameraEventStreamController.add(VideoRecordedEvent(cameraId, videoFile, /* duration */ null)); return videoFile; 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 24fed5ace6f7..073558742345 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 @@ -425,8 +425,10 @@ class PigeonInstanceManager { CameraSize.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); ResolutionInfo.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); CameraIntegerRange.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); + RecordingStats.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); VideoRecordEvent.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); VideoRecordEventStart.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); + VideoRecordEventStatus.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); VideoRecordEventFinalize.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); MeteringPoint.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); Observer.pigeon_setUpMessageHandlers(pigeon_instanceManager: instanceManager); @@ -1366,6 +1368,87 @@ class CameraIntegerRange extends PigeonInternalProxyApiBaseClass { } } +/// RecordingStats provides recording statistics such as duration and bytes recorded. +/// +/// See https://developer.android.com/reference/androidx/camera/video/RecordingStats. +class RecordingStats extends PigeonInternalProxyApiBaseClass { + /// Constructs [RecordingStats] without creating the associated native object. + /// + /// This should only be used by subclasses created by this library or to + /// create copies for an [PigeonInstanceManager]. + @protected + RecordingStats.pigeon_detached({ + super.pigeon_binaryMessenger, + super.pigeon_instanceManager, + required this.recordedDurationNanos, + required this.numBytesRecorded, + }); + + /// The recorded duration in nanoseconds. + final int recordedDurationNanos; + + /// The number of bytes recorded. + final int numBytesRecorded; + + static void pigeon_setUpMessageHandlers({ + bool pigeon_clearHandlers = false, + BinaryMessenger? pigeon_binaryMessenger, + PigeonInstanceManager? pigeon_instanceManager, + RecordingStats Function(int recordedDurationNanos, int numBytesRecorded)? pigeon_newInstance, + }) { + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = _PigeonInternalProxyApiBaseCodec( + pigeon_instanceManager ?? PigeonInstanceManager.instance, + ); + final BinaryMessenger? binaryMessenger = pigeon_binaryMessenger; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.camera_android_camerax.RecordingStats.pigeon_newInstance', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (pigeon_clearHandlers) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int arg_pigeon_instanceIdentifier = args[0]! as int; + final int arg_recordedDurationNanos = args[1]! as int; + final int arg_numBytesRecorded = args[2]! as int; + try { + (pigeon_instanceManager ?? PigeonInstanceManager.instance).addHostCreatedInstance( + pigeon_newInstance?.call(arg_recordedDurationNanos, arg_numBytesRecorded) ?? + RecordingStats.pigeon_detached( + pigeon_binaryMessenger: pigeon_binaryMessenger, + pigeon_instanceManager: pigeon_instanceManager, + recordedDurationNanos: arg_recordedDurationNanos, + numBytesRecorded: arg_numBytesRecorded, + ), + arg_pigeon_instanceIdentifier, + ); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + } + + @override + RecordingStats pigeon_copy() { + return RecordingStats.pigeon_detached( + pigeon_binaryMessenger: pigeon_binaryMessenger, + pigeon_instanceManager: pigeon_instanceManager, + recordedDurationNanos: recordedDurationNanos, + numBytesRecorded: numBytesRecorded, + ); + } +} + /// VideoRecordEvent is used to report video recording events and status. /// /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent. @@ -1377,6 +1460,9 @@ class VideoRecordEvent extends PigeonInternalProxyApiBaseClass { @protected VideoRecordEvent.pigeon_detached({super.pigeon_binaryMessenger, super.pigeon_instanceManager}); + late final _PigeonInternalProxyApiBaseCodec _pigeonVar_codecVideoRecordEvent = + _PigeonInternalProxyApiBaseCodec(pigeon_instanceManager); + static void pigeon_setUpMessageHandlers({ bool pigeon_clearHandlers = false, BinaryMessenger? pigeon_binaryMessenger, @@ -1421,6 +1507,28 @@ class VideoRecordEvent extends PigeonInternalProxyApiBaseClass { } } + /// The recording statistics for this event. + Future getRecordingStats() async { + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = _pigeonVar_codecVideoRecordEvent; + final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; + const pigeonVar_channelName = + 'dev.flutter.pigeon.camera_android_camerax.VideoRecordEvent.getRecordingStats'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([this]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as RecordingStats; + } + @override VideoRecordEvent pigeon_copy() { return VideoRecordEvent.pigeon_detached( @@ -1497,6 +1605,73 @@ class VideoRecordEventStart extends VideoRecordEvent { } } +/// Indicates recording status updates. +/// +/// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Status. +class VideoRecordEventStatus extends VideoRecordEvent { + /// Constructs [VideoRecordEventStatus] without creating the associated native object. + /// + /// This should only be used by subclasses created by this library or to + /// create copies for an [PigeonInstanceManager]. + @protected + VideoRecordEventStatus.pigeon_detached({ + super.pigeon_binaryMessenger, + super.pigeon_instanceManager, + }) : super.pigeon_detached(); + + static void pigeon_setUpMessageHandlers({ + bool pigeon_clearHandlers = false, + BinaryMessenger? pigeon_binaryMessenger, + PigeonInstanceManager? pigeon_instanceManager, + VideoRecordEventStatus Function()? pigeon_newInstance, + }) { + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = _PigeonInternalProxyApiBaseCodec( + pigeon_instanceManager ?? PigeonInstanceManager.instance, + ); + final BinaryMessenger? binaryMessenger = pigeon_binaryMessenger; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.camera_android_camerax.VideoRecordEventStatus.pigeon_newInstance', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (pigeon_clearHandlers) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int arg_pigeon_instanceIdentifier = args[0]! as int; + try { + (pigeon_instanceManager ?? PigeonInstanceManager.instance).addHostCreatedInstance( + pigeon_newInstance?.call() ?? + VideoRecordEventStatus.pigeon_detached( + pigeon_binaryMessenger: pigeon_binaryMessenger, + pigeon_instanceManager: pigeon_instanceManager, + ), + arg_pigeon_instanceIdentifier, + ); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + } + + @override + VideoRecordEventStatus pigeon_copy() { + return VideoRecordEventStatus.pigeon_detached( + pigeon_binaryMessenger: pigeon_binaryMessenger, + pigeon_instanceManager: pigeon_instanceManager, + ); + } +} + /// Indicates the finalization of recording. /// /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize. @@ -1511,6 +1686,9 @@ class VideoRecordEventFinalize extends VideoRecordEvent { super.pigeon_instanceManager, }) : super.pigeon_detached(); + late final _PigeonInternalProxyApiBaseCodec _pigeonVar_codecVideoRecordEventFinalize = + _PigeonInternalProxyApiBaseCodec(pigeon_instanceManager); + static void pigeon_setUpMessageHandlers({ bool pigeon_clearHandlers = false, BinaryMessenger? pigeon_binaryMessenger, @@ -1555,6 +1733,56 @@ class VideoRecordEventFinalize extends VideoRecordEvent { } } + /// The error code of the finalization. + /// + /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#getError(). + Future getError() async { + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = + _pigeonVar_codecVideoRecordEventFinalize; + final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; + const pigeonVar_channelName = + 'dev.flutter.pigeon.camera_android_camerax.VideoRecordEventFinalize.getError'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([this]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as int; + } + + /// Whether the recording finalized with an error. + /// + /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#hasError(). + Future hasError() async { + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = + _pigeonVar_codecVideoRecordEventFinalize; + final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; + const pigeonVar_channelName = + 'dev.flutter.pigeon.camera_android_camerax.VideoRecordEventFinalize.hasError'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([this]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + @override VideoRecordEventFinalize pigeon_copy() { return VideoRecordEventFinalize.pigeon_detached( diff --git a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart index 1b2847e8eb87..e77f7225cad0 100644 --- a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart +++ b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart @@ -152,13 +152,30 @@ enum VideoQuality { highest, } +/// RecordingStats provides recording statistics such as duration and bytes recorded. +/// +/// See https://developer.android.com/reference/androidx/camera/video/RecordingStats. +@ProxyApi( + kotlinOptions: KotlinProxyApiOptions(fullClassName: 'androidx.camera.video.RecordingStats'), +) +abstract class RecordingStats { + /// The recorded duration in nanoseconds. + late int recordedDurationNanos; + + /// The number of bytes recorded. + late int numBytesRecorded; +} + /// VideoRecordEvent is used to report video recording events and status. /// /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent. @ProxyApi( kotlinOptions: KotlinProxyApiOptions(fullClassName: 'androidx.camera.video.VideoRecordEvent'), ) -abstract class VideoRecordEvent {} +abstract class VideoRecordEvent { + /// The recording statistics for this event. + RecordingStats getRecordingStats(); +} /// Indicates the start of recording. /// @@ -170,6 +187,16 @@ abstract class VideoRecordEvent {} ) abstract class VideoRecordEventStart extends VideoRecordEvent {} +/// Indicates recording status updates. +/// +/// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Status. +@ProxyApi( + kotlinOptions: KotlinProxyApiOptions( + fullClassName: 'androidx.camera.video.VideoRecordEvent.Status', + ), +) +abstract class VideoRecordEventStatus extends VideoRecordEvent {} + /// Indicates the finalization of recording. /// /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize. @@ -178,7 +205,17 @@ abstract class VideoRecordEventStart extends VideoRecordEvent {} fullClassName: 'androidx.camera.video.VideoRecordEvent.Finalize', ), ) -abstract class VideoRecordEventFinalize extends VideoRecordEvent {} +abstract class VideoRecordEventFinalize extends VideoRecordEvent { + /// The error code of the finalization. + /// + /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#getError(). + int getError(); + + /// Whether the recording finalized with an error. + /// + /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#hasError(). + bool hasError(); +} /// A MeteringPoint is used to specify a region which can then be converted to /// sensor coordinate system for focus and metering purpose. diff --git a/packages/camera/camera_android_camerax/pubspec.yaml b/packages/camera/camera_android_camerax/pubspec.yaml index 4325c9c21ba7..5b26d9f7228d 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.7.5+2 +version: 0.7.5+3 environment: sdk: ^3.12.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 bd3ad26816ca..5b6705d8061a 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 @@ -49,8 +49,12 @@ import 'android_camera_camerax_test.mocks.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), MockSpec(), MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), MockSpec(), ]) @GenerateMocks( @@ -102,6 +106,30 @@ void main() { return cameraClosingEventSent && cameraErrorSent; } + /// Helper method to simulate video recording start events. + void emitRecordingStarted() { + final mockStatusEvent = MockVideoRecordEventStatus(); + final stats = RecordingStats.pigeon_detached( + recordedDurationNanos: 1000000, + numBytesRecorded: 100, + ); + when(mockStatusEvent.getRecordingStats()).thenAnswer((_) async => stats); + + AndroidCameraCameraX.videoRecordingEventStreamController.add( + VideoRecordEventStart.pigeon_detached(), + ); + AndroidCameraCameraX.videoRecordingEventStreamController.add(mockStatusEvent); + } + + /// Helper method to simulate video recording finalize events. + void emitRecordingFinalized({bool hasError = false, int error = 0}) { + final mockFinalizeEvent = MockVideoRecordEventFinalize(); + when(mockFinalizeEvent.hasError()).thenAnswer((_) async => hasError); + when(mockFinalizeEvent.getError()).thenAnswer((_) async => error); + + AndroidCameraCameraX.videoRecordingEventStreamController.add(mockFinalizeEvent); + } + /// Set up testing functionality related to the configuration /// of CameraX UseCases. void setUpOverridesForTestingUseCaseConfiguration( @@ -2292,9 +2320,7 @@ void main() { ).thenAnswer((_) async => InfoSupportedHardwareLevel.limited); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); @@ -2397,9 +2423,7 @@ void main() { ).thenAnswer((_) async => InfoSupportedHardwareLevel.limited); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); @@ -2486,9 +2510,7 @@ void main() { ).thenAnswer((_) async => InfoSupportedHardwareLevel.limited); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); await camera.startVideoCapturing( const VideoCaptureOptions(1, videoOutputPath: videoOutputPath), @@ -2498,6 +2520,152 @@ void main() { expect(camera.videoOutputPath, videoOutputPath); }); + test( + 'startVideoCapturing throws CameraException if VideoRecordEventFinalize received before starting', + () async { + final camera = AndroidCameraCameraX(); + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockCamera = MockCamera(); + final mockCameraInfo = MockCameraInfo(); + final mockCameraControl = MockCameraControl(); + final mockCamera2CameraInfo = MockCamera2CameraInfo(); + final mockRecorder = MockRecorder(); + final mockVideoCapture = MockVideoCapture(); + final mockPendingRecording = MockPendingRecording(); + final mockRecording = MockRecording(); + final mockImageAnalysis = MockImageAnalysis(); + const cameraId = 0; + + camera.processCameraProvider = mockProcessCameraProvider; + camera.cameraSelector = MockCameraSelector(); + camera.camera = mockCamera; + camera.cameraInfo = mockCameraInfo; + camera.cameraControl = mockCameraControl; + camera.recorder = mockRecorder; + camera.videoCapture = mockVideoCapture; + camera.imageAnalysis = mockImageAnalysis; + camera.enableRecordingAudio = false; + + PigeonOverrides.camera2CameraInfo_from = ({required dynamic cameraInfo}) => + mockCamera2CameraInfo; + PigeonOverrides.videoRecordEventListener_new = + ({required void Function(VideoRecordEventListener, VideoRecordEvent) onEvent}) { + return VideoRecordEventListener.pigeon_detached(onEvent: onEvent); + }; + PigeonOverrides.cameraCharacteristics_infoSupportedHardwareLevel = + MockCameraCharacteristicsKey(); + when( + mockCamera2CameraInfo.getCameraCharacteristic(any), + ).thenAnswer((_) async => InfoSupportedHardwareLevel.limited); + + when(mockRecorder.prepareRecording(any)).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.withAudioEnabled(any), + ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); + when(mockPendingRecording.start(any)).thenAnswer((_) async => mockRecording); + when(mockProcessCameraProvider.isBound(mockVideoCapture)).thenAnswer((_) async => true); + when(mockProcessCameraProvider.isBound(mockImageAnalysis)).thenAnswer((_) async => false); + + emitRecordingFinalized(hasError: true, error: 8); + + await expectLater( + () async => camera.startVideoCapturing( + const VideoCaptureOptions(cameraId, videoOutputPath: '/test/output/path'), + ), + throwsA( + isA().having( + (e) => e.description, + 'description', + contains('error code 8'), + ), + ), + ); + expect(camera.recording, isNull); + expect(camera.pendingRecording, isNull); + verify(mockProcessCameraProvider.unbind([mockVideoCapture])); + }, + ); + + test( + 'startVideoCapturing ignores initial zero-byte status events and waits until first frame recorded', + () async { + final camera = AndroidCameraCameraX(); + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockCamera = MockCamera(); + final mockCameraInfo = MockCameraInfo(); + final mockCameraControl = MockCameraControl(); + final mockCamera2CameraInfo = MockCamera2CameraInfo(); + final mockRecorder = MockRecorder(); + final mockVideoCapture = MockVideoCapture(); + final mockPendingRecording = MockPendingRecording(); + final mockRecording = MockRecording(); + final mockImageAnalysis = MockImageAnalysis(); + const cameraId = 0; + + camera.processCameraProvider = mockProcessCameraProvider; + camera.cameraSelector = MockCameraSelector(); + camera.camera = mockCamera; + camera.cameraInfo = mockCameraInfo; + camera.cameraControl = mockCameraControl; + camera.recorder = mockRecorder; + camera.videoCapture = mockVideoCapture; + camera.imageAnalysis = mockImageAnalysis; + camera.enableRecordingAudio = false; + + PigeonOverrides.camera2CameraInfo_from = ({required dynamic cameraInfo}) => + mockCamera2CameraInfo; + PigeonOverrides.videoRecordEventListener_new = + ({required void Function(VideoRecordEventListener, VideoRecordEvent) onEvent}) { + return VideoRecordEventListener.pigeon_detached(onEvent: onEvent); + }; + PigeonOverrides.cameraCharacteristics_infoSupportedHardwareLevel = + MockCameraCharacteristicsKey(); + when( + mockCamera2CameraInfo.getCameraCharacteristic(any), + ).thenAnswer((_) async => InfoSupportedHardwareLevel.limited); + + when(mockRecorder.prepareRecording(any)).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.withAudioEnabled(any), + ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); + when(mockPendingRecording.start(any)).thenAnswer((_) async => mockRecording); + when(mockProcessCameraProvider.isBound(mockVideoCapture)).thenAnswer((_) async => true); + when(mockProcessCameraProvider.isBound(mockImageAnalysis)).thenAnswer((_) async => false); + + final mockZeroStatus = MockVideoRecordEventStatus(); + final zeroStats = RecordingStats.pigeon_detached( + recordedDurationNanos: 0, + numBytesRecorded: 0, + ); + when(mockZeroStatus.getRecordingStats()).thenAnswer((_) async => zeroStats); + + final mockDataStatus = MockVideoRecordEventStatus(); + final dataStats = RecordingStats.pigeon_detached( + recordedDurationNanos: 50000000, + numBytesRecorded: 1024, + ); + when(mockDataStatus.getRecordingStats()).thenAnswer((_) async => dataStats); + + AndroidCameraCameraX.videoRecordingEventStreamController.add( + VideoRecordEventStart.pigeon_detached(), + ); + AndroidCameraCameraX.videoRecordingEventStreamController.add(mockZeroStatus); + AndroidCameraCameraX.videoRecordingEventStreamController.add(mockDataStatus); + + await camera.startVideoCapturing( + const VideoCaptureOptions(cameraId, videoOutputPath: '/test/output/path'), + ); + + expect(camera.recording, equals(mockRecording)); + }, + ); + test('startVideoCapturing called with stream options starts image streaming', () async { // Set up mocks and constants. final camera = AndroidCameraCameraX(); @@ -2582,9 +2750,7 @@ void main() { ).thenAnswer((_) async => InfoSupportedHardwareLevel.level3); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); await camera.startVideoCapturing(videoCaptureOptions); @@ -2674,9 +2840,7 @@ void main() { ).thenAnswer((_) async => InfoSupportedHardwareLevel.limited); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); // Orientation is unlocked and plugin does not need to set default target // rotation manually. @@ -2685,9 +2849,7 @@ void main() { verifyNever(mockVideoCapture.setTargetRotation(any)); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); // Orientation is locked and plugin does not need to set default target // rotation manually. @@ -2697,9 +2859,7 @@ void main() { verifyNever(mockVideoCapture.setTargetRotation(any)); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); // Orientation is locked and plugin does need to set default target // rotation manually. @@ -2710,9 +2870,7 @@ void main() { verifyNever(mockVideoCapture.setTargetRotation(any)); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); // Orientation is unlocked and plugin does need to set default target // rotation manually. @@ -2765,9 +2923,7 @@ void main() { when(camera.processCameraProvider!.isBound(videoCapture)).thenAnswer((_) async => true); // Simulate video recording being finalized so stopVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventFinalize.pigeon_detached(), - ); + emitRecordingFinalized(); final XFile file = await camera.stopVideoRecording(0); expect(file.path, videoOutputPath); @@ -2808,9 +2964,7 @@ void main() { await expectLater(() async { // Simulate video recording being finalized so stopVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventFinalize.pigeon_detached(), - ); + emitRecordingFinalized(); await camera.stopVideoRecording(0); }, throwsA(isA())); expect(camera.recording, null); @@ -2831,9 +2985,7 @@ void main() { camera.videoOutputPath = videoOutputPath; // Simulate video recording being finalized so stopVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventFinalize.pigeon_detached(), - ); + emitRecordingFinalized(); final XFile file = await camera.stopVideoRecording(0); expect(file.path, videoOutputPath); @@ -2860,9 +3012,7 @@ void main() { when(camera.processCameraProvider!.isBound(videoCapture)).thenAnswer((_) async => true); // Simulate video recording being finalized so stopVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventFinalize.pigeon_detached(), - ); + emitRecordingFinalized(); await camera.stopVideoRecording(90); verify(processCameraProvider.unbind([videoCapture])); @@ -2872,6 +3022,31 @@ void main() { verifyNoMoreInteractions(recording); }); + test('stopVideoRecording throws CameraException if recording finalized with error', () async { + final camera = AndroidCameraCameraX(); + final recording = MockRecording(); + final processCameraProvider = MockProcessCameraProvider(); + final videoCapture = MockVideoCapture(); + const videoOutputPath = '/test/output/path'; + + camera.processCameraProvider = processCameraProvider; + camera.recording = recording; + camera.videoCapture = videoCapture; + camera.videoOutputPath = videoOutputPath; + + when(camera.processCameraProvider!.isBound(videoCapture)).thenAnswer((_) async => true); + + emitRecordingFinalized(hasError: true, error: 8); + + await expectLater( + () async => camera.stopVideoRecording(0), + throwsA( + isA().having((e) => e.description, 'description', contains('error: 8')), + ), + ); + verify(processCameraProvider.unbind([videoCapture])); + }); + test('setDescriptionWhileRecording changes the camera description', () async { final camera = AndroidCameraCameraX(); final mockRecording = MockRecording(); @@ -3037,9 +3212,7 @@ void main() { when(mockCameraInfo.getCameraState()).thenAnswer((_) async => newMockLiveCameraState); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); await camera.availableCameras(); @@ -3241,9 +3414,7 @@ void main() { when(mockCamera.cameraControl).thenAnswer((_) => mockCameraControl); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); await camera.availableCameras(); @@ -5867,9 +6038,7 @@ void main() { ).thenAnswer((_) async => InfoSupportedHardwareLevel.level3); // Simulate video recording being started so startVideoRecording completes. - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); + emitRecordingStarted(); await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); 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 28eb56cfc79c..6045d30498c2 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 @@ -1,8 +1,9 @@ -// Mocks generated by Mockito 5.4.6 from annotations +// Mocks generated by Mockito from annotations // in camera_android_camerax/test/android_camera_camerax_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes + import 'dart:async' as _i5; import 'dart:typed_data' as _i7; @@ -18,6 +19,7 @@ import 'package:mockito/src/dummies.dart' as _i6; // ignore_for_file: comment_references // ignore_for_file: deprecated_member_use // ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: experimental_member_use // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: must_be_immutable @@ -209,22 +211,43 @@ class _FakeResolutionStrategy_37 extends _i1.SmartFake implements _i2.Resolution : super(parent, parentInvocation); } -class _FakeSystemServicesManager_38 extends _i1.SmartFake implements _i2.SystemServicesManager { - _FakeSystemServicesManager_38(Object parent, Invocation parentInvocation) +class _FakeRecordingStats_38 extends _i1.SmartFake implements _i2.RecordingStats { + _FakeRecordingStats_38(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeSystemServicesManager_39 extends _i1.SmartFake implements _i2.SystemServicesManager { + _FakeSystemServicesManager_39(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeVideoOutput_39 extends _i1.SmartFake implements _i2.VideoOutput { - _FakeVideoOutput_39(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +class _FakeVideoOutput_40 extends _i1.SmartFake implements _i2.VideoOutput { + _FakeVideoOutput_40(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeVideoCapture_40 extends _i1.SmartFake implements _i2.VideoCapture { - _FakeVideoCapture_40(Object parent, Invocation parentInvocation) +class _FakeVideoCapture_41 extends _i1.SmartFake implements _i2.VideoCapture { + _FakeVideoCapture_41(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeZoomState_41 extends _i1.SmartFake implements _i2.ZoomState { - _FakeZoomState_41(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +class _FakeVideoRecordEventFinalize_42 extends _i1.SmartFake + implements _i2.VideoRecordEventFinalize { + _FakeVideoRecordEventFinalize_42(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeVideoRecordEventStart_43 extends _i1.SmartFake implements _i2.VideoRecordEventStart { + _FakeVideoRecordEventStart_43(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeVideoRecordEventStatus_44 extends _i1.SmartFake implements _i2.VideoRecordEventStatus { + _FakeVideoRecordEventStatus_44(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeZoomState_45 extends _i1.SmartFake implements _i2.ZoomState { + _FakeZoomState_45(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [Analyzer]. @@ -709,7 +732,6 @@ class MockCamera2CameraInfo extends _i1.Mock implements _i2.Camera2CameraInfo { /// A class which mocks [CameraImageData]. /// /// See the documentation for Mockito's code generation for more information. -// ignore: must_be_immutable class MockCameraImageData extends _i1.Mock implements _i4.CameraImageData { @override _i4.CameraImageFormat get format => @@ -1999,6 +2021,56 @@ class MockRecording extends _i1.Mock implements _i2.Recording { as _i2.Recording); } +/// A class which mocks [RecordingStats]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRecordingStats extends _i1.Mock implements _i2.RecordingStats { + @override + int get recordedDurationNanos => + (super.noSuchMethod( + Invocation.getter(#recordedDurationNanos), + returnValue: 0, + returnValueForMissingStub: 0, + ) + as int); + + @override + int get numBytesRecorded => + (super.noSuchMethod( + Invocation.getter(#numBytesRecorded), + returnValue: 0, + returnValueForMissingStub: 0, + ) + as int); + + @override + _i2.PigeonInstanceManager get pigeon_instanceManager => + (super.noSuchMethod( + Invocation.getter(#pigeon_instanceManager), + returnValue: _FakePigeonInstanceManager_0( + this, + Invocation.getter(#pigeon_instanceManager), + ), + returnValueForMissingStub: _FakePigeonInstanceManager_0( + this, + Invocation.getter(#pigeon_instanceManager), + ), + ) + as _i2.PigeonInstanceManager); + + @override + _i2.RecordingStats pigeon_copy() => + (super.noSuchMethod( + Invocation.method(#pigeon_copy, []), + returnValue: _FakeRecordingStats_38(this, Invocation.method(#pigeon_copy, [])), + returnValueForMissingStub: _FakeRecordingStats_38( + this, + Invocation.method(#pigeon_copy, []), + ), + ) + as _i2.RecordingStats); +} + /// A class which mocks [SystemServicesManager]. /// /// See the documentation for Mockito's code generation for more information. @@ -2054,8 +2126,8 @@ class MockSystemServicesManager extends _i1.Mock implements _i2.SystemServicesMa _i2.SystemServicesManager pigeon_copy() => (super.noSuchMethod( Invocation.method(#pigeon_copy, []), - returnValue: _FakeSystemServicesManager_38(this, Invocation.method(#pigeon_copy, [])), - returnValueForMissingStub: _FakeSystemServicesManager_38( + returnValue: _FakeSystemServicesManager_39(this, Invocation.method(#pigeon_copy, [])), + returnValueForMissingStub: _FakeSystemServicesManager_39( this, Invocation.method(#pigeon_copy, []), ), @@ -2087,10 +2159,10 @@ class MockVideoCapture extends _i1.Mock implements _i2.VideoCapture { (super.noSuchMethod( Invocation.method(#getOutput, []), returnValue: _i5.Future<_i2.VideoOutput>.value( - _FakeVideoOutput_39(this, Invocation.method(#getOutput, [])), + _FakeVideoOutput_40(this, Invocation.method(#getOutput, [])), ), returnValueForMissingStub: _i5.Future<_i2.VideoOutput>.value( - _FakeVideoOutput_39(this, Invocation.method(#getOutput, [])), + _FakeVideoOutput_40(this, Invocation.method(#getOutput, [])), ), ) as _i5.Future<_i2.VideoOutput>); @@ -2108,8 +2180,8 @@ class MockVideoCapture extends _i1.Mock implements _i2.VideoCapture { _i2.VideoCapture pigeon_copy() => (super.noSuchMethod( Invocation.method(#pigeon_copy, []), - returnValue: _FakeVideoCapture_40(this, Invocation.method(#pigeon_copy, [])), - returnValueForMissingStub: _FakeVideoCapture_40( + returnValue: _FakeVideoCapture_41(this, Invocation.method(#pigeon_copy, [])), + returnValueForMissingStub: _FakeVideoCapture_41( this, Invocation.method(#pigeon_copy, []), ), @@ -2117,6 +2189,162 @@ class MockVideoCapture extends _i1.Mock implements _i2.VideoCapture { as _i2.VideoCapture); } +/// A class which mocks [VideoRecordEventFinalize]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockVideoRecordEventFinalize extends _i1.Mock implements _i2.VideoRecordEventFinalize { + @override + _i2.PigeonInstanceManager get pigeon_instanceManager => + (super.noSuchMethod( + Invocation.getter(#pigeon_instanceManager), + returnValue: _FakePigeonInstanceManager_0( + this, + Invocation.getter(#pigeon_instanceManager), + ), + returnValueForMissingStub: _FakePigeonInstanceManager_0( + this, + Invocation.getter(#pigeon_instanceManager), + ), + ) + as _i2.PigeonInstanceManager); + + @override + _i5.Future getError() => + (super.noSuchMethod( + Invocation.method(#getError, []), + returnValue: _i5.Future.value(0), + returnValueForMissingStub: _i5.Future.value(0), + ) + as _i5.Future); + + @override + _i5.Future hasError() => + (super.noSuchMethod( + Invocation.method(#hasError, []), + returnValue: _i5.Future.value(false), + returnValueForMissingStub: _i5.Future.value(false), + ) + as _i5.Future); + + @override + _i2.VideoRecordEventFinalize pigeon_copy() => + (super.noSuchMethod( + Invocation.method(#pigeon_copy, []), + returnValue: _FakeVideoRecordEventFinalize_42( + this, + Invocation.method(#pigeon_copy, []), + ), + returnValueForMissingStub: _FakeVideoRecordEventFinalize_42( + this, + Invocation.method(#pigeon_copy, []), + ), + ) + as _i2.VideoRecordEventFinalize); + + @override + _i5.Future<_i2.RecordingStats> getRecordingStats() => + (super.noSuchMethod( + Invocation.method(#getRecordingStats, []), + returnValue: _i5.Future<_i2.RecordingStats>.value( + _FakeRecordingStats_38(this, Invocation.method(#getRecordingStats, [])), + ), + returnValueForMissingStub: _i5.Future<_i2.RecordingStats>.value( + _FakeRecordingStats_38(this, Invocation.method(#getRecordingStats, [])), + ), + ) + as _i5.Future<_i2.RecordingStats>); +} + +/// A class which mocks [VideoRecordEventStart]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockVideoRecordEventStart extends _i1.Mock implements _i2.VideoRecordEventStart { + @override + _i2.PigeonInstanceManager get pigeon_instanceManager => + (super.noSuchMethod( + Invocation.getter(#pigeon_instanceManager), + returnValue: _FakePigeonInstanceManager_0( + this, + Invocation.getter(#pigeon_instanceManager), + ), + returnValueForMissingStub: _FakePigeonInstanceManager_0( + this, + Invocation.getter(#pigeon_instanceManager), + ), + ) + as _i2.PigeonInstanceManager); + + @override + _i2.VideoRecordEventStart pigeon_copy() => + (super.noSuchMethod( + Invocation.method(#pigeon_copy, []), + returnValue: _FakeVideoRecordEventStart_43(this, Invocation.method(#pigeon_copy, [])), + returnValueForMissingStub: _FakeVideoRecordEventStart_43( + this, + Invocation.method(#pigeon_copy, []), + ), + ) + as _i2.VideoRecordEventStart); + + @override + _i5.Future<_i2.RecordingStats> getRecordingStats() => + (super.noSuchMethod( + Invocation.method(#getRecordingStats, []), + returnValue: _i5.Future<_i2.RecordingStats>.value( + _FakeRecordingStats_38(this, Invocation.method(#getRecordingStats, [])), + ), + returnValueForMissingStub: _i5.Future<_i2.RecordingStats>.value( + _FakeRecordingStats_38(this, Invocation.method(#getRecordingStats, [])), + ), + ) + as _i5.Future<_i2.RecordingStats>); +} + +/// A class which mocks [VideoRecordEventStatus]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockVideoRecordEventStatus extends _i1.Mock implements _i2.VideoRecordEventStatus { + @override + _i2.PigeonInstanceManager get pigeon_instanceManager => + (super.noSuchMethod( + Invocation.getter(#pigeon_instanceManager), + returnValue: _FakePigeonInstanceManager_0( + this, + Invocation.getter(#pigeon_instanceManager), + ), + returnValueForMissingStub: _FakePigeonInstanceManager_0( + this, + Invocation.getter(#pigeon_instanceManager), + ), + ) + as _i2.PigeonInstanceManager); + + @override + _i2.VideoRecordEventStatus pigeon_copy() => + (super.noSuchMethod( + Invocation.method(#pigeon_copy, []), + returnValue: _FakeVideoRecordEventStatus_44(this, Invocation.method(#pigeon_copy, [])), + returnValueForMissingStub: _FakeVideoRecordEventStatus_44( + this, + Invocation.method(#pigeon_copy, []), + ), + ) + as _i2.VideoRecordEventStatus); + + @override + _i5.Future<_i2.RecordingStats> getRecordingStats() => + (super.noSuchMethod( + Invocation.method(#getRecordingStats, []), + returnValue: _i5.Future<_i2.RecordingStats>.value( + _FakeRecordingStats_38(this, Invocation.method(#getRecordingStats, [])), + ), + returnValueForMissingStub: _i5.Future<_i2.RecordingStats>.value( + _FakeRecordingStats_38(this, Invocation.method(#getRecordingStats, [])), + ), + ) + as _i5.Future<_i2.RecordingStats>); +} + /// A class which mocks [ZoomState]. /// /// See the documentation for Mockito's code generation for more information. @@ -2158,8 +2386,8 @@ class MockZoomState extends _i1.Mock implements _i2.ZoomState { _i2.ZoomState pigeon_copy() => (super.noSuchMethod( Invocation.method(#pigeon_copy, []), - returnValue: _FakeZoomState_41(this, Invocation.method(#pigeon_copy, [])), - returnValueForMissingStub: _FakeZoomState_41(this, Invocation.method(#pigeon_copy, [])), + returnValue: _FakeZoomState_45(this, Invocation.method(#pigeon_copy, [])), + returnValueForMissingStub: _FakeZoomState_45(this, Invocation.method(#pigeon_copy, [])), ) as _i2.ZoomState); } From eba38883480a931f9612507083dfb22cf02e5131 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Mon, 19 Oct 2026 17:48:01 -0300 Subject: [PATCH 2/5] fix: ignore leftover video recording events by recreating stream queue on start --- .../lib/src/android_camera_camerax.dart | 12 ++- .../test/android_camera_camerax_test.dart | 102 +++++++++++++++--- 2 files changed, 96 insertions(+), 18 deletions(-) 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 aa4aed85d1c7..093d1f913f6d 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 @@ -103,10 +103,12 @@ class AndroidCameraCameraX extends CameraPlatform { deviceOrientationChangedStreamController = StreamController.broadcast(); - /// Stream queue to pick up finalized viceo recording events in + /// Stream queue to pick up finalized video recording events in /// [stopVideoRecording]. - final StreamQueue videoRecordingEventStreamQueue = - StreamQueue(videoRecordingEventStreamController.stream); + @visibleForTesting + StreamQueue videoRecordingEventStreamQueue = StreamQueue( + videoRecordingEventStreamController.stream, + ); late final VideoRecordEventListener _videoRecordingEventListener = VideoRecordEventListener( onEvent: (_, VideoRecordEvent event) { @@ -1147,6 +1149,10 @@ class AndroidCameraCameraX extends CameraPlatform { // There is currently an active recording, so do not start a new one. return; } + unawaited(videoRecordingEventStreamQueue.cancel()); + videoRecordingEventStreamQueue = StreamQueue( + videoRecordingEventStreamController.stream, + ); final dynamic Function(CameraImageData)? streamCallback = options.streamCallback; if (streamCallback == null) { // For potential performance improvements, unbind imageAnalysis if not in use. 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 5b6705d8061a..e9196c36c9ff 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 @@ -115,10 +115,12 @@ void main() { ); when(mockStatusEvent.getRecordingStats()).thenAnswer((_) async => stats); - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), - ); - AndroidCameraCameraX.videoRecordingEventStreamController.add(mockStatusEvent); + scheduleMicrotask(() { + AndroidCameraCameraX.videoRecordingEventStreamController.add( + VideoRecordEventStart.pigeon_detached(), + ); + AndroidCameraCameraX.videoRecordingEventStreamController.add(mockStatusEvent); + }); } /// Helper method to simulate video recording finalize events. @@ -127,7 +129,9 @@ void main() { when(mockFinalizeEvent.hasError()).thenAnswer((_) async => hasError); when(mockFinalizeEvent.getError()).thenAnswer((_) async => error); - AndroidCameraCameraX.videoRecordingEventStreamController.add(mockFinalizeEvent); + scheduleMicrotask(() { + AndroidCameraCameraX.videoRecordingEventStreamController.add(mockFinalizeEvent); + }); } /// Set up testing functionality related to the configuration @@ -2652,11 +2656,79 @@ void main() { ); when(mockDataStatus.getRecordingStats()).thenAnswer((_) async => dataStats); - AndroidCameraCameraX.videoRecordingEventStreamController.add( - VideoRecordEventStart.pigeon_detached(), + scheduleMicrotask(() { + AndroidCameraCameraX.videoRecordingEventStreamController.add( + VideoRecordEventStart.pigeon_detached(), + ); + AndroidCameraCameraX.videoRecordingEventStreamController.add(mockZeroStatus); + AndroidCameraCameraX.videoRecordingEventStreamController.add(mockDataStatus); + }); + + await camera.startVideoCapturing( + const VideoCaptureOptions(cameraId, videoOutputPath: '/test/output/path'), ); - AndroidCameraCameraX.videoRecordingEventStreamController.add(mockZeroStatus); - AndroidCameraCameraX.videoRecordingEventStreamController.add(mockDataStatus); + + expect(camera.recording, equals(mockRecording)); + }, + ); + + test( + 'startVideoCapturing recreates stream queue and ignores leftover events from previous recording', + () async { + final camera = AndroidCameraCameraX(); + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockCamera = MockCamera(); + final mockCameraInfo = MockCameraInfo(); + final mockCameraControl = MockCameraControl(); + final mockCamera2CameraInfo = MockCamera2CameraInfo(); + final mockRecorder = MockRecorder(); + final mockVideoCapture = MockVideoCapture(); + final mockPendingRecording = MockPendingRecording(); + final mockRecording = MockRecording(); + final mockImageAnalysis = MockImageAnalysis(); + const cameraId = 0; + + camera.processCameraProvider = mockProcessCameraProvider; + camera.cameraSelector = MockCameraSelector(); + camera.camera = mockCamera; + camera.cameraInfo = mockCameraInfo; + camera.cameraControl = mockCameraControl; + camera.recorder = mockRecorder; + camera.videoCapture = mockVideoCapture; + camera.imageAnalysis = mockImageAnalysis; + camera.enableRecordingAudio = false; + + PigeonOverrides.camera2CameraInfo_from = ({required dynamic cameraInfo}) => + mockCamera2CameraInfo; + PigeonOverrides.videoRecordEventListener_new = + ({required void Function(VideoRecordEventListener, VideoRecordEvent) onEvent}) { + return VideoRecordEventListener.pigeon_detached(onEvent: onEvent); + }; + PigeonOverrides.cameraCharacteristics_infoSupportedHardwareLevel = + MockCameraCharacteristicsKey(); + when( + mockCamera2CameraInfo.getCameraCharacteristic(any), + ).thenAnswer((_) async => InfoSupportedHardwareLevel.limited); + + when(mockRecorder.prepareRecording(any)).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.withAudioEnabled(any), + ).thenAnswer((_) async => mockPendingRecording); + when( + mockPendingRecording.asPersistentRecording(), + ).thenAnswer((_) async => mockPendingRecording); + when(mockPendingRecording.start(any)).thenAnswer((_) async => mockRecording); + when(mockProcessCameraProvider.isBound(mockVideoCapture)).thenAnswer((_) async => true); + when(mockProcessCameraProvider.isBound(mockImageAnalysis)).thenAnswer((_) async => false); + + // Simulate a leftover finalize event from a previous session that ended + // without calling stopVideoRecording(). + final mockLeftoverFinalize = MockVideoRecordEventFinalize(); + when(mockLeftoverFinalize.getError()).thenAnswer((_) async => 8); + AndroidCameraCameraX.videoRecordingEventStreamController.add(mockLeftoverFinalize); + + // Simulate the new recording session emitting its own start and status events. + emitRecordingStarted(); await camera.startVideoCapturing( const VideoCaptureOptions(cameraId, videoOutputPath: '/test/output/path'), @@ -3211,9 +3283,6 @@ void main() { when(newMockCamera.cameraControl).thenReturn(mockCameraControl); when(mockCameraInfo.getCameraState()).thenAnswer((_) async => newMockLiveCameraState); - // Simulate video recording being started so startVideoRecording completes. - emitRecordingStarted(); - await camera.availableCameras(); final int flutterSurfaceTextureId = await camera.createCameraWithSettings( @@ -3222,6 +3291,9 @@ void main() { ); await camera.initializeCamera(flutterSurfaceTextureId); + // Simulate video recording being started so startVideoRecording completes. + emitRecordingStarted(); + await camera.startVideoCapturing(VideoCaptureOptions(flutterSurfaceTextureId)); await camera.setDescriptionWhileRecording(testFrontCameraDescription); @@ -3413,9 +3485,6 @@ void main() { when(mockCameraInfo.getCameraState()).thenAnswer((_) async => MockLiveCameraState()); when(mockCamera.cameraControl).thenAnswer((_) => mockCameraControl); - // Simulate video recording being started so startVideoRecording completes. - emitRecordingStarted(); - await camera.availableCameras(); final int flutterSurfaceTextureId = await camera.createCameraWithSettings( @@ -3424,6 +3493,9 @@ void main() { ); await camera.initializeCamera(flutterSurfaceTextureId); + // Simulate video recording being started so startVideoRecording completes. + emitRecordingStarted(); + await camera.startVideoCapturing(VideoCaptureOptions(flutterSurfaceTextureId)); // pause the preview From bfa519a63dca9298ab6469f87609cee6618206b1 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Mon, 26 Oct 2026 20:06:18 -0300 Subject: [PATCH 3/5] refactor: update VideoRecordEventFinalize to pass error and hasError properties synchronously via pigeon --- .../plugins/camerax/CameraXLibrary.g.kt | 63 +--------------- .../VideoRecordEventFinalizeProxyApi.java | 2 +- .../camerax/VideoRecordEventFinalizeTest.java | 4 +- .../lib/src/android_camera_camerax.dart | 6 +- .../lib/src/camerax_library.g.dart | 73 +++++-------------- .../pigeons/camerax_library.dart | 4 +- .../test/android_camera_camerax_test.dart | 6 +- .../android_camera_camerax_test.mocks.dart | 37 ++++------ 8 files changed, 50 insertions(+), 145 deletions(-) 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 b767b228f39f..5dc7fb05ab8b 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 @@ -756,8 +756,6 @@ abstract class CameraXLibraryPigeonProxyApiRegistrar(val binaryMessenger: Binary PigeonApiCameraIntegerRange.setUpMessageHandlers( binaryMessenger, getPigeonApiCameraIntegerRange()) PigeonApiVideoRecordEvent.setUpMessageHandlers(binaryMessenger, getPigeonApiVideoRecordEvent()) - PigeonApiVideoRecordEventFinalize.setUpMessageHandlers( - binaryMessenger, getPigeonApiVideoRecordEventFinalize()) PigeonApiMeteringPoint.setUpMessageHandlers(binaryMessenger, getPigeonApiMeteringPoint()) PigeonApiObserver.setUpMessageHandlers(binaryMessenger, getPigeonApiObserver()) PigeonApiCameraInfo.setUpMessageHandlers(binaryMessenger, getPigeonApiCameraInfo()) @@ -814,7 +812,6 @@ abstract class CameraXLibraryPigeonProxyApiRegistrar(val binaryMessenger: Binary PigeonApiCameraSize.setUpMessageHandlers(binaryMessenger, null) PigeonApiCameraIntegerRange.setUpMessageHandlers(binaryMessenger, null) PigeonApiVideoRecordEvent.setUpMessageHandlers(binaryMessenger, null) - PigeonApiVideoRecordEventFinalize.setUpMessageHandlers(binaryMessenger, null) PigeonApiMeteringPoint.setUpMessageHandlers(binaryMessenger, null) PigeonApiObserver.setUpMessageHandlers(binaryMessenger, null) PigeonApiCameraInfo.setUpMessageHandlers(binaryMessenger, null) @@ -2112,7 +2109,7 @@ abstract class PigeonApiVideoRecordEventFinalize( * See * https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#getError(). */ - abstract fun getError(pigeon_instance: androidx.camera.video.VideoRecordEvent.Finalize): Long + abstract fun error(pigeon_instance: androidx.camera.video.VideoRecordEvent.Finalize): Long /** * Whether the recording finalized with an error. @@ -2122,60 +2119,6 @@ abstract class PigeonApiVideoRecordEventFinalize( */ abstract fun hasError(pigeon_instance: androidx.camera.video.VideoRecordEvent.Finalize): Boolean - companion object { - @Suppress("LocalVariableName") - fun setUpMessageHandlers( - binaryMessenger: BinaryMessenger, - api: PigeonApiVideoRecordEventFinalize? - ) { - val codec = api?.pigeonRegistrar?.codec ?: CameraXLibraryPigeonCodec() - run { - val channel = - BasicMessageChannel( - binaryMessenger, - "dev.flutter.pigeon.camera_android_camerax.VideoRecordEventFinalize.getError", - codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val pigeon_instanceArg = args[0] as androidx.camera.video.VideoRecordEvent.Finalize - val wrapped: List = - try { - listOf(api.getError(pigeon_instanceArg)) - } catch (exception: Throwable) { - CameraXLibraryPigeonUtils.wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = - BasicMessageChannel( - binaryMessenger, - "dev.flutter.pigeon.camera_android_camerax.VideoRecordEventFinalize.hasError", - codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val pigeon_instanceArg = args[0] as androidx.camera.video.VideoRecordEvent.Finalize - val wrapped: List = - try { - listOf(api.hasError(pigeon_instanceArg)) - } catch (exception: Throwable) { - CameraXLibraryPigeonUtils.wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - } - } - @Suppress("LocalVariableName", "FunctionName") /** * Creates a Dart instance of VideoRecordEventFinalize and attaches it to [pigeon_instanceArg]. @@ -2193,12 +2136,14 @@ abstract class PigeonApiVideoRecordEventFinalize( } else { val pigeon_identifierArg = pigeonRegistrar.instanceManager.addHostCreatedInstance(pigeon_instanceArg) + val errorArg = error(pigeon_instanceArg) + val hasErrorArg = hasError(pigeon_instanceArg) val binaryMessenger = pigeonRegistrar.binaryMessenger val codec = pigeonRegistrar.codec val channelName = "dev.flutter.pigeon.camera_android_camerax.VideoRecordEventFinalize.pigeon_newInstance" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(pigeon_identifierArg)) { + channel.send(listOf(pigeon_identifierArg, errorArg, hasErrorArg)) { if (it is List<*>) { if (it.size > 1) { callback( diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeProxyApi.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeProxyApi.java index 8ef6aa4bfd7b..637122717935 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeProxyApi.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeProxyApi.java @@ -14,7 +14,7 @@ class VideoRecordEventFinalizeProxyApi extends PigeonApiVideoRecordEventFinalize } @Override - public long getError(@NonNull VideoRecordEvent.Finalize pigeonInstance) { + public long error(@NonNull VideoRecordEvent.Finalize pigeonInstance) { return (long) pigeonInstance.getError(); } diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeTest.java index 6fcfb068ce4b..eb3ff446fe1a 100644 --- a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeTest.java +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/VideoRecordEventFinalizeTest.java @@ -13,14 +13,14 @@ public class VideoRecordEventFinalizeTest { @Test - public void getError_returnsExpectedValue() { + public void error_returnsExpectedValue() { final PigeonApiVideoRecordEventFinalize api = new TestProxyApiRegistrar().getPigeonApiVideoRecordEventFinalize(); final VideoRecordEvent.Finalize instance = mock(VideoRecordEvent.Finalize.class); when(instance.getError()).thenReturn(VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA); - assertEquals((long) VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA, api.getError(instance)); + assertEquals((long) VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA, api.error(instance)); } @Test 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 fd1c4f752bb2..414d82a06c60 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 @@ -1256,7 +1256,7 @@ class AndroidCameraCameraX extends CameraPlatform { VideoRecordEvent event = await videoRecordingEventStreamQueue.next; while (true) { if (event is VideoRecordEventFinalize) { - final int errorCode = await event.getError(); + final int errorCode = event.error; recording = null; pendingRecording = null; await _unbindUseCaseFromLifecycle(videoCapture!); @@ -1312,8 +1312,8 @@ class AndroidCameraCameraX extends CameraPlatform { await _unbindUseCaseFromLifecycle(videoCapture!); - if (await event.hasError()) { - final int errorCode = await event.getError(); + if (event.hasError) { + final int errorCode = event.error; throw CameraException( 'videoRecordingFailed', 'Video recording finalized with error: $errorCode', 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 74174ade7f0f..0dccdf09feff 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 @@ -1685,16 +1685,25 @@ class VideoRecordEventFinalize extends VideoRecordEvent { VideoRecordEventFinalize.pigeon_detached({ super.pigeon_binaryMessenger, super.pigeon_instanceManager, + required this.error, + required this.hasError, }) : super.pigeon_detached(); - late final _PigeonInternalProxyApiBaseCodec _pigeonVar_codecVideoRecordEventFinalize = - _PigeonInternalProxyApiBaseCodec(pigeon_instanceManager); + /// The error code of the finalization. + /// + /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#getError(). + final int error; + + /// Whether the recording finalized with an error. + /// + /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#hasError(). + final bool hasError; static void pigeon_setUpMessageHandlers({ bool pigeon_clearHandlers = false, BinaryMessenger? pigeon_binaryMessenger, PigeonInstanceManager? pigeon_instanceManager, - VideoRecordEventFinalize Function()? pigeon_newInstance, + VideoRecordEventFinalize Function(int error, bool hasError)? pigeon_newInstance, }) { final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = _PigeonInternalProxyApiBaseCodec( pigeon_instanceManager ?? PigeonInstanceManager.instance, @@ -1712,12 +1721,16 @@ class VideoRecordEventFinalize extends VideoRecordEvent { pigeonVar_channel.setMessageHandler((Object? message) async { final List args = message! as List; final int arg_pigeon_instanceIdentifier = args[0]! as int; + final int arg_error = args[1]! as int; + final bool arg_hasError = args[2]! as bool; try { (pigeon_instanceManager ?? PigeonInstanceManager.instance).addHostCreatedInstance( - pigeon_newInstance?.call() ?? + pigeon_newInstance?.call(arg_error, arg_hasError) ?? VideoRecordEventFinalize.pigeon_detached( pigeon_binaryMessenger: pigeon_binaryMessenger, pigeon_instanceManager: pigeon_instanceManager, + error: arg_error, + hasError: arg_hasError, ), arg_pigeon_instanceIdentifier, ); @@ -1734,61 +1747,13 @@ class VideoRecordEventFinalize extends VideoRecordEvent { } } - /// The error code of the finalization. - /// - /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#getError(). - Future getError() async { - final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = - _pigeonVar_codecVideoRecordEventFinalize; - final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; - const pigeonVar_channelName = - 'dev.flutter.pigeon.camera_android_camerax.VideoRecordEventFinalize.getError'; - final pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([this]); - final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - - final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); - return pigeonVar_replyValue! as int; - } - - /// Whether the recording finalized with an error. - /// - /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#hasError(). - Future hasError() async { - final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = - _pigeonVar_codecVideoRecordEventFinalize; - final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; - const pigeonVar_channelName = - 'dev.flutter.pigeon.camera_android_camerax.VideoRecordEventFinalize.hasError'; - final pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([this]); - final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - - final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); - return pigeonVar_replyValue! as bool; - } - @override VideoRecordEventFinalize pigeon_copy() { return VideoRecordEventFinalize.pigeon_detached( pigeon_binaryMessenger: pigeon_binaryMessenger, pigeon_instanceManager: pigeon_instanceManager, + error: error, + hasError: hasError, ); } } diff --git a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart index 0a802a165b83..e4c9d100f57f 100644 --- a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart +++ b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart @@ -209,12 +209,12 @@ abstract class VideoRecordEventFinalize extends VideoRecordEvent { /// The error code of the finalization. /// /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#getError(). - int getError(); + late int error; /// Whether the recording finalized with an error. /// /// See https://developer.android.com/reference/androidx/camera/video/VideoRecordEvent.Finalize#hasError(). - bool hasError(); + late bool hasError; } /// A MeteringPoint is used to specify a region which can then be converted to 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 5150e867f32e..2a1618d5ead9 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 @@ -126,8 +126,8 @@ void main() { /// Helper method to simulate video recording finalize events. void emitRecordingFinalized({bool hasError = false, int error = 0}) { final mockFinalizeEvent = MockVideoRecordEventFinalize(); - when(mockFinalizeEvent.hasError()).thenAnswer((_) async => hasError); - when(mockFinalizeEvent.getError()).thenAnswer((_) async => error); + when(mockFinalizeEvent.hasError).thenReturn(hasError); + when(mockFinalizeEvent.error).thenReturn(error); scheduleMicrotask(() { AndroidCameraCameraX.videoRecordingEventStreamController.add(mockFinalizeEvent); @@ -2729,7 +2729,7 @@ void main() { // Simulate a leftover finalize event from a previous session that ended // without calling stopVideoRecording(). final mockLeftoverFinalize = MockVideoRecordEventFinalize(); - when(mockLeftoverFinalize.getError()).thenAnswer((_) async => 8); + when(mockLeftoverFinalize.error).thenReturn(8); AndroidCameraCameraX.videoRecordingEventStreamController.add(mockLeftoverFinalize); // Simulate the new recording session emitting its own start and status events. 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 2c5e087de8bd..088111797362 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 @@ -1,9 +1,8 @@ -// Mocks generated by Mockito from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in camera_android_camerax/test/android_camera_camerax_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes - import 'dart:async' as _i5; import 'dart:typed_data' as _i7; @@ -19,7 +18,6 @@ import 'package:mockito/src/dummies.dart' as _i6; // ignore_for_file: comment_references // ignore_for_file: deprecated_member_use // ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: experimental_member_use // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: must_be_immutable @@ -741,6 +739,7 @@ class MockCamera2CameraInfo extends _i1.Mock implements _i2.Camera2CameraInfo { /// A class which mocks [CameraImageData]. /// /// See the documentation for Mockito's code generation for more information. +// ignore: must_be_immutable class MockCameraImageData extends _i1.Mock implements _i4.CameraImageData { @override _i4.CameraImageFormat get format => @@ -2202,6 +2201,20 @@ class MockVideoCapture extends _i1.Mock implements _i2.VideoCapture { /// /// See the documentation for Mockito's code generation for more information. class MockVideoRecordEventFinalize extends _i1.Mock implements _i2.VideoRecordEventFinalize { + @override + int get error => + (super.noSuchMethod(Invocation.getter(#error), returnValue: 0, returnValueForMissingStub: 0) + as int); + + @override + bool get hasError => + (super.noSuchMethod( + Invocation.getter(#hasError), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); + @override _i2.PigeonInstanceManager get pigeon_instanceManager => (super.noSuchMethod( @@ -2217,24 +2230,6 @@ class MockVideoRecordEventFinalize extends _i1.Mock implements _i2.VideoRecordEv ) as _i2.PigeonInstanceManager); - @override - _i5.Future getError() => - (super.noSuchMethod( - Invocation.method(#getError, []), - returnValue: _i5.Future.value(0), - returnValueForMissingStub: _i5.Future.value(0), - ) - as _i5.Future); - - @override - _i5.Future hasError() => - (super.noSuchMethod( - Invocation.method(#hasError, []), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) - as _i5.Future); - @override _i2.VideoRecordEventFinalize pigeon_copy() => (super.noSuchMethod( From 4c2bced7e2a59defc9fc99d29b34bd1f0518e4d2 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Mon, 26 Oct 2026 20:19:06 -0300 Subject: [PATCH 4/5] fix: add tolerance for video duration check in camera_android_camerax integration test --- .../example/integration_test/integration_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 44ed0b2707a9..c96bff430696 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 @@ -157,7 +157,8 @@ void main() { final int duration = videoController.value.duration.inMilliseconds; await videoController.dispose(); - expect(duration, greaterThan(1700)); + // Allow a tolerance around the 2s target for timer jitter and frame + // quantization (typically 100-200ms on device, with margin for CI load). final int delta = (duration - 2000).abs(); expect(delta, lessThan(350)); expect(duration, lessThanOrEqualTo(postStopTime)); From 29b7dc6272e0152d0bd142b18e8cb905a7e73ffd Mon Sep 17 00:00:00 2001 From: Mairramer Date: Tue, 27 Oct 2026 08:41:32 -0300 Subject: [PATCH 5/5] chore: bump camera package version and update dependency constraints --- .../example/integration_test/integration_test.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 c96bff430696..1c78db4236b4 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 @@ -157,8 +157,10 @@ void main() { final int duration = videoController.value.duration.inMilliseconds; await videoController.dispose(); - // Allow a tolerance around the 2s target for timer jitter and frame - // quantization (typically 100-200ms on device, with margin for CI load). + // Allow a 350ms tolerance around the 2s target to account for timer jitter, + // encoder latency, and frame quantization (typically 100-200ms on device, + // with extra margin to prevent flakes under heavy CI load). + // See https://github.com/flutter/flutter/issues/101785. final int delta = (duration - 2000).abs(); expect(delta, lessThan(350)); expect(duration, lessThanOrEqualTo(postStopTime));