diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 73cbd87ada2f..91d440e5eedb 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.12.1 + +* Adds `CameraController.isZeroShutterLagSupported` and + `CameraController.setZeroShutterLagEnabled` for zero-shutter-lag still image + capture on platforms that support it. + ## 0.12.0+2 * Fixes a crash where a `CameraController` could update its value after being disposed, throwing "A CameraController was used after being disposed". diff --git a/packages/camera/camera/example/pubspec.yaml b/packages/camera/camera/example/pubspec.yaml index d94d8bef8cfa..228f9e863295 100644 --- a/packages/camera/camera/example/pubspec.yaml +++ b/packages/camera/camera/example/pubspec.yaml @@ -31,3 +31,7 @@ dev_dependencies: flutter: uses-material-design: true +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../../packages/camera/camera_platform_interface} diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart index a930723761aa..4ec707722776 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -796,6 +796,35 @@ class CameraController extends ValueNotifier { } } + /// Returns whether the selected camera supports zero-shutter-lag capture. + /// + /// Zero-shutter-lag reduces the latency of [takePicture] by returning a + /// recently buffered frame instead of waiting for a new capture. + Future isZeroShutterLagSupported() async { + _throwIfNotInitialized('isZeroShutterLagSupported'); + try { + return await CameraPlatform.instance.isZeroShutterLagSupported(_cameraId); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + + /// Enables or disables zero-shutter-lag capture for still image capture. + /// + /// [isZeroShutterLagSupported] must be called first; only call this method + /// when it returns `true`. + /// + /// When enabled, [takePicture] returns a recently buffered frame instead of + /// waiting for a new capture, reducing shutter latency. + Future setZeroShutterLagEnabled(bool enabled) async { + _throwIfNotInitialized('setZeroShutterLagEnabled'); + try { + await CameraPlatform.instance.setZeroShutterLagEnabled(_cameraId, enabled); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + /// Sets the flash mode for taking pictures. Future setFlashMode(FlashMode mode) async { try { diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 84966be525a0..246a55e05a3c 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for controlling the camera. Supports previewing Dart. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.12.0+2 +version: 0.12.1 environment: sdk: ^3.10.0 @@ -23,7 +23,7 @@ flutter: dependencies: camera_android_camerax: ^0.7.0 camera_avfoundation: ^0.10.0 - camera_platform_interface: ^2.12.0 + camera_platform_interface: ^2.14.0 camera_web: ^0.3.3 flutter: sdk: flutter @@ -38,3 +38,7 @@ dev_dependencies: topics: - camera +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../packages/camera/camera_platform_interface} diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart index c5dfde4f7383..7b372c56b816 100644 --- a/packages/camera/camera/test/camera_preview_test.dart +++ b/packages/camera/camera/test/camera_preview_test.dart @@ -105,6 +105,12 @@ class FakeController extends ValueNotifier implements CameraControl @override Future setZoomLevel(double zoom) async {} + @override + Future isZeroShutterLagSupported() async => false; + + @override + Future setZeroShutterLagEnabled(bool enabled) async {} + @override Future startImageStream(onLatestImageAvailable onAvailable) async {} diff --git a/packages/camera/camera/test/camera_test.dart b/packages/camera/camera/test/camera_test.dart index 5ddb256513c9..430cb7cc3d7d 100644 --- a/packages/camera/camera/test/camera_test.dart +++ b/packages/camera/camera/test/camera_test.dart @@ -727,6 +727,110 @@ void main() { verify(CameraPlatform.instance.setZoomLevel(mockInitializeCamera, 42.0)).called(1); }); + test('isZeroShutterLagSupported() returns the platform value.', () async { + final cameraController = CameraController( + const CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90, + ), + ResolutionPreset.max, + ); + + await cameraController.initialize(); + when( + CameraPlatform.instance.isZeroShutterLagSupported(mockInitializeCamera), + ).thenAnswer((_) async => true); + + expect(await cameraController.isZeroShutterLagSupported(), isTrue); + verify(CameraPlatform.instance.isZeroShutterLagSupported(mockInitializeCamera)).called(1); + + reset(CameraPlatform.instance); + }); + + test('isZeroShutterLagSupported() throws $CameraException when uninitialized.', () async { + final cameraController = CameraController( + const CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90, + ), + ResolutionPreset.max, + ); + + expect( + () => cameraController.isZeroShutterLagSupported(), + throwsA( + isA() + .having( + (CameraException error) => error.code, + 'code', + 'Uninitialized CameraController', + ) + .having( + (CameraException error) => error.description, + 'description', + 'isZeroShutterLagSupported() was called on an uninitialized CameraController.', + ), + ), + ); + }); + + test( + 'setZeroShutterLagEnabled() completes and calls method channel with correct value.', + () async { + final cameraController = CameraController( + const CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90, + ), + ResolutionPreset.max, + ); + + await cameraController.initialize(); + await cameraController.setZeroShutterLagEnabled(true); + + verify( + CameraPlatform.instance.setZeroShutterLagEnabled(mockInitializeCamera, true), + ).called(1); + }, + ); + + test( + 'setZeroShutterLagEnabled() throws $CameraException when a platform exception occured.', + () async { + final cameraController = CameraController( + const CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90, + ), + ResolutionPreset.max, + ); + + await cameraController.initialize(); + when( + CameraPlatform.instance.setZeroShutterLagEnabled(mockInitializeCamera, true), + ).thenThrow(CameraException('TEST_ERROR', 'This is a test error messge')); + + expect( + () => cameraController.setZeroShutterLagEnabled(true), + throwsA( + isA() + .having((CameraException error) => error.code, 'code', 'TEST_ERROR') + .having( + (CameraException error) => error.description, + 'description', + 'This is a test error messge', + ), + ), + ); + + reset(CameraPlatform.instance); + }, + ); + test('setFlashMode() calls $CameraPlatform', () async { final cameraController = CameraController( const CameraDescription( @@ -3646,6 +3750,19 @@ class MockCameraPlatform extends Mock with MockPlatformInterfaceMixin implements @override Future setVideoStabilizationMode(int cameraId, VideoStabilizationMode mode) async => super.noSuchMethod(Invocation.method(#setVideoStabilizationMode, [cameraId, mode])); + + @override + Future isZeroShutterLagSupported(int cameraId) async => + super.noSuchMethod( + Invocation.method(#isZeroShutterLagSupported, [cameraId]), + returnValue: Future.value(false), + ) + as Future; + + @override + Future setZeroShutterLagEnabled(int cameraId, bool enabled) async => super.noSuchMethod( + Invocation.method(#setZeroShutterLagEnabled, [cameraId, enabled]), + ); } class MockCameraDescription extends CameraDescription { diff --git a/packages/camera/camera_android/example/pubspec.yaml b/packages/camera/camera_android/example/pubspec.yaml index 7f683825c765..67ba9873c31d 100644 --- a/packages/camera/camera_android/example/pubspec.yaml +++ b/packages/camera/camera_android/example/pubspec.yaml @@ -31,3 +31,7 @@ dev_dependencies: flutter: uses-material-design: true +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../../packages/camera/camera_platform_interface} diff --git a/packages/camera/camera_android/pubspec.yaml b/packages/camera/camera_android/pubspec.yaml index 42b1fedf5ee1..c72b489e0251 100644 --- a/packages/camera/camera_android/pubspec.yaml +++ b/packages/camera/camera_android/pubspec.yaml @@ -37,3 +37,7 @@ dev_dependencies: topics: - camera +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../packages/camera/camera_platform_interface} diff --git a/packages/camera/camera_android_camerax/CHANGELOG.md b/packages/camera/camera_android_camerax/CHANGELOG.md index f8cfdc5da340..3b02110c3886 100644 --- a/packages/camera/camera_android_camerax/CHANGELOG.md +++ b/packages/camera/camera_android_camerax/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.7.5 + +* Adds support for zero-shutter-lag still image capture via + `isZeroShutterLagSupported` and `setZeroShutterLagEnabled`. + ## 0.7.4+7 * Updates pigeon dev_dependency to ^27.3.2 for analyzer 14 compatibility. diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java index 509cdc7ba357..8409d92b4557 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java @@ -9,6 +9,7 @@ import androidx.camera.core.CameraInfo; import androidx.camera.core.CameraSelector; import androidx.camera.core.ExperimentalLensFacing; +import androidx.camera.core.ExperimentalZeroShutterLag; import androidx.camera.core.ExposureState; /** @@ -61,4 +62,10 @@ public LiveDataProxyApi.LiveDataWrapper getZoomState(CameraInfo pigeonInstance) return new LiveDataProxyApi.LiveDataWrapper( pigeonInstance.getZoomState(), LiveDataSupportedType.ZOOM_STATE); } + + @Override + @OptIn(markerClass = ExperimentalZeroShutterLag.class) + public boolean isZslSupported(CameraInfo pigeonInstance) { + return pigeonInstance.isZslSupported(); + } } 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..c871645e206f 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 @@ -2199,6 +2199,13 @@ abstract class PigeonApiCameraInfo( pigeon_instance: androidx.camera.core.CameraInfo ): io.flutter.plugins.camerax.LiveDataProxyApi.LiveDataWrapper + /** + * Returns whether the camera supports zero-shutter-lag capture. + * + * See https://developer.android.com/reference/androidx/camera/core/CameraInfo#isZslSupported(). + */ + abstract fun isZslSupported(pigeon_instance: androidx.camera.core.CameraInfo): Boolean + companion object { @Suppress("LocalVariableName") fun setUpMessageHandlers(binaryMessenger: BinaryMessenger, api: PigeonApiCameraInfo?) { @@ -2247,6 +2254,28 @@ abstract class PigeonApiCameraInfo( channel.setMessageHandler(null) } } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.camera_android_camerax.CameraInfo.isZslSupported", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pigeon_instanceArg = args[0] as androidx.camera.core.CameraInfo + val wrapped: List = + try { + listOf(api.isZslSupported(pigeon_instanceArg)) + } catch (exception: Throwable) { + CameraXLibraryPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } @@ -4248,7 +4277,8 @@ abstract class PigeonApiImageCapture( resolutionSelector: androidx.camera.core.resolutionselector.ResolutionSelector?, targetRotation: Long?, flashMode: CameraXFlashMode?, - jpegQuality: Long? + jpegQuality: Long?, + zeroShutterLagEnabled: Boolean? ): androidx.camera.core.ImageCapture abstract fun resolutionSelector( @@ -4290,11 +4320,16 @@ abstract class PigeonApiImageCapture( val targetRotationArg = args[2] as Long? val flashModeArg = args[3] as CameraXFlashMode? val jpegQualityArg = args[4] as Long? + val zeroShutterLagEnabledArg = args[5] as Boolean? val wrapped: List = try { api.pigeonRegistrar.instanceManager.addDartCreatedInstance( api.pigeon_defaultConstructor( - resolutionSelectorArg, targetRotationArg, flashModeArg, jpegQualityArg), + resolutionSelectorArg, + targetRotationArg, + flashModeArg, + jpegQualityArg, + zeroShutterLagEnabledArg), pigeon_identifierArg) listOf(null) } catch (exception: Throwable) { diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageCaptureProxyApi.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageCaptureProxyApi.java index 2e552580947a..7b932dce65af 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageCaptureProxyApi.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageCaptureProxyApi.java @@ -6,6 +6,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.camera.core.ExperimentalZeroShutterLag; import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageCaptureException; import androidx.camera.core.resolutionselector.ResolutionSelector; @@ -37,15 +39,20 @@ public ProxyApiRegistrar getPigeonRegistrar() { @NonNull @Override + @OptIn(markerClass = ExperimentalZeroShutterLag.class) public ImageCapture pigeon_defaultConstructor( @Nullable ResolutionSelector resolutionSelector, @Nullable Long targetRotation, @Nullable CameraXFlashMode flashMode, - @Nullable Long jpegQuality) { + @Nullable Long jpegQuality, + @Nullable Boolean zeroShutterLagEnabled) { final ImageCapture.Builder builder = new ImageCapture.Builder(); if (targetRotation != null) { builder.setTargetRotation(targetRotation.intValue()); } + if (Boolean.TRUE.equals(zeroShutterLagEnabled)) { + builder.setCaptureMode(ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG); + } if (flashMode != null) { // This sets the requested flash mode, but may fail silently. switch (flashMode) { diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/CameraInfoTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/CameraInfoTest.java index cb626ce45994..9706104259b5 100644 --- a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/CameraInfoTest.java +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/CameraInfoTest.java @@ -5,12 +5,15 @@ package io.flutter.plugins.camerax; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import androidx.annotation.OptIn; import androidx.camera.core.CameraInfo; import androidx.camera.core.CameraSelector; import androidx.camera.core.CameraState; +import androidx.camera.core.ExperimentalZeroShutterLag; import androidx.camera.core.ExposureState; import androidx.camera.core.ZoomState; import androidx.lifecycle.LiveData; @@ -19,6 +22,17 @@ import org.junit.Test; public class CameraInfoTest { + @Test + @OptIn(markerClass = ExperimentalZeroShutterLag.class) + public void isZslSupported_makesCallToRetrieveZslSupport() { + final PigeonApiCameraInfo api = new TestProxyApiRegistrar().getPigeonApiCameraInfo(); + + final CameraInfo instance = mock(CameraInfo.class); + when(instance.isZslSupported()).thenReturn(true); + + assertTrue(api.isZslSupported(instance)); + } + @Test public void sensorRotationDegrees_makesCallToRetrieveSensorRotationDegrees() { final PigeonApiCameraInfo api = new TestProxyApiRegistrar().getPigeonApiCameraInfo(); diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageCaptureTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageCaptureTest.java index 647fccd09bfb..6a8f5462917b 100644 --- a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageCaptureTest.java +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageCaptureTest.java @@ -18,6 +18,8 @@ import android.os.Looper; import android.view.Surface; import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.camera.core.ExperimentalZeroShutterLag; import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageCaptureException; import androidx.camera.core.resolutionselector.ResolutionSelector; @@ -44,7 +46,7 @@ public void pigeon_defaultConstructor_createsImageCaptureWithCorrectConfiguratio final long targetResolution = Surface.ROTATION_0; final ImageCapture imageCapture = api.pigeon_defaultConstructor( - mockResolutionSelector, targetResolution, CameraXFlashMode.OFF, null); + mockResolutionSelector, targetResolution, CameraXFlashMode.OFF, null, null); assertEquals(imageCapture.getResolutionSelector(), mockResolutionSelector); assertEquals(imageCapture.getTargetRotation(), Surface.ROTATION_0); @@ -60,7 +62,7 @@ public void pigeon_defaultConstructor_setsJpegQualityWhenProvided() { final long jpegQuality = 75; final ImageCapture imageCapture = api.pigeon_defaultConstructor( - mockResolutionSelector, targetRotation, CameraXFlashMode.OFF, jpegQuality); + mockResolutionSelector, targetRotation, CameraXFlashMode.OFF, jpegQuality, null); assertEquals(imageCapture.getResolutionSelector(), mockResolutionSelector); assertEquals(imageCapture.getTargetRotation(), Surface.ROTATION_0); @@ -68,6 +70,31 @@ public void pigeon_defaultConstructor_setsJpegQualityWhenProvided() { assertEquals(imageCapture.getJpegQuality(), 75); } + @Test + @OptIn(markerClass = ExperimentalZeroShutterLag.class) + public void pigeon_defaultConstructor_setsZeroShutterLagCaptureModeWhenEnabled() { + final PigeonApiImageCapture api = new TestProxyApiRegistrar().getPigeonApiImageCapture(); + + final ImageCapture imageCapture = + api.pigeon_defaultConstructor(null, null, CameraXFlashMode.OFF, null, true); + + assertEquals(ImageCapture.CAPTURE_MODE_ZERO_SHUTTER_LAG, imageCapture.getCaptureMode()); + } + + @Test + public void pigeon_defaultConstructor_leavesDefaultCaptureModeWhenZeroShutterLagIsNotEnabled() { + final PigeonApiImageCapture api = new TestProxyApiRegistrar().getPigeonApiImageCapture(); + + assertEquals( + ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY, + api.pigeon_defaultConstructor(null, null, CameraXFlashMode.OFF, null, false) + .getCaptureMode()); + assertEquals( + ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY, + api.pigeon_defaultConstructor(null, null, CameraXFlashMode.OFF, null, null) + .getCaptureMode()); + } + @Test public void resolutionSelector_returnsExpectedResolutionSelector() { final PigeonApiImageCapture api = new TestProxyApiRegistrar().getPigeonApiImageCapture(); diff --git a/packages/camera/camera_android_camerax/example/pubspec.yaml b/packages/camera/camera_android_camerax/example/pubspec.yaml index 4d7f853b96c7..e0c617ad17e2 100644 --- a/packages/camera/camera_android_camerax/example/pubspec.yaml +++ b/packages/camera/camera_android_camerax/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ - camera_platform_interface: ^2.13.0 + camera_platform_interface: ^2.14.0 flutter: sdk: flutter video_player: ^2.7.0 @@ -28,3 +28,7 @@ dev_dependencies: flutter: uses-material-design: true +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../../packages/camera/camera_platform_interface} 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 02698325f0ca..9a923ec92d49 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 @@ -202,6 +202,14 @@ class AndroidCameraCameraX extends CameraPlatform { /// int? _lockedCaptureOrientation; + /// Whether zero-shutter-lag capture was requested via + /// [setZeroShutterLagEnabled]. + bool _zeroShutterLagEnabled = false; + + /// The JPEG compression quality requested via [setJpegImageQuality], if + /// any. + int? _jpegQuality; + /// Whether or not the default rotation for [UseCase]s needs to be set /// manually because the capture orientation was previously locked. /// @@ -414,6 +422,8 @@ class AndroidCameraCameraX extends CameraPlatform { resolutionSelector: _presetResolutionSelector, /* use CameraX default target rotation */ targetRotation: await deviceOrientationManager .getDefaultDisplayRotation(), + jpegQuality: _jpegQuality, + zeroShutterLagEnabled: _zeroShutterLagEnabled, ); // Configure VideoCapture and Recorder instances. @@ -1092,19 +1102,60 @@ class AndroidCameraCameraX extends CameraPlatform { /// the new instance automatically. @override Future setJpegImageQuality(int cameraId, int quality) async { - // Unbind the current ImageCapture if it exists and is bound. - if (imageCapture != null) { - await _unbindUseCaseFromLifecycle(imageCapture!); + _jpegQuality = quality; + await _recreateImageCapture(); + } + + /// Returns whether the selected camera supports zero-shutter-lag capture. + /// + /// The default implementation returns `false`. + @override + Future isZeroShutterLagSupported(int cameraId) async { + return cameraInfo?.isZslSupported() ?? false; + } + + /// Enables or disables zero-shutter-lag capture for still image capture. + /// + /// CameraX only supports setting the capture mode via `ImageCapture.Builder` + /// at construction time, so this recreates the `ImageCapture` use case with + /// the requested mode. The next call to [takePicture] will bind the new + /// instance automatically. + /// + /// This is a no-op on devices that do not support zero-shutter-lag; CameraX + /// falls back to the regular capture pipeline on its own. + @override + Future setZeroShutterLagEnabled(int cameraId, bool enabled) async { + _zeroShutterLagEnabled = enabled; + await _recreateImageCapture(); + } + + /// Recreates [imageCapture] from the currently requested settings + /// ([_jpegQuality], [_zeroShutterLagEnabled]). + /// + /// CameraX only supports several `ImageCapture` settings via + /// `ImageCapture.Builder` at construction time, so any change to one of + /// them requires unbinding and recreating the whole use case. The next + /// call to [takePicture] will bind the new instance automatically. + /// + /// If no camera has been created yet, this only updates the requested + /// settings; [createCamera] applies them when the camera is created. + Future _recreateImageCapture() async { + if (imageCapture == null) { + return; } - // Recreate ImageCapture with the requested JPEG quality. + // Unbind the current ImageCapture if it exists and is bound. + await _unbindUseCaseFromLifecycle(imageCapture!); + + // Recreate ImageCapture with the requested settings. // Preserve locked orientation if set, otherwise use default display rotation. final int targetRotation = _lockedCaptureOrientation ?? await deviceOrientationManager.getDefaultDisplayRotation(); imageCapture = ImageCapture( resolutionSelector: _presetResolutionSelector, targetRotation: targetRotation, - jpegQuality: quality, + jpegQuality: _jpegQuality, + zeroShutterLagEnabled: _zeroShutterLagEnabled, ); } 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..334f1fa634b3 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 @@ -125,6 +125,7 @@ class PigeonOverrides { int? targetRotation, CameraXFlashMode? flashMode, int? jpegQuality, + bool? zeroShutterLagEnabled, })? imageCapture_new; @@ -1917,6 +1918,30 @@ class CameraInfo extends PigeonInternalProxyApiBaseClass { return pigeonVar_replyValue! as LiveData; } + /// Returns whether the camera supports zero-shutter-lag capture. + /// + /// See https://developer.android.com/reference/androidx/camera/core/CameraInfo#isZslSupported(). + Future isZslSupported() async { + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = _pigeonVar_codecCameraInfo; + final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; + const pigeonVar_channelName = + 'dev.flutter.pigeon.camera_android_camerax.CameraInfo.isZslSupported'; + 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 CameraInfo pigeon_copy() { return CameraInfo.pigeon_detached( @@ -4081,6 +4106,7 @@ class ImageCapture extends UseCase { int? targetRotation, CameraXFlashMode? flashMode, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { if (PigeonOverrides.imageCapture_new != null) { return PigeonOverrides.imageCapture_new!( @@ -4088,6 +4114,7 @@ class ImageCapture extends UseCase { targetRotation: targetRotation, flashMode: flashMode, jpegQuality: jpegQuality, + zeroShutterLagEnabled: zeroShutterLagEnabled, ); } return ImageCapture.pigeon_new( @@ -4097,6 +4124,7 @@ class ImageCapture extends UseCase { targetRotation: targetRotation, flashMode: flashMode, jpegQuality: jpegQuality, + zeroShutterLagEnabled: zeroShutterLagEnabled, ); } @@ -4108,6 +4136,7 @@ class ImageCapture extends UseCase { int? targetRotation, CameraXFlashMode? flashMode, int? jpegQuality, + bool? zeroShutterLagEnabled, }) : super.pigeon_detached() { final int pigeonVar_instanceIdentifier = pigeon_instanceManager.addDartCreatedInstance(this); final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = _pigeonVar_codecImageCapture; @@ -4125,6 +4154,7 @@ class ImageCapture extends UseCase { targetRotation, flashMode, jpegQuality, + zeroShutterLagEnabled, ]); () async { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; diff --git a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart index 1b2847e8eb87..0db222d8382c 100644 --- a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart +++ b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart @@ -241,6 +241,11 @@ abstract class CameraInfo { /// A LiveData of ZoomState. LiveData getZoomState(); + + /// Returns whether the camera supports zero-shutter-lag capture. + /// + /// See https://developer.android.com/reference/androidx/camera/core/CameraInfo#isZslSupported(). + bool isZslSupported(); } /// Direction of lens of a camera. @@ -533,7 +538,12 @@ enum CameraXFlashMode { /// See https://developer.android.com/reference/kotlin/androidx/camera/core/ImageCapture. @ProxyApi(kotlinOptions: KotlinProxyApiOptions(fullClassName: 'androidx.camera.core.ImageCapture')) abstract class ImageCapture extends UseCase { - ImageCapture(int? targetRotation, CameraXFlashMode? flashMode, int? jpegQuality); + ImageCapture( + int? targetRotation, + CameraXFlashMode? flashMode, + int? jpegQuality, + bool? zeroShutterLagEnabled, + ); late final ResolutionSelector? resolutionSelector; diff --git a/packages/camera/camera_android_camerax/pubspec.yaml b/packages/camera/camera_android_camerax/pubspec.yaml index fc7165f5fc7b..ace4448bca82 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.4+7 +version: 0.7.5 environment: sdk: ^3.12.0 @@ -19,7 +19,7 @@ flutter: dependencies: async: ^2.5.0 - camera_platform_interface: ^2.13.0 + camera_platform_interface: ^2.14.0 flutter: sdk: flutter meta: ^1.7.0 @@ -40,3 +40,7 @@ dev_dependencies: topics: - camera +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../packages/camera/camera_platform_interface} 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 43bb1e15b132..2bde996cdf79 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 @@ -170,6 +170,7 @@ void main() { CameraXFlashMode? flashMode, ResolutionSelector? resolutionSelector, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { final mockImageCapture = MockImageCapture(); when(mockImageCapture.resolutionSelector).thenReturn(resolutionSelector); @@ -531,6 +532,7 @@ void main() { CameraXFlashMode? flashMode, ResolutionSelector? resolutionSelector, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { return mockImageCapture; }; @@ -1069,6 +1071,7 @@ void main() { CameraXFlashMode? flashMode, ResolutionSelector? resolutionSelector, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { return mockImageCapture; }; @@ -1476,6 +1479,7 @@ void main() { CameraXFlashMode? flashMode, ResolutionSelector? resolutionSelector, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { return mockImageCapture; }; @@ -1829,6 +1833,7 @@ void main() { CameraXFlashMode? flashMode, ResolutionSelector? resolutionSelector, int? jpegQuality, + bool? zeroShutterLagEnabled, }) => mockImageCapture; PigeonOverrides.recorder_new = ({int? aspectRatio, int? targetVideoEncodingBitRate, QualitySelector? qualitySelector}) => @@ -2857,6 +2862,7 @@ void main() { ResolutionSelector? resolutionSelector, int? targetRotation, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { return mockImageCapture; }; @@ -3071,6 +3077,7 @@ void main() { ResolutionSelector? resolutionSelector, int? targetRotation, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { return mockImageCapture; }; @@ -3327,6 +3334,7 @@ void main() { CameraXFlashMode? flashMode, ResolutionSelector? resolutionSelector, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { actualTargetRotation = targetRotation; actualJpegQuality = jpegQuality; @@ -3382,6 +3390,7 @@ void main() { CameraXFlashMode? flashMode, ResolutionSelector? resolutionSelector, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { actualTargetRotation = targetRotation; actualJpegQuality = jpegQuality; @@ -3430,6 +3439,7 @@ void main() { CameraXFlashMode? flashMode, ResolutionSelector? resolutionSelector, int? jpegQuality, + bool? zeroShutterLagEnabled, }) { return mockNewImageCapture; }; @@ -3469,6 +3479,193 @@ void main() { }, ); + test('isZeroShutterLagSupported returns the value reported by CameraInfo', () async { + final camera = AndroidCameraCameraX(); + final mockCameraInfo = MockCameraInfo(); + const cameraId = 9; + + camera.cameraInfo = mockCameraInfo; + when(mockCameraInfo.isZslSupported()).thenAnswer((_) async => true); + + expect(await camera.isZeroShutterLagSupported(cameraId), isTrue); + }); + + test('isZeroShutterLagSupported returns false when no camera has been selected yet', () async { + final camera = AndroidCameraCameraX(); + const cameraId = 9; + + expect(await camera.isZeroShutterLagSupported(cameraId), isFalse); + }); + + test( + 'setZeroShutterLagEnabled unbinds and recreates ImageCapture with the requested mode', + () async { + final camera = AndroidCameraCameraX(); + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockDeviceOrientationManager = MockDeviceOrientationManager(); + final mockImageCapture = MockImageCapture(); + final mockNewImageCapture = MockImageCapture(); + const int defaultTargetRotation = Surface.rotation90; + const cameraId = 9; + bool? actualZeroShutterLagEnabled; + + camera.processCameraProvider = mockProcessCameraProvider; + camera.imageCapture = mockImageCapture; + + PigeonOverrides.deviceOrientationManager_new = + ({required void Function(DeviceOrientationManager, String) onDeviceOrientationChanged}) { + when( + mockDeviceOrientationManager.getDefaultDisplayRotation(), + ).thenAnswer((_) async => defaultTargetRotation); + return mockDeviceOrientationManager; + }; + PigeonOverrides.imageCapture_new = + ({ + int? targetRotation, + CameraXFlashMode? flashMode, + ResolutionSelector? resolutionSelector, + int? jpegQuality, + bool? zeroShutterLagEnabled, + }) { + actualZeroShutterLagEnabled = zeroShutterLagEnabled; + return mockNewImageCapture; + }; + + when(mockProcessCameraProvider.isBound(mockImageCapture)).thenAnswer((_) async => true); + + await camera.setZeroShutterLagEnabled(cameraId, true); + + verify(mockProcessCameraProvider.unbind([mockImageCapture])).called(1); + expect(actualZeroShutterLagEnabled, isTrue); + expect(camera.imageCapture, same(mockNewImageCapture)); + }, + ); + + test('setZeroShutterLagEnabled preserves a previously requested JPEG quality', () async { + final camera = AndroidCameraCameraX(); + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockDeviceOrientationManager = MockDeviceOrientationManager(); + final mockImageCapture = MockImageCapture(); + final mockNewImageCapture = MockImageCapture(); + const int defaultTargetRotation = Surface.rotation90; + const jpegQuality = 73; + const cameraId = 9; + int? actualJpegQuality; + + camera.processCameraProvider = mockProcessCameraProvider; + camera.imageCapture = mockImageCapture; + + PigeonOverrides.deviceOrientationManager_new = + ({required void Function(DeviceOrientationManager, String) onDeviceOrientationChanged}) { + when( + mockDeviceOrientationManager.getDefaultDisplayRotation(), + ).thenAnswer((_) async => defaultTargetRotation); + return mockDeviceOrientationManager; + }; + PigeonOverrides.imageCapture_new = + ({ + int? targetRotation, + CameraXFlashMode? flashMode, + ResolutionSelector? resolutionSelector, + int? jpegQuality, + bool? zeroShutterLagEnabled, + }) { + actualJpegQuality = jpegQuality; + return mockNewImageCapture; + }; + + when(mockProcessCameraProvider.isBound(mockImageCapture)).thenAnswer((_) async => true); + + await camera.setJpegImageQuality(cameraId, jpegQuality); + await camera.setZeroShutterLagEnabled(cameraId, true); + + expect(actualJpegQuality, jpegQuality); + }); + + test('setJpegImageQuality preserves a previously requested zero-shutter-lag setting', () async { + final camera = AndroidCameraCameraX(); + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockDeviceOrientationManager = MockDeviceOrientationManager(); + final mockImageCapture = MockImageCapture(); + final mockNewImageCapture = MockImageCapture(); + const int defaultTargetRotation = Surface.rotation90; + const cameraId = 9; + bool? actualZeroShutterLagEnabled; + + camera.processCameraProvider = mockProcessCameraProvider; + camera.imageCapture = mockImageCapture; + + PigeonOverrides.deviceOrientationManager_new = + ({required void Function(DeviceOrientationManager, String) onDeviceOrientationChanged}) { + when( + mockDeviceOrientationManager.getDefaultDisplayRotation(), + ).thenAnswer((_) async => defaultTargetRotation); + return mockDeviceOrientationManager; + }; + PigeonOverrides.imageCapture_new = + ({ + int? targetRotation, + CameraXFlashMode? flashMode, + ResolutionSelector? resolutionSelector, + int? jpegQuality, + bool? zeroShutterLagEnabled, + }) { + actualZeroShutterLagEnabled = zeroShutterLagEnabled; + return mockNewImageCapture; + }; + + when(mockProcessCameraProvider.isBound(mockImageCapture)).thenAnswer((_) async => true); + + await camera.setZeroShutterLagEnabled(cameraId, true); + await camera.setJpegImageQuality(cameraId, 73); + + expect(actualZeroShutterLagEnabled, isTrue); + }); + + test('createCamera applies a previously requested zero-shutter-lag setting', () async { + final camera = AndroidCameraCameraX(); + const testCameraDescription = CameraDescription( + name: 'cameraName', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90, + ); + final mockCamera = MockCamera(); + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockCameraInfo = MockCameraInfo(); + bool? actualZeroShutterLagEnabled; + + // A prior call to setZeroShutterLagEnabled sets this field; set it + // directly here since imageCapture is null before createCamera runs, + // so setZeroShutterLagEnabled itself would not recreate ImageCapture. + await camera.setZeroShutterLagEnabled(9, true); + + setUpOverridesForTestingUseCaseConfiguration(mockProcessCameraProvider); + // setUpOverridesForTestingUseCaseConfiguration sets a fixed + // imageCapture_new override; replace it to capture the argument this + // test cares about. + PigeonOverrides.imageCapture_new = + ({ + int? targetRotation, + CameraXFlashMode? flashMode, + ResolutionSelector? resolutionSelector, + int? jpegQuality, + bool? zeroShutterLagEnabled, + }) { + actualZeroShutterLagEnabled = zeroShutterLagEnabled; + final mockImageCapture = MockImageCapture(); + when(mockImageCapture.resolutionSelector).thenReturn(resolutionSelector); + return mockImageCapture; + }; + + when(mockProcessCameraProvider.bindToLifecycle(any, any)).thenAnswer((_) async => mockCamera); + when(mockCamera.getCameraInfo()).thenAnswer((_) async => mockCameraInfo); + when(mockCameraInfo.getCameraState()).thenAnswer((_) async => MockLiveCameraState()); + + await camera.createCamera(testCameraDescription, ResolutionPreset.low); + + expect(actualZeroShutterLagEnabled, isTrue); + }); + test('takePicture turns non-torch flash mode off when torch mode enabled', () async { final camera = AndroidCameraCameraX(); final mockProcessCameraProvider = MockProcessCameraProvider(); 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..ac63f5dba4a5 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 @@ -458,6 +458,15 @@ class MockCameraInfo extends _i1.Mock implements _i3.CameraInfo { returnValueForMissingStub: _FakeCameraInfo_8(this, Invocation.method(#pigeon_copy, [])), ) as _i3.CameraInfo); + + @override + _i5.Future isZslSupported() => + (super.noSuchMethod( + Invocation.method(#isZslSupported, []), + returnValue: _i5.Future.value(false), + returnValueForMissingStub: _i5.Future.value(false), + ) + as _i5.Future); } /// A class which mocks [CameraCharacteristicsKey]. diff --git a/packages/camera/camera_android_camerax/test/preview_rotation_test.dart b/packages/camera/camera_android_camerax/test/preview_rotation_test.dart index 3e5f408fd5e9..ea3e30f04376 100644 --- a/packages/camera/camera_android_camerax/test/preview_rotation_test.dart +++ b/packages/camera/camera_android_camerax/test/preview_rotation_test.dart @@ -112,6 +112,7 @@ void main() { CameraXFlashMode? flashMode, ResolutionSelector? resolutionSelector, int? jpegQuality, + bool? zeroShutterLagEnabled, }) => MockImageCapture(); PigeonOverrides.recorder_new = ({int? aspectRatio, int? targetVideoEncodingBitRate, QualitySelector? qualitySelector}) => diff --git a/packages/camera/camera_avfoundation/CHANGELOG.md b/packages/camera/camera_avfoundation/CHANGELOG.md index b69f57f769fa..ecd33223ce0c 100644 --- a/packages/camera/camera_avfoundation/CHANGELOG.md +++ b/packages/camera/camera_avfoundation/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.10.4 + +* Adds support for zero-shutter-lag still image capture via + `isZeroShutterLagSupported` and `setZeroShutterLagEnabled` (iOS 17+). + ## 0.10.3 * Updates pigeon dev_dependency to ^27.3.2 for analyzer 14 compatibility. diff --git a/packages/camera/camera_avfoundation/darwin/Tests/CameraPluginDelegatingMethodTests.swift b/packages/camera/camera_avfoundation/darwin/Tests/CameraPluginDelegatingMethodTests.swift index 37ee555dbaef..c0434298bf70 100644 --- a/packages/camera/camera_avfoundation/darwin/Tests/CameraPluginDelegatingMethodTests.swift +++ b/packages/camera/camera_avfoundation/darwin/Tests/CameraPluginDelegatingMethodTests.swift @@ -273,6 +273,51 @@ final class CameraPluginDelegatingMethodTests: XCTestCase { XCTAssertTrue(setJpegImageQualityCalled) } + func testIsZeroShutterLagSupported_returnsValueFromCameraIsZeroShutterLagSupported() { + let (cameraPlugin, mockCamera) = createCameraPlugin() + let expectation = expectation(description: "Call completed") + + var isZeroShutterLagSupportedCalled = false + mockCamera.isZeroShutterLagSupportedStub = { + isZeroShutterLagSupportedCalled = true + return true + } + + cameraPlugin.isZeroShutterLagSupported { result in + switch result { + case .success(let supported): + XCTAssertTrue(supported) + case .failure: + XCTFail("Unexpected error") + } + expectation.fulfill() + } + + waitForExpectations(timeout: 30, handler: nil) + + XCTAssertTrue(isZeroShutterLagSupportedCalled) + } + + func testSetZeroShutterLagEnabled_callsCameraSetZeroShutterLagEnabled() { + let (cameraPlugin, mockCamera) = createCameraPlugin() + let expectation = expectation(description: "Call completed") + + var setZeroShutterLagEnabledCalled = false + mockCamera.setZeroShutterLagEnabledStub = { enabled in + XCTAssertTrue(enabled) + setZeroShutterLagEnabledCalled = true + } + + cameraPlugin.setZeroShutterLagEnabled(enabled: true) { result in + let _ = self.assertSuccess(result) + expectation.fulfill() + } + + waitForExpectations(timeout: 30, handler: nil) + + XCTAssertTrue(setZeroShutterLagEnabledCalled) + } + func testStartImageStream_callsCameraStartImageStream() { let (cameraPlugin, mockCamera) = createCameraPlugin() let expectation = expectation(description: "Call completed") diff --git a/packages/camera/camera_avfoundation/darwin/Tests/Mocks/MockCamera.swift b/packages/camera/camera_avfoundation/darwin/Tests/Mocks/MockCamera.swift index 569f55630155..ce3a9096c1c8 100644 --- a/packages/camera/camera_avfoundation/darwin/Tests/Mocks/MockCamera.swift +++ b/packages/camera/camera_avfoundation/darwin/Tests/Mocks/MockCamera.swift @@ -28,6 +28,8 @@ final class MockCamera: NSObject, Camera { var unlockCaptureOrientationStub: (() -> Void)? var setImageFileFormatStub: ((PlatformImageFileFormat) -> Void)? var setJpegImageQualityStub: ((Int64) -> Void)? + var isZeroShutterLagSupportedStub: (() -> Bool)? + var setZeroShutterLagEnabledStub: ((Bool) -> Void)? var setExposureModeStub: ((PlatformExposureMode) -> Void)? var setExposureOffsetStub: ((Double) -> Void)? var setExposurePointStub: ((PlatformPoint?, @escaping (Result) -> Void) -> Void)? @@ -149,6 +151,14 @@ final class MockCamera: NSObject, Camera { setJpegImageQualityStub?(quality) } + func isZeroShutterLagSupported() -> Bool { + return isZeroShutterLagSupportedStub?() ?? false + } + + func setZeroShutterLagEnabled(_ enabled: Bool) { + setZeroShutterLagEnabledStub?(enabled) + } + func setExposureMode(_ mode: PlatformExposureMode) { setExposureModeStub?(mode) } diff --git a/packages/camera/camera_avfoundation/darwin/Tests/Mocks/MockCapturePhotoOutput.swift b/packages/camera/camera_avfoundation/darwin/Tests/Mocks/MockCapturePhotoOutput.swift index 184fd0be36b2..5e2875614f91 100644 --- a/packages/camera/camera_avfoundation/darwin/Tests/Mocks/MockCapturePhotoOutput.swift +++ b/packages/camera/camera_avfoundation/darwin/Tests/Mocks/MockCapturePhotoOutput.swift @@ -13,6 +13,8 @@ final class MockCapturePhotoOutput: NSObject, CapturePhotoOutput { var availablePhotoCodecTypes: [AVVideoCodecType] = [] var isHighResolutionCaptureEnabled = false var supportedFlashModes: [AVCaptureDevice.FlashMode] = [] + var flutterZeroShutterLagSupported = false + var flutterZeroShutterLagEnabled = false // Stub that is called when the corresponding public method is called. var capturePhotoWithSettingsStub: diff --git a/packages/camera/camera_avfoundation/darwin/Tests/PhotoCaptureTests.swift b/packages/camera/camera_avfoundation/darwin/Tests/PhotoCaptureTests.swift index 1113fb424450..051fa78a2f2a 100644 --- a/packages/camera/camera_avfoundation/darwin/Tests/PhotoCaptureTests.swift +++ b/packages/camera/camera_avfoundation/darwin/Tests/PhotoCaptureTests.swift @@ -15,6 +15,82 @@ final class PhotoCaptureTests: XCTestCase { return CameraTestUtils.createTestCamera(configuration) } + func testIsZeroShutterLagSupported_returnsValueReportedByPhotoOutput() { + let cam = createCam(with: DispatchQueue(label: "capture_session_queue")) + let mockOutput = MockCapturePhotoOutput() + mockOutput.flutterZeroShutterLagSupported = true + cam.capturePhotoOutput = mockOutput + + XCTAssertTrue(cam.isZeroShutterLagSupported()) + + mockOutput.flutterZeroShutterLagSupported = false + XCTAssertFalse(cam.isZeroShutterLagSupported()) + } + + func testSetZeroShutterLagEnabled_doesNotEnableOrReconfigureWhenUnsupported() { + let configuration = CameraTestUtils.createTestCameraConfiguration() + let mockSession = MockCaptureSession() + var beginCount = 0 + mockSession.beginConfigurationStub = { beginCount += 1 } + configuration.videoCaptureSession = mockSession + let cam = CameraTestUtils.createTestCamera(configuration) + + let mockOutput = MockCapturePhotoOutput() + mockOutput.flutterZeroShutterLagSupported = false + cam.capturePhotoOutput = mockOutput + + cam.setZeroShutterLagEnabled(true) + + XCTAssertFalse(mockOutput.flutterZeroShutterLagEnabled) + XCTAssertEqual(beginCount, 0) + } + + func testSetZeroShutterLagEnabled_doesNotReconfigureWhenValueAlreadyMatches() { + let configuration = CameraTestUtils.createTestCameraConfiguration() + let mockSession = MockCaptureSession() + var beginCount = 0 + mockSession.beginConfigurationStub = { beginCount += 1 } + configuration.videoCaptureSession = mockSession + let cam = CameraTestUtils.createTestCamera(configuration) + + let mockOutput = MockCapturePhotoOutput() + mockOutput.flutterZeroShutterLagSupported = true + mockOutput.flutterZeroShutterLagEnabled = true + cam.capturePhotoOutput = mockOutput + + cam.setZeroShutterLagEnabled(true) + + XCTAssertTrue(mockOutput.flutterZeroShutterLagEnabled) + XCTAssertEqual(beginCount, 0) + } + + func testSetZeroShutterLagEnabled_appliesValueAndReconfiguresSession() { + let configuration = CameraTestUtils.createTestCameraConfiguration() + let mockSession = MockCaptureSession() + var beginCount = 0 + var commitCount = 0 + mockSession.beginConfigurationStub = { beginCount += 1 } + mockSession.commitConfigurationStub = { commitCount += 1 } + configuration.videoCaptureSession = mockSession + let cam = CameraTestUtils.createTestCamera(configuration) + + let mockOutput = MockCapturePhotoOutput() + mockOutput.flutterZeroShutterLagSupported = true + cam.capturePhotoOutput = mockOutput + + cam.setZeroShutterLagEnabled(true) + XCTAssertTrue(mockOutput.flutterZeroShutterLagEnabled) + + cam.setZeroShutterLagEnabled(false) + XCTAssertFalse(mockOutput.flutterZeroShutterLagEnabled) + + // Setting the value it already has must not reconfigure the session. + cam.setZeroShutterLagEnabled(false) + + XCTAssertEqual(beginCount, 2) + XCTAssertEqual(commitCount, 2) + } + func testCaptureToFile_mustReportErrorToResultIfSavePhotoDelegateCompletionsWithError() { let errorExpectation = expectation( description: "Must send error to result if save photo delegate completes with error.") diff --git a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/Camera.swift b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/Camera.swift index 02e9503f6adc..ffa5659e6df3 100644 --- a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/Camera.swift +++ b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/Camera.swift @@ -64,6 +64,9 @@ protocol Camera: FlutterTexture, AVCaptureVideoDataOutputSampleBufferDelegate, func setImageFileFormat(_ fileFormat: PlatformImageFileFormat) func setJpegImageQuality(_ quality: Int64) + func isZeroShutterLagSupported() -> Bool + func setZeroShutterLagEnabled(_ enabled: Bool) + func setExposureMode(_ mode: PlatformExposureMode) func setExposureOffset(_ offset: Double) diff --git a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/CameraPlugin.swift b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/CameraPlugin.swift index 136c05f18d58..d587fc2644db 100644 --- a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/CameraPlugin.swift +++ b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/CameraPlugin.swift @@ -21,6 +21,11 @@ public final class CameraPlugin: NSObject, FlutterPlugin { /// An internal camera object that manages camera's state and performs camera operations. var camera: Camera? + /// The zero-shutter-lag state last requested via `setZeroShutterLagEnabled`, + /// if any. Kept here so it survives camera recreation (e.g. switching + /// cameras) and is applied to newly created cameras. + private var zeroShutterLagRequested: Bool? + public static func register(with registrar: FlutterPluginRegistrar) { let instance = CameraPlugin( registry: registrar.textures(), @@ -262,6 +267,11 @@ extension CameraPlugin: CameraApi { camera?.close() camera = newCamera + // Apply the zero-shutter-lag state requested before this camera existed. + if let zeroShutterLagRequested = zeroShutterLagRequested { + newCamera.setZeroShutterLagEnabled(zeroShutterLagRequested) + } + ensureToRunOnMainQueue { [weak self] in guard let strongSelf = self else { return } completion(.success(strongSelf.registry.register(newCamera))) @@ -564,4 +574,20 @@ extension CameraPlugin: CameraApi { completion(.success(())) } } + + func isZeroShutterLagSupported(completion: @escaping (Result) -> Void) { + captureSessionQueue.async { [weak self] in + completion(.success(self?.camera?.isZeroShutterLagSupported() ?? false)) + } + } + + func setZeroShutterLagEnabled( + enabled: Bool, completion: @escaping (Result) -> Void + ) { + captureSessionQueue.async { [weak self] in + self?.zeroShutterLagRequested = enabled + self?.camera?.setZeroShutterLagEnabled(enabled) + completion(.success(())) + } + } } diff --git a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/CaptureOutput.swift b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/CaptureOutput.swift index 5506c017d25e..b309906fd253 100644 --- a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/CaptureOutput.swift +++ b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/CaptureOutput.swift @@ -59,6 +59,15 @@ protocol CapturePhotoOutput: CaptureOutput { /// Corresponds to the `supportedFlashModes` property of `AVCapturePhotoOutput` var supportedFlashModes: [AVCaptureDevice.FlashMode] { get } + /// Corresponds to the `isZeroShutterLagSupported` property of + /// `AVCapturePhotoOutput` on iOS 17+/macOS 14+; `false` on older versions. + var flutterZeroShutterLagSupported: Bool { get } + + /// Corresponds to the `isZeroShutterLagEnabled` property of + /// `AVCapturePhotoOutput` on iOS 17+/macOS 14+; reads `false` and ignores + /// writes on older versions. + var flutterZeroShutterLagEnabled: Bool { get set } + /// Corresponds to the `capturePhotoWithSettings` method of `AVCapturePhotoOutput` func capturePhoto(with settings: AVCapturePhotoSettings, delegate: AVCapturePhotoCaptureDelegate) } @@ -69,6 +78,27 @@ extension AVCapturePhotoOutput: CapturePhotoOutput { return self } + var flutterZeroShutterLagSupported: Bool { + if #available(iOS 17.0, macOS 14.0, *) { + return isZeroShutterLagSupported + } + return false + } + + var flutterZeroShutterLagEnabled: Bool { + get { + if #available(iOS 17.0, macOS 14.0, *) { + return isZeroShutterLagEnabled + } + return false + } + set { + if #available(iOS 17.0, macOS 14.0, *) { + isZeroShutterLagEnabled = newValue + } + } + } + func connection(with mediaType: AVMediaType) -> CaptureConnection? { // Explicit type is required to access the underlying AVCapturePhotoOutput.connection method let connection: AVCaptureConnection? = connection(with: mediaType) diff --git a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/DefaultCamera.swift b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/DefaultCamera.swift index d702248a6d7b..1c7c8acf1eea 100644 --- a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/DefaultCamera.swift +++ b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/DefaultCamera.swift @@ -122,6 +122,12 @@ final class DefaultCamera: NSObject, Camera { private var fileFormat = PlatformImageFileFormat.jpeg private var imageQuality: Int64 = 100 + + /// The zero-shutter-lag state last requested via [setZeroShutterLagEnabled], + /// if any. Session reconfiguration (e.g. switching cameras) silently resets + /// `isZeroShutterLagEnabled`, so the requested state is kept here and + /// reapplied afterwards. + private var zeroShutterLagRequested: Bool? private var lockedCaptureOrientation = UIDeviceOrientation.unknown private var exposureMode = PlatformExposureMode.auto private var focusMode = PlatformFocusMode.auto @@ -858,6 +864,35 @@ final class DefaultCamera: NSObject, Camera { self.imageQuality = quality } + func isZeroShutterLagSupported() -> Bool { + return capturePhotoOutput.flutterZeroShutterLagSupported + } + + /// Applies the requested zero-shutter-lag state to the photo output. + /// + /// Enabling is guarded on `flutterZeroShutterLagSupported`: setting + /// `isZeroShutterLagEnabled` to `true` while unsupported throws + /// `NSInvalidArgumentException`, and the platform interface documents this + /// setting as best-effort. Changing the value requires a reconfiguration of + /// the capture pipeline, so the update is wrapped in + /// `beginConfiguration`/`commitConfiguration`. + func setZeroShutterLagEnabled(_ enabled: Bool) { + zeroShutterLagRequested = enabled + applyZeroShutterLag(enabled) + } + + private func applyZeroShutterLag(_ enabled: Bool) { + if enabled && !capturePhotoOutput.flutterZeroShutterLagSupported { + return + } + if capturePhotoOutput.flutterZeroShutterLagEnabled == enabled { + return + } + videoCaptureSession.beginConfiguration() + capturePhotoOutput.flutterZeroShutterLagEnabled = enabled + videoCaptureSession.commitConfiguration() + } + func setExposureMode(_ mode: PlatformExposureMode) { exposureMode = mode applyExposureMode() @@ -1208,6 +1243,12 @@ final class DefaultCamera: NSObject, Camera { videoCaptureSession.addConnection(newConnection) videoCaptureSession.commitConfiguration() + // Reconfiguring the session silently resets `isZeroShutterLagEnabled`; + // reapply the state the caller last requested. + if let zeroShutterLagRequested = zeroShutterLagRequested { + applyZeroShutterLag(zeroShutterLagRequested) + } + completion(.success(())) } diff --git a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/Messages.swift b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/Messages.swift index 201bb7844cf4..166dd01d66e8 100644 --- a/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/Messages.swift +++ b/packages/camera/camera_avfoundation/darwin/camera_avfoundation/Sources/camera_avfoundation/Messages.swift @@ -894,6 +894,11 @@ protocol CameraApi { format: PlatformImageFileFormat, completion: @escaping (Result) -> Void) /// Sets the JPEG compression quality for still image capture. func setJpegImageQuality(quality: Int64, completion: @escaping (Result) -> Void) + /// Returns whether the current session configuration supports + /// zero-shutter-lag capture. + func isZeroShutterLagSupported(completion: @escaping (Result) -> Void) + /// Enables or disables zero-shutter-lag capture. + func setZeroShutterLagEnabled(enabled: Bool, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -1554,6 +1559,47 @@ class CameraApiSetup { } else { setJpegImageQualityChannel.setMessageHandler(nil) } + /// Returns whether the current session configuration supports + /// zero-shutter-lag capture. + let isZeroShutterLagSupportedChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.camera_avfoundation.CameraApi.isZeroShutterLagSupported\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isZeroShutterLagSupportedChannel.setMessageHandler { _, reply in + api.isZeroShutterLagSupported { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + isZeroShutterLagSupportedChannel.setMessageHandler(nil) + } + /// Enables or disables zero-shutter-lag capture. + let setZeroShutterLagEnabledChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.camera_avfoundation.CameraApi.setZeroShutterLagEnabled\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setZeroShutterLagEnabledChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let enabledArg = args[0] as! Bool + api.setZeroShutterLagEnabled(enabled: enabledArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + setZeroShutterLagEnabledChannel.setMessageHandler(nil) + } } } diff --git a/packages/camera/camera_avfoundation/example/pubspec.yaml b/packages/camera/camera_avfoundation/example/pubspec.yaml index a038f12b1935..1722593fa522 100644 --- a/packages/camera/camera_avfoundation/example/pubspec.yaml +++ b/packages/camera/camera_avfoundation/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ - camera_platform_interface: ^2.13.0 + camera_platform_interface: ^2.14.0 flutter: sdk: flutter path_provider: ^2.0.0 @@ -29,3 +29,7 @@ dev_dependencies: flutter: uses-material-design: true +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../../packages/camera/camera_platform_interface} diff --git a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart index f06439fcf620..03bc1d5c971f 100644 --- a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart +++ b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart @@ -417,6 +417,16 @@ class AVFoundationCamera extends CameraPlatform { await _hostApi.setJpegImageQuality(quality); } + @override + Future isZeroShutterLagSupported(int cameraId) { + return _hostApi.isZeroShutterLagSupported(); + } + + @override + Future setZeroShutterLagEnabled(int cameraId, bool enabled) async { + await _hostApi.setZeroShutterLagEnabled(enabled); + } + @override Widget buildPreview(int cameraId) { return Texture(textureId: cameraId); diff --git a/packages/camera/camera_avfoundation/lib/src/messages.g.dart b/packages/camera/camera_avfoundation/lib/src/messages.g.dart index 525f21b61ca3..32280dd91257 100644 --- a/packages/camera/camera_avfoundation/lib/src/messages.g.dart +++ b/packages/camera/camera_avfoundation/lib/src/messages.g.dart @@ -1263,6 +1263,42 @@ class CameraApi { _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true); } + + /// Returns whether the current session configuration supports + /// zero-shutter-lag capture. + Future isZeroShutterLagSupported() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.camera_avfoundation.CameraApi.isZeroShutterLagSupported$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + /// Enables or disables zero-shutter-lag capture. + Future setZeroShutterLagEnabled(bool enabled) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.camera_avfoundation.CameraApi.setZeroShutterLagEnabled$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([enabled]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true); + } } /// Returns a broadcast [Stream] of events from the `imageDataStream` event channel. diff --git a/packages/camera/camera_avfoundation/pigeons/messages.dart b/packages/camera/camera_avfoundation/pigeons/messages.dart index c726c65d52a6..2dca280f6f02 100644 --- a/packages/camera/camera_avfoundation/pigeons/messages.dart +++ b/packages/camera/camera_avfoundation/pigeons/messages.dart @@ -343,6 +343,16 @@ abstract class CameraApi { @async @ObjCSelector('setJpegImageQuality:') void setJpegImageQuality(int quality); + + /// Returns whether the current session configuration supports + /// zero-shutter-lag capture. + @async + bool isZeroShutterLagSupported(); + + /// Enables or disables zero-shutter-lag capture. + @async + @ObjCSelector('setZeroShutterLagEnabled:') + void setZeroShutterLagEnabled(bool enabled); } @EventChannelApi() diff --git a/packages/camera/camera_avfoundation/pubspec.yaml b/packages/camera/camera_avfoundation/pubspec.yaml index 5da621f2cf8b..f32ea9c576fd 100644 --- a/packages/camera/camera_avfoundation/pubspec.yaml +++ b/packages/camera/camera_avfoundation/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_avfoundation description: iOS implementation of the camera plugin. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_avfoundation issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.10.3 +version: 0.10.4 environment: sdk: ^3.10.0 @@ -18,7 +18,7 @@ flutter: sharedDarwinSource: true dependencies: - camera_platform_interface: ^2.13.0 + camera_platform_interface: ^2.14.0 flutter: sdk: flutter meta: ^1.10.0 @@ -35,3 +35,7 @@ dev_dependencies: topics: - camera +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../packages/camera/camera_platform_interface} diff --git a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart index a1f6c16c6e33..759c4e02a1af 100644 --- a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart +++ b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart @@ -794,5 +794,17 @@ void main() { verify(mockApi.setJpegImageQuality(50)); }); + + test('Should report zero-shutter-lag support from the host API', () async { + when(mockApi.isZeroShutterLagSupported()).thenAnswer((_) async => true); + + expect(await camera.isZeroShutterLagSupported(cameraId), isTrue); + }); + + test('Should set zero-shutter-lag enabled', () async { + await camera.setZeroShutterLagEnabled(cameraId, true); + + verify(mockApi.setZeroShutterLagEnabled(true)); + }); }); } diff --git a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.mocks.dart b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.mocks.dart index 3a634774fef1..861ec79ed252 100644 --- a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.mocks.dart +++ b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.mocks.dart @@ -351,4 +351,22 @@ class MockCameraApi extends _i1.Mock implements _i2.CameraApi { returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + _i4.Future isZeroShutterLagSupported() => + (super.noSuchMethod( + Invocation.method(#isZeroShutterLagSupported, []), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future setZeroShutterLagEnabled(bool? enabled) => + (super.noSuchMethod( + Invocation.method(#setZeroShutterLagEnabled, [enabled]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); } diff --git a/packages/camera/camera_platform_interface/CHANGELOG.md b/packages/camera/camera_platform_interface/CHANGELOG.md index 47f4146fc8a4..4fbe57aa8e99 100644 --- a/packages/camera/camera_platform_interface/CHANGELOG.md +++ b/packages/camera/camera_platform_interface/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.14.0 + +* Adds `isZeroShutterLagSupported` and `setZeroShutterLagEnabled` for + zero-shutter-lag still image capture on platforms that support it. + ## 2.13.1 * Changes the default implementation of `setJpegImageQuality` to a no-op so that diff --git a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart index 3b49571a8529..4d8c27a85665 100644 --- a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart +++ b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart @@ -345,4 +345,24 @@ abstract class CameraPlatform extends PlatformInterface { // No-op by default. Platforms that support setting the JPEG quality // override this method. } + + /// Returns whether the selected camera supports zero-shutter-lag capture. + /// + /// The default implementation returns `false`. + Future isZeroShutterLagSupported(int cameraId) async => false; + + /// Enables or disables zero-shutter-lag capture for still image capture. + /// + /// [isZeroShutterLagSupported] must be called first; only call this method on + /// a camera that reports support for zero-shutter-lag capture. + /// + /// When enabled, [takePicture] returns the buffered frame closest to the + /// moment it was called instead of waiting for a new frame to be captured, + /// reducing shutter latency on devices that support it. + /// + /// Some platforms enable zero-shutter-lag by default on devices that + /// support it; this setter provides explicit control in either direction. + Future setZeroShutterLagEnabled(int cameraId, bool enabled) { + throw UnimplementedError('setZeroShutterLagEnabled() is not implemented.'); + } } diff --git a/packages/camera/camera_platform_interface/pubspec.yaml b/packages/camera/camera_platform_interface/pubspec.yaml index 600718419798..6102c20a7fd4 100644 --- a/packages/camera/camera_platform_interface/pubspec.yaml +++ b/packages/camera/camera_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/packages/tree/main/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.13.1 +version: 2.14.0 environment: sdk: ^3.10.0 diff --git a/packages/camera/camera_platform_interface/test/camera_platform_interface_test.dart b/packages/camera/camera_platform_interface/test/camera_platform_interface_test.dart index fbb9cc91364e..716467580a56 100644 --- a/packages/camera/camera_platform_interface/test/camera_platform_interface_test.dart +++ b/packages/camera/camera_platform_interface/test/camera_platform_interface_test.dart @@ -361,6 +361,25 @@ void main() { expect(cameraPlatform.setJpegImageQuality(1, 50), completes); }); + test('Default implementation of isZeroShutterLagSupported() should return false', () async { + // Arrange + final cameraPlatform = ExtendsCameraPlatform(); + + // Act & Assert + expect(await cameraPlatform.isZeroShutterLagSupported(1), isFalse); + }); + + test( + 'Default implementation of setZeroShutterLagEnabled() should throw unimplemented error', + () { + // Arrange + final cameraPlatform = ExtendsCameraPlatform(); + + // Act & Assert + expect(() => cameraPlatform.setZeroShutterLagEnabled(1, true), throwsUnimplementedError); + }, + ); + test('Default implementation of supportsImageStreaming() should return false', () { // Arrange final cameraPlatform = ExtendsCameraPlatform(); diff --git a/packages/camera/camera_web/example/pubspec.yaml b/packages/camera/camera_web/example/pubspec.yaml index 260fbc719408..ebb49b27eb71 100644 --- a/packages/camera/camera_web/example/pubspec.yaml +++ b/packages/camera/camera_web/example/pubspec.yaml @@ -27,3 +27,7 @@ dev_dependencies: integration_test: sdk: flutter mockito: ^5.4.4 +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../../packages/camera/camera_platform_interface} diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index dadd95cb64b3..c43cf84e7647 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -31,3 +31,7 @@ dev_dependencies: topics: - camera +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../packages/camera/camera_platform_interface} diff --git a/packages/camera/camera_windows/example/pubspec.yaml b/packages/camera/camera_windows/example/pubspec.yaml index 211cfd402b0a..d190556532bd 100644 --- a/packages/camera/camera_windows/example/pubspec.yaml +++ b/packages/camera/camera_windows/example/pubspec.yaml @@ -27,3 +27,7 @@ dev_dependencies: flutter: uses-material-design: true +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../../packages/camera/camera_platform_interface} diff --git a/packages/camera/camera_windows/pubspec.yaml b/packages/camera/camera_windows/pubspec.yaml index a17da257bd42..8325a764ea0b 100644 --- a/packages/camera/camera_windows/pubspec.yaml +++ b/packages/camera/camera_windows/pubspec.yaml @@ -35,3 +35,7 @@ dev_dependencies: topics: - camera +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + camera_platform_interface: {path: ../../../packages/camera/camera_platform_interface}