diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart index bcec28fe5b9d..73359e455ad6 100644 --- a/packages/camera/camera/test/camera_preview_test.dart +++ b/packages/camera/camera/test/camera_preview_test.dart @@ -140,7 +140,7 @@ class FakeController extends ValueNotifier void main() { group('RotatedBox (Android only)', () { testWidgets( - 'when recording rotatedBox should turn according to recording orientation', + 'when recording in DeviceOrientaiton.portraitUp, rotatedBox should not be rotated', ( WidgetTester tester, ) async { @@ -148,17 +148,129 @@ void main() { final FakeController controller = FakeController(); addTearDown(controller.dispose); + controller.value = controller.value.copyWith( - isInitialized: true, - isRecordingVideo: true, - deviceOrientation: DeviceOrientation.portraitUp, - lockedCaptureOrientation: - const Optional.fromNullable( - DeviceOrientation.landscapeRight), - recordingOrientation: const Optional.fromNullable( - DeviceOrientation.landscapeLeft), - previewSize: const Size(480, 640), + isInitialized: true, + isRecordingVideo: true, + deviceOrientation: DeviceOrientation.portraitDown, + lockedCaptureOrientation: + const Optional.fromNullable( + DeviceOrientation.landscapeRight), + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.portraitUp), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CameraPreview(controller), + ), + ); + expect(find.byType(RotatedBox), findsOneWidget); + + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.quarterTurns, 0); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'when recording in DeviceOrientaiton.landscapeRight, rotatedBox should be rotated by one clockwise quarter turn', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final FakeController controller = FakeController(); + addTearDown(controller.dispose); + + controller.value = controller.value.copyWith( + isInitialized: true, + isRecordingVideo: true, + deviceOrientation: DeviceOrientation.portraitUp, + lockedCaptureOrientation: + const Optional.fromNullable( + DeviceOrientation.landscapeLeft), + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.landscapeRight), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CameraPreview(controller), + ), + ); + expect(find.byType(RotatedBox), findsOneWidget); + + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.quarterTurns, 1); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'when recording in DeviceOrientaiton.portraitDown, rotatedBox should be rotated by two clockwise quarter turns', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final FakeController controller = FakeController(); + addTearDown(controller.dispose); + + controller.value = controller.value.copyWith( + isInitialized: true, + isRecordingVideo: true, + deviceOrientation: DeviceOrientation.portraitUp, + lockedCaptureOrientation: + const Optional.fromNullable( + DeviceOrientation.landscapeRight), + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.portraitDown), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CameraPreview(controller), + ), ); + expect(find.byType(RotatedBox), findsOneWidget); + + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.quarterTurns, 2); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'when recording in DeviceOrientaiton.landscapeLeft, rotatedBox should be rotated by three clockwise quarter turns', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final FakeController controller = FakeController(); + addTearDown(controller.dispose); + + controller.value = controller.value.copyWith( + isInitialized: true, + isRecordingVideo: true, + deviceOrientation: DeviceOrientation.portraitUp, + lockedCaptureOrientation: + const Optional.fromNullable( + DeviceOrientation.landscapeRight), + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.landscapeLeft), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); await tester.pumpWidget( Directionality( @@ -176,7 +288,7 @@ void main() { }); testWidgets( - 'when orientation locked rotatedBox should turn according to locked orientation', + 'when orientation locked in DeviceOrientaiton.portaitUp, rotatedBox should not be rotated', ( WidgetTester tester, ) async { @@ -184,16 +296,53 @@ void main() { final FakeController controller = FakeController(); addTearDown(controller.dispose); + controller.value = controller.value.copyWith( - isInitialized: true, - deviceOrientation: DeviceOrientation.portraitUp, - lockedCaptureOrientation: - const Optional.fromNullable( - DeviceOrientation.landscapeRight), - recordingOrientation: const Optional.fromNullable( - DeviceOrientation.landscapeLeft), - previewSize: const Size(480, 640), + isInitialized: true, + deviceOrientation: DeviceOrientation.portraitDown, + lockedCaptureOrientation: + const Optional.fromNullable( + DeviceOrientation.portraitUp), + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.landscapeLeft), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CameraPreview(controller), + ), ); + expect(find.byType(RotatedBox), findsOneWidget); + + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.quarterTurns, 0); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'when orientation locked in DeviceOrientaiton.landscapeRight, rotatedBox should be rotated by one clockwise quarter turn', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final FakeController controller = FakeController(); + addTearDown(controller.dispose); + + controller.value = controller.value.copyWith( + isInitialized: true, + deviceOrientation: DeviceOrientation.portraitDown, + lockedCaptureOrientation: + const Optional.fromNullable( + DeviceOrientation.landscapeRight), + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.landscapeLeft), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); await tester.pumpWidget( Directionality( @@ -211,7 +360,7 @@ void main() { }); testWidgets( - 'when not locked and not recording rotatedBox should turn according to device orientation', + 'when orientation locked in DeviceOrientaiton.portraitDown, rotatedBox should be rotated by two clockwise quarter turns', ( WidgetTester tester, ) async { @@ -219,13 +368,86 @@ void main() { final FakeController controller = FakeController(); addTearDown(controller.dispose); + controller.value = controller.value.copyWith( - isInitialized: true, - deviceOrientation: DeviceOrientation.portraitUp, - recordingOrientation: const Optional.fromNullable( - DeviceOrientation.landscapeLeft), - previewSize: const Size(480, 640), + isInitialized: true, + deviceOrientation: DeviceOrientation.portraitUp, + lockedCaptureOrientation: + const Optional.fromNullable( + DeviceOrientation.portraitDown), + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.landscapeLeft), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CameraPreview(controller), + ), + ); + expect(find.byType(RotatedBox), findsOneWidget); + + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.quarterTurns, 2); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'when orientation locked in DeviceOrientaiton.landscapeRight, rotatedBox should be rotated by three clockwise quarter turns', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final FakeController controller = FakeController(); + addTearDown(controller.dispose); + + controller.value = controller.value.copyWith( + isInitialized: true, + deviceOrientation: DeviceOrientation.portraitUp, + lockedCaptureOrientation: + const Optional.fromNullable( + DeviceOrientation.landscapeRight), + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.landscapeLeft), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CameraPreview(controller), + ), ); + expect(find.byType(RotatedBox), findsOneWidget); + + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.quarterTurns, 1); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'when orientation not locked, not recording, and device orientation is portrait up, rotatedBox should not be rotated', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final FakeController controller = FakeController(); + addTearDown(controller.dispose); + + controller.value = controller.value.copyWith( + isInitialized: true, + deviceOrientation: DeviceOrientation.portraitUp, + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.landscapeLeft), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); await tester.pumpWidget( Directionality( @@ -241,6 +463,105 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + + testWidgets( + 'when orientation not locked, not recording, and device orientation is landscape right, rotatedBox should be rotated by one clockwise quarter turn', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final FakeController controller = FakeController(); + addTearDown(controller.dispose); + + controller.value = controller.value.copyWith( + isInitialized: true, + deviceOrientation: DeviceOrientation.landscapeRight, + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.landscapeLeft), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CameraPreview(controller), + ), + ); + expect(find.byType(RotatedBox), findsOneWidget); + + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.quarterTurns, 1); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'when orientation not locked, not recording, and device orientation is portrait down, rotatedBox should be rotated by two clockwise quarter turns', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final FakeController controller = FakeController(); + addTearDown(controller.dispose); + + controller.value = controller.value.copyWith( + isInitialized: true, + deviceOrientation: DeviceOrientation.portraitDown, + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.landscapeLeft), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CameraPreview(controller), + ), + ); + expect(find.byType(RotatedBox), findsOneWidget); + + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.quarterTurns, 2); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets( + 'when orientation not locked, not recording, and device orientation is landscape left, rotatedBox should be rotated by three clockwise quarter turns', + ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final FakeController controller = FakeController(); + addTearDown(controller.dispose); + + controller.value = controller.value.copyWith( + isInitialized: true, + deviceOrientation: DeviceOrientation.landscapeLeft, + recordingOrientation: const Optional.fromNullable( + DeviceOrientation.portraitDown), + previewSize: const Size(480, 640) // preview size irrelevant to test + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CameraPreview(controller), + ), + ); + expect(find.byType(RotatedBox), findsOneWidget); + + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.quarterTurns, 3); + + debugDefaultTargetPlatformOverride = null; + }); }, skip: kIsWeb); testWidgets('when not on Android there should not be a rotated box', @@ -249,9 +570,9 @@ void main() { final FakeController controller = FakeController(); addTearDown(controller.dispose); controller.value = controller.value.copyWith( - isInitialized: true, - previewSize: const Size(480, 640), - ); + isInitialized: true, + previewSize: const Size(480, 640) // preview size irrelevant to test + ); await tester.pumpWidget( Directionality( diff --git a/packages/camera/camera_android_camerax/CHANGELOG.md b/packages/camera/camera_android_camerax/CHANGELOG.md index 7e190cb5ce33..8cfe3a258415 100644 --- a/packages/camera/camera_android_camerax/CHANGELOG.md +++ b/packages/camera/camera_android_camerax/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.6.15 + +* Fixes incorrect camera preview rotation when capture orientation is locked. + +## 0.6.14 + +* Fixes incorrect camera preview rotation when capture orientation is unlocked. + ## 0.6.13 * Adds API support query for image streaming. diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/GeneratedCameraXLibrary.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/GeneratedCameraXLibrary.java index 7adf02595ad9..93f65cbc791c 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/GeneratedCameraXLibrary.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/GeneratedCameraXLibrary.java @@ -1441,9 +1441,6 @@ void requestCameraPermissions( @NonNull String getTempFilePath(@NonNull String prefix, @NonNull String suffix); - @NonNull - Boolean isPreviewPreTransformed(); - /** The codec used by SystemServicesHostApi. */ static @NonNull MessageCodec getCodec() { return SystemServicesHostApiCodec.INSTANCE; @@ -1511,29 +1508,6 @@ public void error(Throwable error) { channel.setMessageHandler(null); } } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.SystemServicesHostApi.isPreviewPreTransformed", - getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList(); - try { - Boolean output = api.isPreviewPreTransformed(); - wrapped.add(0, output); - } catch (Throwable exception) { - ArrayList wrappedError = wrapError(exception); - wrapped = wrappedError; - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } } } /** Generated class from Pigeon that represents Flutter messages that can be called from Java. */ @@ -1761,6 +1735,9 @@ void create( void setTargetRotation(@NonNull Long identifier, @NonNull Long rotation); + @NonNull + Boolean surfaceProducerHandlesCropAndRotation(); + /** The codec used by PreviewHostApi. */ static @NonNull MessageCodec getCodec() { return PreviewHostApiCodec.INSTANCE; @@ -1898,6 +1875,29 @@ static void setup(@NonNull BinaryMessenger binaryMessenger, @Nullable PreviewHos channel.setMessageHandler(null); } } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.PreviewHostApi.surfaceProducerHandlesCropAndRotation", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList(); + try { + Boolean output = api.surfaceProducerHandlesCropAndRotation(); + wrapped.add(0, output); + } catch (Throwable exception) { + ArrayList wrappedError = wrapError(exception); + wrapped = wrappedError; + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PreviewHostApiImpl.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PreviewHostApiImpl.java index 92202dd74dcf..ffec50a22d41 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PreviewHostApiImpl.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/PreviewHostApiImpl.java @@ -155,7 +155,10 @@ String getProvideSurfaceErrorDescription(int resultCode) { public void releaseFlutterSurfaceTexture() { if (flutterSurfaceProducer != null) { flutterSurfaceProducer.release(); + return; } + throw new IllegalStateException( + "releaseFlutterSurfaceTexture() cannot be called if the flutterSurfaceProducer for the camera preview has not yet been initialized."); } /** Returns the resolution information for the specified {@link Preview}. */ @@ -179,6 +182,16 @@ public void setTargetRotation(@NonNull Long identifier, @NonNull Long rotation) preview.setTargetRotation(rotation.intValue()); } + @NonNull + @Override + public Boolean surfaceProducerHandlesCropAndRotation() { + if (flutterSurfaceProducer != null) { + return flutterSurfaceProducer.handlesCropAndRotation(); + } + throw new IllegalStateException( + "surfaceProducerHandlesCropAndRotation() cannot be called if the flutterSurfaceProducer for the camera preview has not yet been initialized."); + } + /** Retrieves the {@link Preview} instance associated with the specified {@code identifier}. */ private Preview getPreviewInstance(@NonNull Long identifier) { return Objects.requireNonNull(instanceManager.getInstance(identifier)); diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/SystemServicesHostApiImpl.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/SystemServicesHostApiImpl.java index 138e9259e025..d058d62fe224 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/SystemServicesHostApiImpl.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/SystemServicesHostApiImpl.java @@ -6,7 +6,6 @@ import android.app.Activity; import android.content.Context; -import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -104,18 +103,4 @@ public String getTempFilePath(@NonNull String prefix, @NonNull String suffix) { null); } } - - /** - * Returns whether or not Impeller uses an {@code ImageReader} backend to provide a {@code - * Surface} to CameraX to build the preview. If it is backed by an {@code ImageReader}, then - * CameraX will not automatically apply the transformation needed to correct the preview. - * - *

This is determined by the engine, which approximately uses {@code SurfaceTexture}s on - * Android SDKs below 29. - */ - @Override - @NonNull - public Boolean isPreviewPreTransformed() { - return Build.VERSION.SDK_INT < 29; - } } diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PreviewTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PreviewTest.java index 6fd43c2f9a39..bc2577bc0072 100644 --- a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PreviewTest.java +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/PreviewTest.java @@ -263,6 +263,20 @@ public void setTargetRotation_makesCallToSetTargetRotation() { verify(mockPreview).setTargetRotation(targetRotation); } + @Test + public void + surfaceProducerHandlesCropAndRotation_returnsIfSurfaceProducerHandlesCropAndRotation() { + final PreviewHostApiImpl hostApi = + new PreviewHostApiImpl(mockBinaryMessenger, testInstanceManager, mockTextureRegistry); + final TextureRegistry.SurfaceProducer mockSurfaceProducer = + mock(TextureRegistry.SurfaceProducer.class); + + hostApi.flutterSurfaceProducer = mockSurfaceProducer; + when(mockSurfaceProducer.handlesCropAndRotation()).thenReturn(true); + + assertEquals(hostApi.surfaceProducerHandlesCropAndRotation(), true); + } + // TODO(bparrishMines): Replace with inline calls to onSurfaceCleanup once available on stable; // see https://github.com/flutter/flutter/issues/16125. This separate method only exists to scope // the suppression. diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/SystemServicesTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/SystemServicesTest.java index 52d02e67f653..fdfc1b224ffa 100644 --- a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/SystemServicesTest.java +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/SystemServicesTest.java @@ -5,9 +5,7 @@ package io.flutter.plugins.camerax; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -33,7 +31,6 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) public class SystemServicesTest { @@ -136,28 +133,4 @@ public void getTempFilePath_throwsRuntimeExceptionOnIOException() { mockedStaticFile.close(); } - - @Test - @Config(sdk = 28) - public void isPreviewPreTransformed_returnsTrueWhenRunningBelowSdk29() { - final SystemServicesHostApiImpl systemServicesHostApi = - new SystemServicesHostApiImpl(mockBinaryMessenger, mockInstanceManager, mockContext); - assertTrue(systemServicesHostApi.isPreviewPreTransformed()); - } - - @Test - @Config(sdk = 28) - public void isPreviewPreTransformed_returnsTrueWhenRunningSdk28() { - final SystemServicesHostApiImpl systemServicesHostApi = - new SystemServicesHostApiImpl(mockBinaryMessenger, mockInstanceManager, mockContext); - assertTrue(systemServicesHostApi.isPreviewPreTransformed()); - } - - @Test - @Config(sdk = 29) - public void isPreviewPreTransformed_returnsFalseWhenRunningAboveSdk28() { - final SystemServicesHostApiImpl systemServicesHostApi = - new SystemServicesHostApiImpl(mockBinaryMessenger, mockInstanceManager, mockContext); - assertFalse(systemServicesHostApi.isPreviewPreTransformed()); - } } 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 31cde2fa0fcb..1ab89f8b808f 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 @@ -47,6 +47,7 @@ import 'recording.dart'; import 'resolution_filter.dart'; import 'resolution_selector.dart'; import 'resolution_strategy.dart'; +import 'rotated_preview.dart'; import 'surface.dart'; import 'system_services.dart'; import 'use_case.dart'; @@ -183,12 +184,11 @@ class AndroidCameraCameraX extends CameraPlatform { /// set for the camera in use. static const String zoomStateNotSetErrorCode = 'zoomStateNotSet'; - /// Whether or not the capture orientation is locked. + /// The current locked capture orientaiton. /// - /// Indicates a new target rotation should not be set as it has been locked by - /// [lockCaptureOrientation]. + /// Null if the capture orientation is not locked. @visibleForTesting - bool captureOrientationLocked = false; + DeviceOrientation? lockedCaptureOrientation; /// Whether or not the default rotation for [UseCase]s needs to be set /// manually because the capture orientation was previously locked. @@ -238,12 +238,21 @@ class AndroidCameraCameraX extends CameraPlatform { late bool cameraIsFrontFacing; /// The camera sensor orientation. + /// + /// This can change if the camera being used changes. Also, it is independent + /// of the device orientation or user interface orientation. @visibleForTesting - late int sensorOrientation; + late double sensorOrientationDegrees; + + /// Whether or not the Android surface producer automatically handles + /// correcting the rotation of camera previews for the device this plugin runs on. + late bool _handlesCropAndRotation; - /// Subscription for listening to changes in device orientation. - StreamSubscription? - _subscriptionForDeviceOrientationChanges; + /// The initial orientation of the device when the camera is created. + late DeviceOrientation _initialDeviceOrientation; + + /// Correctly rotated preview [Widget], if used. + RotatedPreview? _rotatedPreview; /// Returns list of all available cameras and their descriptions. @override @@ -380,10 +389,10 @@ class AndroidCameraCameraX extends CameraPlatform { // Retrieve info required for correcting the rotation of the camera preview // if necessary. - - final Camera2CameraInfo camera2CameraInfo = - await proxy.getCamera2CameraInfo(cameraInfo!); - sensorOrientation = await proxy.getSensorOrientation(camera2CameraInfo); + sensorOrientationDegrees = cameraDescription.sensorOrientation.toDouble(); + _handlesCropAndRotation = + await proxy.previewSurfaceProducerHandlesCropAndRotation(preview!); + _initialDeviceOrientation = await proxy.getUiOrientation(); return flutterSurfaceTextureId; } @@ -444,7 +453,6 @@ class AndroidCameraCameraX extends CameraPlatform { await liveCameraState?.removeObservers(); processCameraProvider?.unbindAll(); await imageAnalysis?.clearAnalyzer(); - await _subscriptionForDeviceOrientationChanges?.cancel(); } /// The camera has been initialized. @@ -496,7 +504,7 @@ class AndroidCameraCameraX extends CameraPlatform { // if orientation is ever unlocked and (2) the capture orientation is locked // and should not be changed until unlocked. shouldSetDefaultRotation = true; - captureOrientationLocked = true; + lockedCaptureOrientation = orientation; // Get target rotation based on locked orientation. final int targetLockedRotation = @@ -506,13 +514,19 @@ class AndroidCameraCameraX extends CameraPlatform { await imageCapture!.setTargetRotation(targetLockedRotation); await imageAnalysis!.setTargetRotation(targetLockedRotation); await videoCapture!.setTargetRotation(targetLockedRotation); + + // Update preview to maintain correct rotation, if needed. + _rotatedPreview?.setLockedCaptureOrientation(lockedCaptureOrientation); } /// Unlocks the capture orientation. @override Future unlockCaptureOrientation(int cameraId) async { // Flag that default rotation should be set for UseCases as needed. - captureOrientationLocked = false; + lockedCaptureOrientation = null; + + // Update preview to maintain correct rotation, if needed. + _rotatedPreview?.setLockedCaptureOrientation(null); } /// Sets the exposure point for automatically determining the exposure values. @@ -836,7 +850,34 @@ class AndroidCameraCameraX extends CameraPlatform { ); } - return Texture(textureId: cameraId); + final Widget preview = Texture(textureId: cameraId); + + if (_handlesCropAndRotation) { + return preview; + } + + final Stream deviceOrientationStream = + onDeviceOrientationChanged() + .map((DeviceOrientationChangedEvent e) => e.orientation); + if (cameraIsFrontFacing) { + _rotatedPreview = RotatedPreview.frontFacingCamera( + _initialDeviceOrientation, + deviceOrientationStream, + sensorOrientationDegrees: sensorOrientationDegrees, + initialLockedCaptureOrientation: lockedCaptureOrientation, + child: preview, + ); + } else { + _rotatedPreview = RotatedPreview.backFacingCamera( + _initialDeviceOrientation, + deviceOrientationStream, + sensorOrientationDegrees: sensorOrientationDegrees, + initialLockedCaptureOrientation: lockedCaptureOrientation, + child: preview, + ); + } + + return _rotatedPreview!; } /// Captures an image and returns the file where it was saved. @@ -856,7 +897,7 @@ class AndroidCameraCameraX extends CameraPlatform { // Set target rotation to default CameraX rotation only if capture // orientation not locked. - if (!captureOrientationLocked && shouldSetDefaultRotation) { + if (lockedCaptureOrientation == null && shouldSetDefaultRotation) { await imageCapture! .setTargetRotation(await proxy.getDefaultDisplayRotation()); } @@ -985,7 +1026,7 @@ class AndroidCameraCameraX extends CameraPlatform { // Set target rotation to default CameraX rotation only if capture // orientation not locked. - if (!captureOrientationLocked && shouldSetDefaultRotation) { + if (lockedCaptureOrientation == null && shouldSetDefaultRotation) { await videoCapture! .setTargetRotation(await proxy.getDefaultDisplayRotation()); } @@ -1116,7 +1157,7 @@ class AndroidCameraCameraX extends CameraPlatform { // Set target rotation to default CameraX rotation only if capture // orientation not locked. - if (!captureOrientationLocked && shouldSetDefaultRotation) { + if (lockedCaptureOrientation == null && shouldSetDefaultRotation) { await imageAnalysis! .setTargetRotation(await proxy.getDefaultDisplayRotation()); } 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 a9461eaaae03..e830a288ce9e 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 @@ -1003,33 +1003,6 @@ class SystemServicesHostApi { return (replyList[0] as String?)!; } } - - Future isPreviewPreTransformed() async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.SystemServicesHostApi.isPreviewPreTransformed', - codec, - binaryMessenger: _binaryMessenger); - final List? replyList = await channel.send(null) as List?; - if (replyList == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyList.length > 1) { - throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], - ); - } else if (replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (replyList[0] as bool?)!; - } - } } abstract class SystemServicesFlutterApi { @@ -1356,6 +1329,33 @@ class PreviewHostApi { return; } } + + Future surfaceProducerHandlesCropAndRotation() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.PreviewHostApi.surfaceProducerHandlesCropAndRotation', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = await channel.send(null) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (replyList[0] as bool?)!; + } + } } class VideoCaptureHostApi { diff --git a/packages/camera/camera_android_camerax/lib/src/camerax_proxy.dart b/packages/camera/camera_android_camerax/lib/src/camerax_proxy.dart index feae868ac40c..c5d92d29560b 100644 --- a/packages/camera/camera_android_camerax/lib/src/camerax_proxy.dart +++ b/packages/camera/camera_android_camerax/lib/src/camerax_proxy.dart @@ -70,6 +70,8 @@ class CameraXProxy { this.getCamera2CameraInfo = _getCamera2CameraInfo, this.getUiOrientation = _getUiOrientation, this.getSensorOrientation = _getSensorOrientation, + this.previewSurfaceProducerHandlesCropAndRotation = + _previewSurfaceProducerHandlesCropAndRotation, }); /// Returns a [ProcessCameraProvider] instance. @@ -200,6 +202,11 @@ class CameraXProxy { Future Function(Camera2CameraInfo camera2CameraInfo) getSensorOrientation; + /// Returns whether or not the preview's surface producer handles correctly + /// rotating the camera preview automatically. + Future Function(Preview preview) + previewSurfaceProducerHandlesCropAndRotation; + static Future _getProcessCameraProvider() { return ProcessCameraProvider.getInstance(); } @@ -355,4 +362,9 @@ class CameraXProxy { Camera2CameraInfo camera2CameraInfo) async { return camera2CameraInfo.getSensorOrientation(); } + + static Future _previewSurfaceProducerHandlesCropAndRotation( + Preview preview) async { + return preview.surfaceProducerHandlesCropAndRotation(); + } } diff --git a/packages/camera/camera_android_camerax/lib/src/preview.dart b/packages/camera/camera_android_camerax/lib/src/preview.dart index 8990313817b6..76da65e4a81a 100644 --- a/packages/camera/camera_android_camerax/lib/src/preview.dart +++ b/packages/camera/camera_android_camerax/lib/src/preview.dart @@ -86,6 +86,12 @@ class Preview extends UseCase { Future getResolutionInfo() { return _api.getResolutionInfoFromInstance(this); } + + /// Returns whether or not the Android surface producer automatically handles + /// correcting the rotation of camera previews for the device this plugin runs on. + Future surfaceProducerHandlesCropAndRotation() { + return _api.surfaceProducerHandlesCropAndRotationFromInstance(); + } } /// Host API implementation of [Preview]. @@ -156,4 +162,10 @@ class PreviewHostApiImpl extends PreviewHostApi { return resolutionInfo; } + + /// Returns whether or not the Android surface producer automatically handles + /// correcting the rotation of camera previews for the device this plugin runs on. + Future surfaceProducerHandlesCropAndRotationFromInstance() { + return surfaceProducerHandlesCropAndRotation(); + } } diff --git a/packages/camera/camera_android_camerax/lib/src/rotated_preview.dart b/packages/camera/camera_android_camerax/lib/src/rotated_preview.dart new file mode 100644 index 000000000000..44ef50eb25ba --- /dev/null +++ b/packages/camera/camera_android_camerax/lib/src/rotated_preview.dart @@ -0,0 +1,158 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +/// Widget that rotates the camera preview to be upright according to the +/// current user interface orientation. +@internal +final class RotatedPreview extends StatefulWidget { + /// Creates [RotatedPreview] that will correct the preview + /// rotation assuming that the front camera is being used. + RotatedPreview.frontFacingCamera( + this.initialDeviceOrientation, + this.deviceOrientation, { + required this.sensorOrientationDegrees, + required this.child, + this.initialLockedCaptureOrientation, + super.key, + }) : facingSign = 1, + lockedCaptureOrientation = + ValueNotifier(initialLockedCaptureOrientation); + + /// Creates [RotatedPreview] that will correct the preview + /// rotation assuming that the back camera is being used. + RotatedPreview.backFacingCamera( + this.initialDeviceOrientation, + this.deviceOrientation, { + required this.child, + required this.sensorOrientationDegrees, + this.initialLockedCaptureOrientation, + super.key, + }) : facingSign = -1, + lockedCaptureOrientation = + ValueNotifier(initialLockedCaptureOrientation); + + /// The initial orientation of the device when the camera is created. + final DeviceOrientation initialDeviceOrientation; + + /// The orientation of the device using the camera. + final Stream deviceOrientation; + + /// The orienation of the camera sensor in degrees. + final double sensorOrientationDegrees; + + /// The camera preview [Widget] to rotate. + final Widget child; + + /// The initial locked capture orientation of the camera when this + /// widget is created. + final DeviceOrientation? initialLockedCaptureOrientation; + + /// Value used to calculate the correct preview rotation. + /// + /// 1 if the camera is front facing; -1 if the camera is back facing. + final int facingSign; + + /// Value notifier for the camera's locked capture orientation, + /// if it is locked. + final ValueNotifier lockedCaptureOrientation; + + /// Update the locked capture orientation of the camera. + // ignore: use_setters_to_change_properties + void setLockedCaptureOrientation( + DeviceOrientation? newLockedCaptureOrientation) { + lockedCaptureOrientation.value = newLockedCaptureOrientation; + } + + @override + State createState() => _RotatedPreviewState(); +} + +final class _RotatedPreviewState extends State { + late DeviceOrientation deviceOrientation; + late StreamSubscription deviceOrientationSubscription; + + @override + void initState() { + deviceOrientation = widget.initialDeviceOrientation; + deviceOrientationSubscription = + widget.deviceOrientation.listen((DeviceOrientation event) { + // Ensure that we aren't updating the state if the widget is being destroyed. + if (!mounted) { + return; + } + setState(() { + deviceOrientation = event; + }); + }); + super.initState(); + } + + double _getClockwiseDegreesFromDeviceOrientation( + DeviceOrientation orientation) { + return switch (orientation) { + DeviceOrientation.portraitUp => 0, + DeviceOrientation.landscapeRight => 90, + DeviceOrientation.portraitDown => 180, + DeviceOrientation.landscapeLeft => 270, + }; + } + + double _computeRotationDegrees({ + required DeviceOrientation deviceOrientation, + required double sensorOrientationDegrees, + required int sign, + DeviceOrientation? lockedCaptureOrientation, + }) { + final double deviceOrientationDegrees = + _getClockwiseDegreesFromDeviceOrientation(deviceOrientation); + + // Rotate the camera preview according to + // https://developer.android.com/media/camera/camera2/camera-preview#orientation_calculation. + double rotationDegrees = + (sensorOrientationDegrees - deviceOrientationDegrees * sign + 360) % + 360; + + // Then, subtract the rotation already applied in the CameraPreview widget + // (see camera/camera/lib/src/camera_preview.dart). This will be either + // the locked capture orientation (if applied) or the device orientation. + rotationDegrees -= lockedCaptureOrientation != null + ? _getClockwiseDegreesFromDeviceOrientation(lockedCaptureOrientation) + : deviceOrientationDegrees; + + return rotationDegrees; + } + + @override + void dispose() { + deviceOrientationSubscription.cancel(); + widget.lockedCaptureOrientation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.lockedCaptureOrientation, + builder: (BuildContext context, + DeviceOrientation? lockedCaptureOrientation, Widget? child) { + final double rotationDegrees = _computeRotationDegrees( + deviceOrientation: deviceOrientation, + sensorOrientationDegrees: widget.sensorOrientationDegrees, + sign: widget.facingSign, + lockedCaptureOrientation: lockedCaptureOrientation, + ); + + return RotatedBox( + quarterTurns: rotationDegrees ~/ 90, + child: widget.child, + ); + }); + } +} diff --git a/packages/camera/camera_android_camerax/lib/src/system_services.dart b/packages/camera/camera_android_camerax/lib/src/system_services.dart index 5f59bd2f4c60..b75a1cb98035 100644 --- a/packages/camera/camera_android_camerax/lib/src/system_services.dart +++ b/packages/camera/camera_android_camerax/lib/src/system_services.dart @@ -48,21 +48,6 @@ class SystemServices { SystemServicesHostApi(binaryMessenger: binaryMessenger); return api.getTempFilePath(prefix, suffix); } - - /// Returns whether or not the Android Surface used to display the camera - /// preview is backed by a SurfaceTexture, to which the transformation to - /// correctly rotate the preview has been applied. - /// - /// This is used to determine the correct rotation of the camera preview - /// because Surfaces not backed by a SurfaceTexture are not transformed by - /// CameraX to the expected rotation based on that of the device and must - /// be corrected by the plugin. - static Future isPreviewPreTransformed( - {BinaryMessenger? binaryMessenger}) { - final SystemServicesHostApi api = - SystemServicesHostApi(binaryMessenger: binaryMessenger); - return api.isPreviewPreTransformed(); - } } /// Host API implementation of [SystemServices]. diff --git a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart index 949830db3e83..a6e4eded23ab 100644 --- a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart +++ b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart @@ -260,8 +260,6 @@ abstract class SystemServicesHostApi { CameraPermissionsErrorData? requestCameraPermissions(bool enableAudio); String getTempFilePath(String prefix, String suffix); - - bool isPreviewPreTransformed(); } @FlutterApi() @@ -297,6 +295,8 @@ abstract class PreviewHostApi { ResolutionInfo getResolutionInfo(int identifier); void setTargetRotation(int identifier, int rotation); + + bool surfaceProducerHandlesCropAndRotation(); } @HostApi(dartHostTestHandler: 'TestVideoCaptureHostApi') diff --git a/packages/camera/camera_android_camerax/pubspec.yaml b/packages/camera/camera_android_camerax/pubspec.yaml index fb7e6f0e317a..35c179794041 100644 --- a/packages/camera/camera_android_camerax/pubspec.yaml +++ b/packages/camera/camera_android_camerax/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_android_camerax description: Android implementation of the camera plugin using the CameraX library. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android_camerax issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.6.13 +version: 0.6.15 environment: sdk: ^3.6.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 89e201b3b329..55b6b782ba8d 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 @@ -50,7 +50,7 @@ import 'package:camera_android_camerax/src/zoom_state.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:flutter/services.dart' show DeviceOrientation, PlatformException, Uint8List; -import 'package:flutter/widgets.dart' show BuildContext, Size, Texture, Widget; +import 'package:flutter/widgets.dart' show BuildContext, Size; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -201,6 +201,8 @@ void main() { Future.value(MockCamera2CameraInfo()), getUiOrientation: () => Future.value(DeviceOrientation.portraitUp), + previewSurfaceProducerHandlesCropAndRotation: (_) => + Future.value(false), ); /// CameraXProxy for testing exposure and focus related controls. @@ -919,7 +921,9 @@ void main() { expect(camera.recorder!.qualitySelector, isNull); }); - test('createCamera sets sensor orientation as expected', () async { + test( + 'createCamera sets sensor orientation, handlesCropAndRotation, initialDeviceOrientation as expected', + () async { final AndroidCameraCameraX camera = AndroidCameraCameraX(); const CameraLensDirection testLensDirection = CameraLensDirection.back; const int testSensorOrientation = 270; @@ -929,6 +933,7 @@ void main() { sensorOrientation: testSensorOrientation); const bool enableAudio = true; const ResolutionPreset testResolutionPreset = ResolutionPreset.veryHigh; + const bool testHandlesCropAndRotation = true; const DeviceOrientation testUiOrientation = DeviceOrientation.portraitDown; // Mock/Detached objects for (typically attached) objects created by @@ -948,6 +953,8 @@ void main() { getProxyForTestingResolutionPreset(mockProcessCameraProvider); camera.proxy.getSensorOrientation = (_) async => Future.value(testSensorOrientation); + camera.proxy.previewSurfaceProducerHandlesCropAndRotation = + (_) async => Future.value(testHandlesCropAndRotation); camera.proxy.getUiOrientation = () async => Future.value(testUiOrientation); @@ -960,7 +967,7 @@ void main() { await camera.createCamera(testCameraDescription, testResolutionPreset, enableAudio: enableAudio); - expect(camera.sensorOrientation, testSensorOrientation); + expect(camera.sensorOrientationDegrees, testSensorOrientation); }); test( @@ -1296,6 +1303,8 @@ void main() { expect(camera.cameraControl, equals(mockCameraControl)); }); + // Further `buildPreview` testing concerning the Widget that it returns is + // located in preview_rotation_test.dart. test( 'buildPreview throws an exception if the preview is not bound to the lifecycle', () async { @@ -1310,22 +1319,6 @@ void main() { () => camera.buildPreview(cameraId), throwsA(isA())); }); - test( - 'buildPreview returns a Texture once the preview is bound to the lifecycle if it is backed by a SurfaceTexture', - () async { - final AndroidCameraCameraX camera = AndroidCameraCameraX(); - const int cameraId = 37; - - // Tell camera that createCamera has been called and thus, preview has been - // bound to the lifecycle of the camera. - camera.previewInitiallyBound = true; - - final Widget widget = camera.buildPreview(cameraId); - - expect(widget is Texture, isTrue); - expect((widget as Texture).textureId, cameraId); - }); - group('video recording', () { test( 'startVideoCapturing binds video capture use case, updates saved camera instance and its properties, and starts the recording', @@ -1357,7 +1350,7 @@ void main() { camera.imageAnalysis = MockImageAnalysis(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeRight; // Tell plugin to create detached Observer when camera info updated. camera.proxy = CameraXProxy( @@ -1440,7 +1433,7 @@ void main() { camera.imageAnalysis = MockImageAnalysis(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitUp; // Tell plugin to create detached Observer when camera info updated. camera.proxy = CameraXProxy( @@ -1520,7 +1513,7 @@ void main() { camera.imageCapture = MockImageCapture(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitDown; // Tell plugin to create detached Analyzer for testing. camera.proxy = CameraXProxy( @@ -1635,7 +1628,7 @@ void main() { // Orientation is locked and plugin does not need to set default target // rotation manually. camera.recording = null; - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitDown; await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verifyNever(mockVideoCapture.setTargetRotation(any)); @@ -1646,7 +1639,7 @@ void main() { // Orientation is locked and plugin does need to set default target // rotation manually. camera.recording = null; - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitUp; camera.shouldSetDefaultRotation = true; await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verifyNever(mockVideoCapture.setTargetRotation(any)); @@ -1658,7 +1651,7 @@ void main() { // Orientation is unlocked and plugin does need to set default target // rotation manually. camera.recording = null; - camera.captureOrientationLocked = false; + camera.lockedCaptureOrientation = null; camera.shouldSetDefaultRotation = true; await camera.startVideoCapturing(const VideoCaptureOptions(cameraId)); verify(mockVideoCapture.setTargetRotation(defaultTargetRotation)); @@ -1858,7 +1851,7 @@ void main() { camera.cameraSelector = MockCameraSelector(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeLeft; // Tell plugin to create detached camera state observers. camera.proxy = CameraXProxy( @@ -1913,20 +1906,20 @@ void main() { // Orientation is locked and plugin does not need to set default target // rotation manually. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitDown; await camera.takePicture(cameraId); verifyNever(mockImageCapture.setTargetRotation(any)); // Orientation is locked and plugin does need to set default target // rotation manually. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeLeft; camera.shouldSetDefaultRotation = true; await camera.takePicture(cameraId); verifyNever(mockImageCapture.setTargetRotation(any)); // Orientation is unlocked and plugin does need to set default target // rotation manually. - camera.captureOrientationLocked = false; + camera.lockedCaptureOrientation = null; camera.shouldSetDefaultRotation = true; await camera.takePicture(cameraId); verify(mockImageCapture.setTargetRotation(defaultTargetRotation)); @@ -1945,7 +1938,7 @@ void main() { camera.processCameraProvider = mockProcessCameraProvider; // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitDown; when(mockProcessCameraProvider.isBound(camera.imageCapture)) .thenAnswer((_) async => true); @@ -1969,7 +1962,7 @@ void main() { camera.cameraControl = mockCameraControl; // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitUp; camera.processCameraProvider = mockProcessCameraProvider; when(mockProcessCameraProvider.isBound(camera.imageCapture)) @@ -2192,7 +2185,7 @@ void main() { camera.imageAnalysis = MockImageAnalysis(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeRight; when(mockProcessCameraProvider.bindToLifecycle(any, any)) .thenAnswer((_) => Future.value(mockCamera)); @@ -2235,7 +2228,7 @@ void main() { camera.imageAnalysis = MockImageAnalysis(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeLeft; when(mockProcessCameraProvider.isBound(camera.imageAnalysis)) .thenAnswer((_) async => true); @@ -2297,7 +2290,7 @@ void main() { camera.imageAnalysis = mockImageAnalysis; // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeLeft; when(mockProcessCameraProvider.isBound(mockImageAnalysis)) .thenAnswer((_) async => false); @@ -2361,7 +2354,7 @@ void main() { camera.processCameraProvider = mockProcessCameraProvider; // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitUp; // Tell plugin to create a detached analyzer for testing purposes. camera.proxy = CameraXProxy(createAnalyzer: (_) => MockAnalyzer()); @@ -2413,7 +2406,7 @@ void main() { // Orientation is locked and plugin does not need to set default target // rotation manually. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitDown; imageStreamSubscription = camera .onStreamedFrameAvailable(cameraId) .listen((CameraImageData data) {}); @@ -2423,7 +2416,7 @@ void main() { // Orientation is locked and plugin does need to set default target // rotation manually. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitUp; camera.shouldSetDefaultRotation = true; imageStreamSubscription = camera .onStreamedFrameAvailable(cameraId) @@ -2434,7 +2427,7 @@ void main() { // Orientation is unlocked and plugin does need to set default target // rotation manually. - camera.captureOrientationLocked = false; + camera.lockedCaptureOrientation = null; camera.shouldSetDefaultRotation = true; imageStreamSubscription = camera .onStreamedFrameAvailable(cameraId) @@ -2477,11 +2470,11 @@ void main() { verify(mockImageAnalysis.setTargetRotation(expectedTargetRotation)); verify(mockImageCapture.setTargetRotation(expectedTargetRotation)); verify(mockVideoCapture.setTargetRotation(expectedTargetRotation)); - expect(camera.captureOrientationLocked, isTrue); + expect(camera.lockedCaptureOrientation, orientation); expect(camera.shouldSetDefaultRotation, isTrue); // Reset flags for testing. - camera.captureOrientationLocked = false; + camera.lockedCaptureOrientation = null; camera.shouldSetDefaultRotation = false; } }); @@ -2492,9 +2485,9 @@ void main() { final AndroidCameraCameraX camera = AndroidCameraCameraX(); const int cameraId = 57; - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitDown; await camera.unlockCaptureOrientation(cameraId); - expect(camera.captureOrientationLocked, isFalse); + expect(camera.lockedCaptureOrientation, isNull); }); test('setExposureMode sets expected controlAeLock value via Camera2 interop', @@ -3782,7 +3775,7 @@ void main() { camera.cameraSelector = MockCameraSelector(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitDown; // Tell plugin to create a detached analyzer for testing purposes. camera.proxy = CameraXProxy( @@ -3832,7 +3825,7 @@ void main() { camera.imageAnalysis = MockImageAnalysis(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitUp; // Tell plugin to create detached Observer when camera info updated. camera.proxy = CameraXProxy( @@ -3897,7 +3890,7 @@ void main() { camera.imageAnalysis = MockImageAnalysis(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeLeft; // Tell plugin to create detached Observer when camera info updated. camera.proxy = CameraXProxy( @@ -3962,7 +3955,7 @@ void main() { camera.imageAnalysis = MockImageAnalysis(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeLeft; // Tell plugin to create detached Observer when camera info updated. camera.proxy = CameraXProxy( @@ -4028,7 +4021,7 @@ void main() { camera.imageCapture = MockImageCapture(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.portraitDown; // Tell plugin to create detached Observer when camera info updated. camera.proxy = CameraXProxy( @@ -4100,7 +4093,7 @@ void main() { camera.preview = MockPreview(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeLeft; // Tell plugin to create detached Observer when camera info updated. camera.proxy = CameraXProxy( @@ -4167,7 +4160,7 @@ void main() { camera.preview = MockPreview(); // Ignore setting target rotation for this test; tested seprately. - camera.captureOrientationLocked = true; + camera.lockedCaptureOrientation = DeviceOrientation.landscapeRight; // Tell plugin to create detached Observer when camera info updated. camera.proxy = CameraXProxy( 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 316b12759692..fc838a461bb4 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 @@ -1134,6 +1134,17 @@ class MockPreview extends _i1.Mock implements _i35.Preview { ), )), ) as _i17.Future<_i10.ResolutionInfo>); + + @override + _i17.Future surfaceProducerHandlesCropAndRotation() => + (super.noSuchMethod( + Invocation.method( + #surfaceProducerHandlesCropAndRotation, + [], + ), + returnValue: _i17.Future.value(false), + returnValueForMissingStub: _i17.Future.value(false), + ) as _i17.Future); } /// A class which mocks [ProcessCameraProvider]. @@ -1410,16 +1421,6 @@ class MockTestSystemServicesHostApi extends _i1.Mock ), ), ) as String); - - @override - bool isPreviewPreTransformed() => (super.noSuchMethod( - Invocation.method( - #isPreviewPreTransformed, - [], - ), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); } /// A class which mocks [VideoCapture]. diff --git a/packages/camera/camera_android_camerax/test/preview_rotation_test.dart b/packages/camera/camera_android_camerax/test/preview_rotation_test.dart new file mode 100644 index 000000000000..97606602cfb3 --- /dev/null +++ b/packages/camera/camera_android_camerax/test/preview_rotation_test.dart @@ -0,0 +1,1017 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:camera_android_camerax/camera_android_camerax.dart'; +import 'package:camera_android_camerax/src/camera_selector.dart'; +import 'package:camera_android_camerax/src/camerax_library.g.dart'; +import 'package:camera_android_camerax/src/camerax_proxy.dart'; +import 'package:camera_android_camerax/src/device_orientation_manager.dart'; +import 'package:camera_android_camerax/src/fallback_strategy.dart'; +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import 'android_camera_camerax_test.mocks.dart'; + +// Constants to map clockwise degree rotations to quarter turns: +const int _0DegreesClockwise = 0; +const int _90DegreesClockwise = 1; +const int _180DegreesClockwise = 2; +const int _270DegreesClockwise = 3; + +void main() { + /// Sets up mock CameraSelector and mock ProcessCameraProvider used to + /// select test camera when `availableCameras` is called. + /// + /// Also mocks a call for mock ProcessCameraProvider that is irrelevant + /// to this test. + /// + /// Returns mock ProcessCameraProvider that is used to select test camera. + MockProcessCameraProvider + setUpMockCameraSelectorAndMockProcessCameraProviderForSelectingTestCamera( + {required MockCameraSelector mockCameraSelector, + required int sensorRotationDegrees}) { + final MockProcessCameraProvider mockProcessCameraProvider = + MockProcessCameraProvider(); + final MockCameraInfo mockCameraInfo = MockCameraInfo(); + final MockCamera mockCamera = MockCamera(); + + // Mock retrieving available test camera. + when(mockProcessCameraProvider.bindToLifecycle(any, any)) + .thenAnswer((_) async => mockCamera); + when(mockCamera.getCameraInfo()).thenAnswer((_) async => mockCameraInfo); + when(mockProcessCameraProvider.getAvailableCameraInfos()) + .thenAnswer((_) async => [mockCameraInfo]); + when(mockCameraSelector.filter([mockCameraInfo])) + .thenAnswer((_) async => [mockCameraInfo]); + when(mockCameraInfo.getSensorRotationDegrees()) + .thenAnswer((_) async => sensorRotationDegrees); + + // Mock additional ProcessCameraProvider operation that is irrelevant + // for the tests in this file. + when(mockCameraInfo.getCameraState()) + .thenAnswer((_) async => MockLiveCameraState()); + + return mockProcessCameraProvider; + } + + /// Returns CameraXProxy used to mock all calls to native Android in + /// the `availableCameras` and `createCameraWithSettings` methods. + CameraXProxy getProxyForCreatingTestCamera( + {required MockProcessCameraProvider mockProcessCameraProvider, + required CameraSelector Function(int) createCameraSelector, + required bool handlesCropAndRotation, + required Future Function() getUiOrientation}) => + CameraXProxy( + getProcessCameraProvider: () async => mockProcessCameraProvider, + createCameraSelector: createCameraSelector, + previewSurfaceProducerHandlesCropAndRotation: (_) => + Future.value(handlesCropAndRotation), + getUiOrientation: getUiOrientation, + createPreview: (_, __) => MockPreview(), + createImageCapture: (_, __) => MockImageCapture(), + createRecorder: (_) => MockRecorder(), + createVideoCapture: (_) async => MockVideoCapture(), + createImageAnalysis: (_, __) => MockImageAnalysis(), + createResolutionStrategy: ( + {bool highestAvailable = false, + Size? boundSize, + int? fallbackRule}) => + MockResolutionStrategy(), + createResolutionSelector: (_, __, ___) => MockResolutionSelector(), + createFallbackStrategy: ( + {required VideoQuality quality, + required VideoResolutionFallbackRule fallbackRule}) => + MockFallbackStrategy(), + createQualitySelector: ( + {required VideoQuality videoQuality, + required FallbackStrategy fallbackStrategy}) => + MockQualitySelector(), + createCameraStateObserver: (_) => MockObserver(), + requestCameraPermissions: (_) => Future.value(), + startListeningForDeviceOrientationChange: (_, __) {}, + setPreviewSurfaceProvider: (_) => Future.value( + 3), // 3 is a random Flutter SurfaceTexture ID for testing + createAspectRatioStrategy: (int aspectRatio, int fallbackRule) => + MockAspectRatioStrategy(), + createResolutionFilterWithOnePreferredSize: + (Size preferredResolution) => MockResolutionFilter(), + ); + + /// Returns function that a CameraXProxy can use to select the front camera. + MockCameraSelector Function(int cameraSelectorLensDirection) + createCameraSelectorForFrontCamera( + MockCameraSelector mockCameraSelector) { + return (int cameraSelectorLensDirection) { + switch (cameraSelectorLensDirection) { + case CameraSelector.lensFacingFront: + return mockCameraSelector; + default: + return MockCameraSelector(); + } + }; + } + + /// Returns function that a CameraXProxy can use to select the back camera. + MockCameraSelector Function(int cameraSelectorLensDirection) + createCameraSelectorForBackCamera(MockCameraSelector mockCameraSelector) { + return (int cameraSelectorLensDirection) { + switch (cameraSelectorLensDirection) { + case CameraSelector.lensFacingBack: + return mockCameraSelector; + default: + return MockCameraSelector(); + } + }; + } + + /// Error message for detecting an incorrect preview rotation. + String getExpectedRotationTestFailureReason( + int expectedQuarterTurns, int actualQuarterTurns) => + 'Expected the preview to be rotated by $expectedQuarterTurns quarter turns (which is ${expectedQuarterTurns * 90} degrees clockwise) but instead was rotated $actualQuarterTurns quarter turns.'; + + tearDownAll(() async { + await DeviceOrientationManager.deviceOrientationChangedStreamController + .close(); + }); + + testWidgets( + 'when handlesCropAndRotation is true, the preview is an unrotated Texture', + (WidgetTester tester) async { + final AndroidCameraCameraX camera = AndroidCameraCameraX(); + const int cameraId = 537; + const MediaSettings testMediaSettings = + MediaSettings(); // media settings irrelevant for test + + // Set up test camera (specifics irrelevant for this test) and + // tell camera that handlesCropAndRotation is true. + final MockCameraSelector mockCameraSelector = MockCameraSelector(); + final MockProcessCameraProvider mockProcessCameraProvider = + setUpMockCameraSelectorAndMockProcessCameraProviderForSelectingTestCamera( + mockCameraSelector: mockCameraSelector, + sensorRotationDegrees: /* irrelevant for test */ 90); + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProvider, + createCameraSelector: (_) => mockCameraSelector, + handlesCropAndRotation: true, + /* irrelevant for test */ getUiOrientation: () => + Future.value(DeviceOrientation.landscapeLeft)); + + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture was built. + final Texture texture = tester.widget(find.byType(Texture)); + expect(texture.textureId, cameraId); + + // Verify RotatedBox was not built and thus, the Texture is not rotated. + expect(() => tester.widget(find.byType(RotatedBox)), + throwsStateError); + }); + + group('when handlesCropAndRotation is false,', () { + // Test that preview rotation responds to initial device orientation: + group('sensor orientation degrees is 270, camera is front facing,', () { + late AndroidCameraCameraX camera; + late int cameraId; + late MockCameraSelector mockFrontCameraSelector; + late MockCameraSelector Function(int cameraSelectorLensDirection) + proxyCreateCameraSelectorForFrontCamera; + late MockProcessCameraProvider mockProcessCameraProviderForFrontCamera; + late MediaSettings testMediaSettings; + + setUp(() { + camera = AndroidCameraCameraX(); + cameraId = 27; + + // Create and set up mock CameraSelector and mock ProcessCameraProvider for test front camera + // with sensor orientation degrees 270. + mockFrontCameraSelector = MockCameraSelector(); + proxyCreateCameraSelectorForFrontCamera = + createCameraSelectorForFrontCamera(mockFrontCameraSelector); + mockProcessCameraProviderForFrontCamera = + setUpMockCameraSelectorAndMockProcessCameraProviderForSelectingTestCamera( + mockCameraSelector: mockFrontCameraSelector, + sensorRotationDegrees: 270); + + // Media settings to create camera; irrelevant for test. + testMediaSettings = const MediaSettings(); + }); + + testWidgets( + 'initial device orientation fixed to DeviceOrientation.portraitUp, then the preview Texture is rotated 270 degrees clockwise', + (WidgetTester tester) async { + // Set up test to use front camera, tell camera that handlesCropAndRotation is false, + // set camera initial device orientation to portrait up. + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProviderForFrontCamera, + createCameraSelector: proxyCreateCameraSelectorForFrontCamera, + handlesCropAndRotation: false, + getUiOrientation: () => + Future.value(DeviceOrientation.portraitUp)); + + // Get and create test front camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture is rotated by ((270 - 0 * 1 + 360) % 360) - 0 = 270 degrees. + const int expectedQuarterTurns = _270DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(rotatedBox.quarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'initial device orientation fixed to DeviceOrientation.landscapeRight, then the preview Texture is rotated 90 degrees clockwise', + (WidgetTester tester) async { + // Set up test to use front camera, tell camera that handlesCropAndRotation is false, + // set camera initial device orientation to landscape right. + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProviderForFrontCamera, + createCameraSelector: proxyCreateCameraSelectorForFrontCamera, + handlesCropAndRotation: false, + getUiOrientation: () => Future.value( + DeviceOrientation.landscapeRight)); + + // Get and create test front camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture is rotated by ((90 - 270 * 1 + 360) % 360) - 90 = 90 degrees. + const int expectedQuarterTurns = _90DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(rotatedBox.quarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'initial device orientation fixed to DeviceOrientation.portraitDown, then the preview Texture is rotated 270 degrees clockwise', + (WidgetTester tester) async { + // Set up test to use front camera, tell camera that handlesCropAndRotation is false, + // set camera initial device orientation to portrait down. + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProviderForFrontCamera, + createCameraSelector: proxyCreateCameraSelectorForFrontCamera, + handlesCropAndRotation: false, + getUiOrientation: () => Future.value( + DeviceOrientation.portraitDown)); + + // Get and create test front camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture is rotated by ((270 - 180 * 1 + 360) % 360) - 180 = -90 degrees clockwise = 90 degrees counterclockwise = 270 degrees. + const int expectedQuarterTurns = _270DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'initial device orientation fixed to DeviceOrientation.landscapeLeft, then the preview Texture is rotated 90 degrees clockwise', + (WidgetTester tester) async { + // Set up test to use front camera, tell camera that handlesCropAndRotation is false, + // set camera initial device orientation to landscape left. + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProviderForFrontCamera, + createCameraSelector: proxyCreateCameraSelectorForFrontCamera, + handlesCropAndRotation: false, + getUiOrientation: () => Future.value( + DeviceOrientation.landscapeLeft)); + + // Get and create test front camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture is rotated by ((270 - 270 * 1 + 360) % 360) - 270 = -270 degrees clockwise = 270 degrees counterclockwise = 90 degrees. + const int expectedQuarterTurns = _90DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + }); + + testWidgets( + 'sensor orientation degrees is 90, camera is front facing, then the preview Texture rotates correctly as the device orientation rotates', + (WidgetTester tester) async { + final AndroidCameraCameraX camera = AndroidCameraCameraX(); + const int cameraId = 3372; + + // Create and set up mock CameraSelector and mock ProcessCameraProvider for test front camera + // with sensor orientation degrees 90. + final MockCameraSelector mockFrontCameraSelector = MockCameraSelector(); + final MockCameraSelector Function(int cameraSelectorLensDirection) + proxyCreateCameraSelectorForFrontCamera = + createCameraSelectorForFrontCamera(mockFrontCameraSelector); + final MockProcessCameraProvider mockProcessCameraProviderForFrontCamera = + setUpMockCameraSelectorAndMockProcessCameraProviderForSelectingTestCamera( + mockCameraSelector: mockFrontCameraSelector, + sensorRotationDegrees: 90); + + // Media settings to create camera; irrelevant for test. + const MediaSettings testMediaSettings = MediaSettings(); + + // Set up test to use front camera and tell camera that handlesCropAndRotation is false, + // set camera initial device orientation to landscape left. + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProviderForFrontCamera, + createCameraSelector: proxyCreateCameraSelectorForFrontCamera, + handlesCropAndRotation: false, + getUiOrientation: /* initial device orientation irrelevant for test */ + () => Future.value( + DeviceOrientation.landscapeLeft)); + + // Get and create test front camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Calculated according to: + // ((90 - currentDeviceOrientation * 1 + 360) % 360) - currentDeviceOrientation. + final Map expectedRotationPerDeviceOrientation = + { + DeviceOrientation.portraitUp: _90DegreesClockwise, + DeviceOrientation.landscapeRight: _270DegreesClockwise, + DeviceOrientation.portraitDown: _90DegreesClockwise, + DeviceOrientation.landscapeLeft: _270DegreesClockwise, + }; + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + for (final DeviceOrientation currentDeviceOrientation + in expectedRotationPerDeviceOrientation.keys) { + final DeviceOrientationChangedEvent testEvent = + DeviceOrientationChangedEvent(currentDeviceOrientation); + DeviceOrientationManager.deviceOrientationChangedStreamController + .add(testEvent); + + await tester.pumpAndSettle(); + + // Verify Texture is rotated by expected clockwise degrees. + final int expectedQuarterTurns = + expectedRotationPerDeviceOrientation[currentDeviceOrientation]!; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns < 0 + ? rotatedBox.quarterTurns + 4 + : rotatedBox.quarterTurns; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: + 'When the device orientation is $currentDeviceOrientation, expected the preview to be rotated by $expectedQuarterTurns quarter turns (which is ${expectedQuarterTurns * 90} degrees clockwise) but instead was rotated ${rotatedBox.quarterTurns} quarter turns.'); + } + }); + + // Test the preview rotation responds to the two most common sensor orientations for Android phone cameras; see + // https://developer.android.com/media/camera/camera2/camera-preview#camera_orientation. + group( + 'initial device orientation is DeviceOrientation.landscapeLeft, camera is back facing,', + () { + late AndroidCameraCameraX camera; + late int cameraId; + late MockCameraSelector mockBackCameraSelector; + late MockCameraSelector Function(int cameraSelectorLensDirection) + proxyCreateCameraSelectorForBackCamera; + late MediaSettings testMediaSettings; + late DeviceOrientation testInitialDeviceOrientation; + + setUp(() { + camera = AndroidCameraCameraX(); + cameraId = 347; + + // Set test camera initial device orientation for test. + testInitialDeviceOrientation = DeviceOrientation.landscapeLeft; + + // Create and set up mock CameraSelector and mock ProcessCameraProvider for test back camera + // with sensor orientation degrees 270. + mockBackCameraSelector = MockCameraSelector(); + proxyCreateCameraSelectorForBackCamera = + createCameraSelectorForBackCamera(mockBackCameraSelector); + + testMediaSettings = const MediaSettings(); + }); + + testWidgets( + 'sensor orientation degrees is 90, then the preview Texture is rotated 90 degrees clockwise', + (WidgetTester tester) async { + // Create mock ProcessCameraProvider that will acknowledge that the test back camera with sensor orientation degrees + // 90 is available. + final MockProcessCameraProvider mockProcessCameraProviderForBackCamera = + setUpMockCameraSelectorAndMockProcessCameraProviderForSelectingTestCamera( + mockCameraSelector: mockBackCameraSelector, + sensorRotationDegrees: 90); + + // Set up test to use back camera, tell camera that handlesCropAndRotation is false, + // set camera initial device orientation to landscape left. + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProviderForBackCamera, + createCameraSelector: proxyCreateCameraSelectorForBackCamera, + handlesCropAndRotation: false, + getUiOrientation: () => + Future.value(testInitialDeviceOrientation)); + + // Get and create test back camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture is rotated by ((90 - 270 * -1 + 360) % 360) - 270 = -270 degrees clockwise = 270 degrees counterclockwise = 90 degrees clockwise. + const int expectedQuarterTurns = _90DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'sensor orientation degrees is 270, then the preview Texture is rotated 270 degrees clockwise', + (WidgetTester tester) async { + // Create mock ProcessCameraProvider that will acknowledge that the test back camera with sensor orientation degrees + // 270 is available. + final MockProcessCameraProvider mockProcessCameraProviderForBackCamera = + setUpMockCameraSelectorAndMockProcessCameraProviderForSelectingTestCamera( + mockCameraSelector: mockBackCameraSelector, + sensorRotationDegrees: 270); + + // Set up test to use back camera, tell camera that handlesCropAndRotation is false, + // set camera initial device orientation to landscape left. + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProviderForBackCamera, + createCameraSelector: proxyCreateCameraSelectorForBackCamera, + handlesCropAndRotation: false, + getUiOrientation: () => + Future.value(testInitialDeviceOrientation)); + + // Get and create test back camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture is rotated by ((270 - 270 * -1 + 360) % 360) - 270 = -90 degrees clockwise = 90 degrees counterclockwise = 270 degrees clockwise. + const int expectedQuarterTurns = _270DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + }); + + // Test the preview rotation responds to the camera being front or back facing: + group( + 'initial device orientation is DeviceOrientation.landscapeRight, sensor orientation degrees is 90,', + () { + late AndroidCameraCameraX camera; + late int cameraId; + late MediaSettings testMediaSettings; + late DeviceOrientation testInitialDeviceOrientation; + late int testSensorOrientation; + + setUp(() { + camera = AndroidCameraCameraX(); + cameraId = 317; + + // Set test camera initial device orientation and sensor orientation for test. + testInitialDeviceOrientation = DeviceOrientation.landscapeRight; + testSensorOrientation = 90; + + // Media settings to create camera; irrelevant for test. + testMediaSettings = const MediaSettings(); + }); + + testWidgets( + 'camera is front facing, then the preview Texture is rotated 270 degrees clockwise', + (WidgetTester tester) async { + // Set up test front camera with sensor orientation degrees 90. + final MockCameraSelector mockFrontCameraSelector = MockCameraSelector(); + final MockProcessCameraProvider mockProcessCameraProvider = + setUpMockCameraSelectorAndMockProcessCameraProviderForSelectingTestCamera( + mockCameraSelector: mockFrontCameraSelector, + sensorRotationDegrees: testSensorOrientation); + + // Set up front camera selection and initial device orientation as landscape right. + final MockCameraSelector Function(int cameraSelectorLensDirection) + proxyCreateCameraSelectorForFrontCamera = + createCameraSelectorForFrontCamera(mockFrontCameraSelector); + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProvider, + createCameraSelector: proxyCreateCameraSelectorForFrontCamera, + handlesCropAndRotation: false, + getUiOrientation: () => + Future.value(testInitialDeviceOrientation)); + + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture is rotated by ((90 - 90 * 1 + 360) % 360) - 90 = -90 degrees clockwise = 90 degrees counterclockwise = 270 degrees clockwise. + const int expectedQuarterTurns = _270DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'camera is back facing, then the preview Texture is rotated 90 degrees clockwise', + (WidgetTester tester) async { + // Set up test front camera with sensor orientation degrees 90. + final MockCameraSelector mockBackCameraSelector = MockCameraSelector(); + final MockProcessCameraProvider mockProcessCameraProvider = + setUpMockCameraSelectorAndMockProcessCameraProviderForSelectingTestCamera( + mockCameraSelector: mockBackCameraSelector, + sensorRotationDegrees: testSensorOrientation); + + // Set up front camera selection and initial device orientation as landscape right. + final MockCameraSelector Function(int cameraSelectorLensDirection) + proxyCreateCameraSelectorForBackCamera = + createCameraSelectorForBackCamera(mockBackCameraSelector); + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProvider, + createCameraSelector: proxyCreateCameraSelectorForBackCamera, + handlesCropAndRotation: false, + getUiOrientation: () => + Future.value(testInitialDeviceOrientation)); + + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture is rotated by ((90 - 90 * -1 + 360) % 360) - 90 = 90 degrees clockwise. + const int expectedQuarterTurns = _90DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(rotatedBox.quarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + }); + // Test that preview is properly rotated when capture orientation is locked: + group( + 'initial device orientation is DeviceOrientation.portraitDown, sensor orientation degrees is 270, camera is back facing,', + () { + late AndroidCameraCameraX camera; + late int cameraId; + late MediaSettings testMediaSettings; + + setUp(() { + camera = AndroidCameraCameraX(); + cameraId = 22; + testMediaSettings = const MediaSettings(); + + // Set up back facing test camera with sensor orientation degrees 270. Additionally, set + // initial device orientation to portrait down. + final MockCameraSelector mockBackCameraSelector = MockCameraSelector(); + final MockProcessCameraProvider mockProcessCameraProvider = + setUpMockCameraSelectorAndMockProcessCameraProviderForSelectingTestCamera( + mockCameraSelector: mockBackCameraSelector, + sensorRotationDegrees: 270); + final MockCameraSelector Function(int cameraSelectorLensDirection) + proxyCreateCameraSelectorForBackCamera = + createCameraSelectorForBackCamera(mockBackCameraSelector); + camera.proxy = getProxyForCreatingTestCamera( + mockProcessCameraProvider: mockProcessCameraProvider, + createCameraSelector: proxyCreateCameraSelectorForBackCamera, + handlesCropAndRotation: false, + getUiOrientation: () => Future.value( + DeviceOrientation.portraitDown)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.landscapeLeft before the preview is built, then the preview Texture is rotated 180 degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Lock capture orientation to landscape left. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.landscapeLeft); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Verify Texture is rotated by ((180 - 270 * -1 + 360) % 360) - 270 = -180 degrees clockwise = 180 degrees counterclockwise = 180 degrees clockwise. + const int expectedQuarterTurns = _180DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.landscapeLeft after the preview is built, then the preview Texture is rotated 180 degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Lock capture orientation to landscape left. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.landscapeLeft); + await tester.pumpAndSettle(); + + // Verify Texture is rotated by ((180 - 270 * -1 + 360) % 360) - 270 = -180 degrees clockwise = 180 degrees counterclockwise = 180 degrees clockwise. + const int expectedQuarterTurns = _180DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.portraitUp before the preview is built, then locked to DeviceOrientation.landscapeRight after it is built, then the preview Texture is rotated 0 degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Lock capture orientation to portrait up. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.portraitUp); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Lock capture orientation to landscape left. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.landscapeRight); + await tester.pumpAndSettle(); + + // Verify Texture is rotated by ((180 - 270 * -1 + 360) % 360) - 90 = 0 degrees clockwise. + const int expectedQuarterTurns = _0DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(rotatedBox.quarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.portraitUp after the preview is built, then locked to DeviceOrientation.landscapeRight, then the preview Texture is rotated 0 degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Lock capture orientation to portrait up. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.portraitUp); + await tester.pumpAndSettle(); + + // Lock capture orientation to landscape left. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.landscapeRight); + await tester.pumpAndSettle(); + + // Verify Texture is rotated by ((180 - 270 * -1 + 360) % 360) - 90 = 0 degrees clockwise. + const int expectedQuarterTurns = _0DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(rotatedBox.quarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.portraitUp before the preview is built, then it is unlocked, then the preview Texture is rotated 270 degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Lock capture orientation to portrait up. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.portraitUp); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Unlock capture orientation. + await camera.unlockCaptureOrientation(cameraId); + await tester.pumpAndSettle(); + + // Verify Texture is rotated by ((180 - 270 * -1 + 360) % 360) - 180 = -90 degrees clockwise = 90 degrees counterclockwise = 270 degrees clockwise. + const int expectedQuarterTurns = _270DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.portraitUp after the preview is built, then it is unlocked, then the preview Texture is rotated 270 degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Lock capture orientation to portrait up. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.portraitUp); + await tester.pumpAndSettle(); + + // Unlock capture orientation. + await camera.unlockCaptureOrientation(cameraId); + await tester.pumpAndSettle(); + + // Verify Texture is rotated by ((180 - 270 * -1 + 360) % 360) - 180 = -90 degrees clockwise = 90 degrees counterclockwise = 270 degrees clockwise. + const int expectedQuarterTurns = _270DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.portraitDown before the preview is built, then the device orientation is changed DeviceOrientation.landscapeRight, then the preview Texture is rotated 180 degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Lock capture orientation to portrait down. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.portraitDown); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Change the device orientation to landscape right. + const DeviceOrientationChangedEvent testEvent = + DeviceOrientationChangedEvent(DeviceOrientation.landscapeRight); + DeviceOrientationManager.deviceOrientationChangedStreamController + .add(testEvent); + await tester.pumpAndSettle(); + + // Verify Texture is rotated by ((90 - 270 * -1 + 360) % 360) - 180 = -180 degrees clockwise = 180 degrees counterclockwise = 180 degrees clockwise. + const int expectedQuarterTurns = _180DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.portraitDown after the preview is built, then the device orientation is changed DeviceOrientation.landscapeRight, then the preview Texture is rotated X degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Lock capture orientation to portrait down. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.portraitDown); + await tester.pumpAndSettle(); + + // Change the device orientation to landscape right. + const DeviceOrientationChangedEvent testEvent = + DeviceOrientationChangedEvent(DeviceOrientation.landscapeRight); + DeviceOrientationManager.deviceOrientationChangedStreamController + .add(testEvent); + await tester.pumpAndSettle(); + + // Verify Texture is rotated by ((90 - 270 * -1 + 360) % 360) - 180 = -180 degrees clockwise = 180 degrees counterclockwise = 180 degrees clockwise. + const int expectedQuarterTurns = _180DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4; + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(clockwiseQuarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.landscapeRight before the preview is built, the device orientation is changed to DeviceOrientation.portraitUp, then the capture orientation is unlocked, then the preview Texture is rotated 270 degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Lock capture orientation to landscape right. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.landscapeRight); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Change the device orientation to portrait up. + const DeviceOrientationChangedEvent testEvent = + DeviceOrientationChangedEvent(DeviceOrientation.portraitUp); + DeviceOrientationManager.deviceOrientationChangedStreamController + .add(testEvent); + await tester.pumpAndSettle(); + + // Unlock capture orientation. + await camera.unlockCaptureOrientation(cameraId); + await tester.pumpAndSettle(); + + // Verify Texture is rotated by ((0 - 270 * -1 + 360) % 360) - 0 = 270 degrees clockwise. + const int expectedQuarterTurns = _270DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(rotatedBox.quarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + + testWidgets( + 'capture orientation is locked to DeviceOrientation.landscapeRight after the preview is built, the device orientation is changed to DeviceOrientation.portraitUp, then the capture orientation is unlocked, then the preview Texture is rotated 270 degrees', + (WidgetTester tester) async { + // Get and create test camera. + final List availableCameras = + await camera.availableCameras(); + expect(availableCameras.length, 1); + await camera.createCameraWithSettings( + availableCameras.first, testMediaSettings); + + // Build and put camera preview in widget tree. + await tester.pumpWidget(camera.buildPreview(cameraId)); + + // Lock capture orientation to landscape right. + await camera.lockCaptureOrientation( + cameraId, DeviceOrientation.landscapeRight); + await tester.pumpAndSettle(); + + // Change the device orientation to portrait up. + const DeviceOrientationChangedEvent testEvent = + DeviceOrientationChangedEvent(DeviceOrientation.portraitUp); + DeviceOrientationManager.deviceOrientationChangedStreamController + .add(testEvent); + await tester.pumpAndSettle(); + + // Unlock capture orientation. + await camera.unlockCaptureOrientation(cameraId); + await tester.pumpAndSettle(); + + // Verify Texture is rotated by ((0 - 270 * -1 + 360) % 360) - 0 = 270 degrees clockwise. + const int expectedQuarterTurns = _270DegreesClockwise; + final RotatedBox rotatedBox = + tester.widget(find.byType(RotatedBox)); + expect(rotatedBox.child, isA()); + expect((rotatedBox.child! as Texture).textureId, cameraId); + expect(rotatedBox.quarterTurns, expectedQuarterTurns, + reason: getExpectedRotationTestFailureReason( + expectedQuarterTurns, rotatedBox.quarterTurns)); + }); + }); + }); +} diff --git a/packages/camera/camera_android_camerax/test/preview_test.dart b/packages/camera/camera_android_camerax/test/preview_test.dart index b3cbc257b0cc..8ba2cee9dc34 100644 --- a/packages/camera/camera_android_camerax/test/preview_test.dart +++ b/packages/camera/camera_android_camerax/test/preview_test.dart @@ -170,5 +170,19 @@ void main() { verify(mockApi.getResolutionInfo(instanceManager.getIdentifier(preview))); }); + + test( + 'surfaceProducerHandlesCropAndRotation makes call to check if Android surface producer automatically corrects camera preview rotation', + () async { + final MockTestPreviewHostApi mockApi = MockTestPreviewHostApi(); + TestPreviewHostApi.setup(mockApi); + final Preview preview = Preview.detached(); + + when(mockApi.surfaceProducerHandlesCropAndRotation()).thenReturn(true); + + expect(await preview.surfaceProducerHandlesCropAndRotation(), true); + + verify(mockApi.surfaceProducerHandlesCropAndRotation()); + }); }); } diff --git a/packages/camera/camera_android_camerax/test/preview_test.mocks.dart b/packages/camera/camera_android_camerax/test/preview_test.mocks.dart index 8976a31245db..d8b7a21e44bb 100644 --- a/packages/camera/camera_android_camerax/test/preview_test.mocks.dart +++ b/packages/camera/camera_android_camerax/test/preview_test.mocks.dart @@ -127,6 +127,15 @@ class MockTestPreviewHostApi extends _i1.Mock ), returnValueForMissingStub: null, ); + + @override + bool surfaceProducerHandlesCropAndRotation() => (super.noSuchMethod( + Invocation.method( + #surfaceProducerHandlesCropAndRotation, + [], + ), + returnValue: false, + ) as bool); } /// A class which mocks [ResolutionSelector]. diff --git a/packages/camera/camera_android_camerax/test/system_services_test.dart b/packages/camera/camera_android_camerax/test/system_services_test.dart index d1725515e2f8..030f9aee5b26 100644 --- a/packages/camera/camera_android_camerax/test/system_services_test.dart +++ b/packages/camera/camera_android_camerax/test/system_services_test.dart @@ -85,16 +85,4 @@ void main() { verify(mockApi.getTempFilePath(testPrefix, testSuffix)); }); }); - - test('isPreviewPreTransformed returns expected answer', () async { - final MockTestSystemServicesHostApi mockApi = - MockTestSystemServicesHostApi(); - TestSystemServicesHostApi.setup(mockApi); - const bool isPreviewPreTransformed = true; - - when(mockApi.isPreviewPreTransformed()).thenReturn(isPreviewPreTransformed); - - expect(await SystemServices.isPreviewPreTransformed(), isTrue); - verify(mockApi.isPreviewPreTransformed()); - }); } diff --git a/packages/camera/camera_android_camerax/test/system_services_test.mocks.dart b/packages/camera/camera_android_camerax/test/system_services_test.mocks.dart index 9abb64c39bb4..ec97625adb94 100644 --- a/packages/camera/camera_android_camerax/test/system_services_test.mocks.dart +++ b/packages/camera/camera_android_camerax/test/system_services_test.mocks.dart @@ -87,13 +87,4 @@ class MockTestSystemServicesHostApi extends _i1.Mock ), ), ) as String); - - @override - bool isPreviewPreTransformed() => (super.noSuchMethod( - Invocation.method( - #isPreviewPreTransformed, - [], - ), - returnValue: false, - ) as bool); } diff --git a/packages/camera/camera_android_camerax/test/test_camerax_library.g.dart b/packages/camera/camera_android_camerax/test/test_camerax_library.g.dart index 32235b878f39..1007d975ebca 100644 --- a/packages/camera/camera_android_camerax/test/test_camerax_library.g.dart +++ b/packages/camera/camera_android_camerax/test/test_camerax_library.g.dart @@ -510,8 +510,6 @@ abstract class TestSystemServicesHostApi { String getTempFilePath(String prefix, String suffix); - bool isPreviewPreTransformed(); - static void setup(TestSystemServicesHostApi? api, {BinaryMessenger? binaryMessenger}) { { @@ -563,24 +561,6 @@ abstract class TestSystemServicesHostApi { }); } } - { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.SystemServicesHostApi.isPreviewPreTransformed', - codec, - binaryMessenger: binaryMessenger); - if (api == null) { - _testBinaryMessengerBinding!.defaultBinaryMessenger - .setMockDecodedMessageHandler(channel, null); - } else { - _testBinaryMessengerBinding!.defaultBinaryMessenger - .setMockDecodedMessageHandler(channel, - (Object? message) async { - // ignore message - final bool output = api.isPreviewPreTransformed(); - return [output]; - }); - } - } } } @@ -722,6 +702,8 @@ abstract class TestPreviewHostApi { void setTargetRotation(int identifier, int rotation); + bool surfaceProducerHandlesCropAndRotation(); + static void setup(TestPreviewHostApi? api, {BinaryMessenger? binaryMessenger}) { { @@ -835,6 +817,24 @@ abstract class TestPreviewHostApi { }); } } + { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.PreviewHostApi.surfaceProducerHandlesCropAndRotation', + codec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(channel, + (Object? message) async { + // ignore message + final bool output = api.surfaceProducerHandlesCropAndRotation(); + return [output]; + }); + } + } } }