[camera_android_camerax] Fix 90°-off preview rotation (#8629)
Fixes 90°-off preview rotation without locked capture orientation. Part of https://github.com/flutter/flutter/issues/154241.
The major changes in this PR:
1. Ensures the preview widget is rebuilt when a new device orientation is detected by changing the `buildPreview` to return a `StatefulWidget` instead of a `Texture` that wouldn't pick up changes in device orientation.
2. Uses [`handlesCropAndRotation`](https://api.flutter.dev/javadoc/io/flutter/view/TextureRegistry.SurfaceProducer.html#handlesCropAndRotation()) to detect if the preview is already correctly rotated or not. This API was added exactly for that purpose.
3. Corrects the preview when needed using https://developer.android.com/media/camera/camera2/camera-preview#orientation_calculation (also subtracts rotation applied in `CameraPreview` widget).
See https://github.com/flutter/flutter/issues/154241#issuecomment-2655098620 for slightly more context if desired.
diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart
index bcec28f..73359e4 100644
--- a/packages/camera/camera/test/camera_preview_test.dart
+++ b/packages/camera/camera/test/camera_preview_test.dart
@@ -140,7 +140,7 @@
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,18 @@
final FakeController controller = FakeController();
addTearDown(controller.dispose);
+
controller.value = controller.value.copyWith(
- isInitialized: true,
- isRecordingVideo: true,
- deviceOrientation: DeviceOrientation.portraitUp,
- lockedCaptureOrientation:
- const Optional<DeviceOrientation>.fromNullable(
- DeviceOrientation.landscapeRight),
- recordingOrientation: const Optional<DeviceOrientation>.fromNullable(
- DeviceOrientation.landscapeLeft),
- previewSize: const Size(480, 640),
- );
+ isInitialized: true,
+ isRecordingVideo: true,
+ deviceOrientation: DeviceOrientation.portraitDown,
+ lockedCaptureOrientation:
+ const Optional<DeviceOrientation>.fromNullable(
+ DeviceOrientation.landscapeRight),
+ recordingOrientation: const Optional<DeviceOrientation>.fromNullable(
+ DeviceOrientation.portraitUp),
+ previewSize: const Size(480, 640) // preview size irrelevant to test
+ );
await tester.pumpWidget(
Directionality(
@@ -170,13 +171,13 @@
final RotatedBox rotatedBox =
tester.widget<RotatedBox>(find.byType(RotatedBox));
- expect(rotatedBox.quarterTurns, 3);
+ expect(rotatedBox.quarterTurns, 0);
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
- 'when orientation locked rotatedBox should turn according to locked orientation',
+ 'when recording in DeviceOrientaiton.landscapeRight, rotatedBox should be rotated by one clockwise quarter turn',
(
WidgetTester tester,
) async {
@@ -184,16 +185,18 @@
final FakeController controller = FakeController();
addTearDown(controller.dispose);
+
controller.value = controller.value.copyWith(
- isInitialized: true,
- deviceOrientation: DeviceOrientation.portraitUp,
- lockedCaptureOrientation:
- const Optional<DeviceOrientation>.fromNullable(
- DeviceOrientation.landscapeRight),
- recordingOrientation: const Optional<DeviceOrientation>.fromNullable(
- DeviceOrientation.landscapeLeft),
- previewSize: const Size(480, 640),
- );
+ isInitialized: true,
+ isRecordingVideo: true,
+ deviceOrientation: DeviceOrientation.portraitUp,
+ lockedCaptureOrientation:
+ const Optional<DeviceOrientation>.fromNullable(
+ DeviceOrientation.landscapeLeft),
+ recordingOrientation: const Optional<DeviceOrientation>.fromNullable(
+ DeviceOrientation.landscapeRight),
+ previewSize: const Size(480, 640) // preview size irrelevant to test
+ );
await tester.pumpWidget(
Directionality(
@@ -211,7 +214,7 @@
});
testWidgets(
- 'when not locked and not recording rotatedBox should turn according to device orientation',
+ 'when recording in DeviceOrientaiton.portraitDown, rotatedBox should be rotated by two clockwise quarter turns',
(
WidgetTester tester,
) async {
@@ -219,13 +222,91 @@
final FakeController controller = FakeController();
addTearDown(controller.dispose);
+
controller.value = controller.value.copyWith(
- isInitialized: true,
- deviceOrientation: DeviceOrientation.portraitUp,
- recordingOrientation: const Optional<DeviceOrientation>.fromNullable(
- DeviceOrientation.landscapeLeft),
- previewSize: const Size(480, 640),
+ isInitialized: true,
+ isRecordingVideo: true,
+ deviceOrientation: DeviceOrientation.portraitUp,
+ lockedCaptureOrientation:
+ const Optional<DeviceOrientation>.fromNullable(
+ DeviceOrientation.landscapeRight),
+ recordingOrientation: const Optional<DeviceOrientation>.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<RotatedBox>(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<DeviceOrientation>.fromNullable(
+ DeviceOrientation.landscapeRight),
+ recordingOrientation: const Optional<DeviceOrientation>.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<RotatedBox>(find.byType(RotatedBox));
+ expect(rotatedBox.quarterTurns, 3);
+
+ debugDefaultTargetPlatformOverride = null;
+ });
+
+ testWidgets(
+ 'when orientation locked in DeviceOrientaiton.portaitUp, 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.portraitDown,
+ lockedCaptureOrientation:
+ const Optional<DeviceOrientation>.fromNullable(
+ DeviceOrientation.portraitUp),
+ recordingOrientation: const Optional<DeviceOrientation>.fromNullable(
+ DeviceOrientation.landscapeLeft),
+ previewSize: const Size(480, 640) // preview size irrelevant to test
+ );
await tester.pumpWidget(
Directionality(
@@ -241,6 +322,246 @@
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<DeviceOrientation>.fromNullable(
+ DeviceOrientation.landscapeRight),
+ recordingOrientation: const Optional<DeviceOrientation>.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<RotatedBox>(find.byType(RotatedBox));
+ expect(rotatedBox.quarterTurns, 1);
+
+ debugDefaultTargetPlatformOverride = null;
+ });
+
+ testWidgets(
+ 'when orientation locked 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,
+ deviceOrientation: DeviceOrientation.portraitUp,
+ lockedCaptureOrientation:
+ const Optional<DeviceOrientation>.fromNullable(
+ DeviceOrientation.portraitDown),
+ recordingOrientation: const Optional<DeviceOrientation>.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<RotatedBox>(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<DeviceOrientation>.fromNullable(
+ DeviceOrientation.landscapeRight),
+ recordingOrientation: const Optional<DeviceOrientation>.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<RotatedBox>(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<DeviceOrientation>.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<RotatedBox>(find.byType(RotatedBox));
+ expect(rotatedBox.quarterTurns, 0);
+
+ 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<DeviceOrientation>.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<RotatedBox>(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<DeviceOrientation>.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<RotatedBox>(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<DeviceOrientation>.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<RotatedBox>(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 @@
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 7e190cb..0328dff 100644
--- a/packages/camera/camera_android_camerax/CHANGELOG.md
+++ b/packages/camera/camera_android_camerax/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.6.14
+
+* Fixes incorrect camera preview rotation.
+
## 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 7adf025..93f65cb 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 @@
@NonNull
String getTempFilePath(@NonNull String prefix, @NonNull String suffix);
- @NonNull
- Boolean isPreviewPreTransformed();
-
/** The codec used by SystemServicesHostApi. */
static @NonNull MessageCodec<Object> getCodec() {
return SystemServicesHostApiCodec.INSTANCE;
@@ -1511,29 +1508,6 @@
channel.setMessageHandler(null);
}
}
- {
- BasicMessageChannel<Object> channel =
- new BasicMessageChannel<>(
- binaryMessenger,
- "dev.flutter.pigeon.SystemServicesHostApi.isPreviewPreTransformed",
- getCodec());
- if (api != null) {
- channel.setMessageHandler(
- (message, reply) -> {
- ArrayList<Object> wrapped = new ArrayList<Object>();
- try {
- Boolean output = api.isPreviewPreTransformed();
- wrapped.add(0, output);
- } catch (Throwable exception) {
- ArrayList<Object> 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 setTargetRotation(@NonNull Long identifier, @NonNull Long rotation);
+ @NonNull
+ Boolean surfaceProducerHandlesCropAndRotation();
+
/** The codec used by PreviewHostApi. */
static @NonNull MessageCodec<Object> getCodec() {
return PreviewHostApiCodec.INSTANCE;
@@ -1898,6 +1875,29 @@
channel.setMessageHandler(null);
}
}
+ {
+ BasicMessageChannel<Object> channel =
+ new BasicMessageChannel<>(
+ binaryMessenger,
+ "dev.flutter.pigeon.PreviewHostApi.surfaceProducerHandlesCropAndRotation",
+ getCodec());
+ if (api != null) {
+ channel.setMessageHandler(
+ (message, reply) -> {
+ ArrayList<Object> wrapped = new ArrayList<Object>();
+ try {
+ Boolean output = api.surfaceProducerHandlesCropAndRotation();
+ wrapped.add(0, output);
+ } catch (Throwable exception) {
+ ArrayList<Object> 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 92202dd..ffec50a 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 @@
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 @@
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 138e925..d058d62 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 @@
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.
- *
- * <p>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 6fd43c2..bc2577b 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 @@
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 52d02e6..fdfc1b2 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 @@
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 31cde2f..8e61727 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 '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';
@@ -238,12 +239,18 @@
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;
- /// Subscription for listening to changes in device orientation.
- StreamSubscription<DeviceOrientationChangedEvent>?
- _subscriptionForDeviceOrientationChanges;
+ /// 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;
+
+ /// The initial orientation of the device when the camera is created.
+ late DeviceOrientation _initialDeviceOrientation;
/// Returns list of all available cameras and their descriptions.
@override
@@ -380,10 +387,10 @@
// 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 +451,6 @@
await liveCameraState?.removeObservers();
processCameraProvider?.unbindAll();
await imageAnalysis?.clearAnalyzer();
- await _subscriptionForDeviceOrientationChanges?.cancel();
}
/// The camera has been initialized.
@@ -836,7 +842,30 @@
);
}
- return Texture(textureId: cameraId);
+ final Widget preview = Texture(textureId: cameraId);
+
+ if (_handlesCropAndRotation) {
+ return preview;
+ }
+
+ final Stream<DeviceOrientation> deviceOrientationStream =
+ onDeviceOrientationChanged()
+ .map((DeviceOrientationChangedEvent e) => e.orientation);
+ if (cameraIsFrontFacing) {
+ return RotatedPreview.frontFacingCamera(
+ _initialDeviceOrientation,
+ deviceOrientationStream,
+ sensorOrientationDegrees: sensorOrientationDegrees,
+ child: preview,
+ );
+ } else {
+ return RotatedPreview.backFacingCamera(
+ _initialDeviceOrientation,
+ deviceOrientationStream,
+ sensorOrientationDegrees: sensorOrientationDegrees,
+ child: preview,
+ );
+ }
}
/// Captures an image and returns the file where it was saved.
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 a9461ea..e830a28 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 @@
return (replyList[0] as String?)!;
}
}
-
- Future<bool> isPreviewPreTransformed() async {
- final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
- 'dev.flutter.pigeon.SystemServicesHostApi.isPreviewPreTransformed',
- codec,
- binaryMessenger: _binaryMessenger);
- final List<Object?>? replyList = await channel.send(null) as List<Object?>?;
- 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 @@
return;
}
}
+
+ Future<bool> surfaceProducerHandlesCropAndRotation() async {
+ final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
+ 'dev.flutter.pigeon.PreviewHostApi.surfaceProducerHandlesCropAndRotation',
+ codec,
+ binaryMessenger: _binaryMessenger);
+ final List<Object?>? replyList = await channel.send(null) as List<Object?>?;
+ 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 feae868..c5d92d2 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 @@
this.getCamera2CameraInfo = _getCamera2CameraInfo,
this.getUiOrientation = _getUiOrientation,
this.getSensorOrientation = _getSensorOrientation,
+ this.previewSurfaceProducerHandlesCropAndRotation =
+ _previewSurfaceProducerHandlesCropAndRotation,
});
/// Returns a [ProcessCameraProvider] instance.
@@ -200,6 +202,11 @@
Future<int> Function(Camera2CameraInfo camera2CameraInfo)
getSensorOrientation;
+ /// Returns whether or not the preview's surface producer handles correctly
+ /// rotating the camera preview automatically.
+ Future<bool> Function(Preview preview)
+ previewSurfaceProducerHandlesCropAndRotation;
+
static Future<ProcessCameraProvider> _getProcessCameraProvider() {
return ProcessCameraProvider.getInstance();
}
@@ -355,4 +362,9 @@
Camera2CameraInfo camera2CameraInfo) async {
return camera2CameraInfo.getSensorOrientation();
}
+
+ static Future<bool> _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 8990313..76da65e 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 @@
Future<ResolutionInfo> 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<bool> surfaceProducerHandlesCropAndRotation() {
+ return _api.surfaceProducerHandlesCropAndRotationFromInstance();
+ }
}
/// Host API implementation of [Preview].
@@ -156,4 +162,10 @@
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<bool> 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 0000000..849de39
--- /dev/null
+++ b/packages/camera/camera_android_camerax/lib/src/rotated_preview.dart
@@ -0,0 +1,119 @@
+// 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.
+ const RotatedPreview.frontFacingCamera(
+ this.initialDeviceOrientation,
+ this.deviceOrientation, {
+ required this.sensorOrientationDegrees,
+ required this.child,
+ super.key,
+ }) : facingSign = 1;
+
+ /// Creates [RotatedPreview] that will correct the preview
+ /// rotation assuming that the back camera is being used.
+ const RotatedPreview.backFacingCamera(
+ this.initialDeviceOrientation,
+ this.deviceOrientation, {
+ required this.child,
+ required this.sensorOrientationDegrees,
+ super.key,
+ }) : facingSign = -1;
+
+ /// 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> deviceOrientation;
+
+ /// The orienation of the camera sensor in degrees.
+ final double sensorOrientationDegrees;
+
+ /// The camera preview [Widget] to rotate.
+ final Widget child;
+
+ /// 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;
+
+ @override
+ State<StatefulWidget> createState() => _RotatedPreviewState();
+}
+
+final class _RotatedPreviewState extends State<RotatedPreview> {
+ late DeviceOrientation deviceOrientation;
+ late StreamSubscription<DeviceOrientation> 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 _computeRotationDegrees(
+ DeviceOrientation orientation, {
+ required double sensorOrientationDegrees,
+ required int sign,
+ }) {
+ final double deviceOrientationDegrees = switch (orientation) {
+ DeviceOrientation.portraitUp => 0,
+ DeviceOrientation.landscapeRight => 90,
+ DeviceOrientation.portraitDown => 180,
+ DeviceOrientation.landscapeLeft => 270,
+ };
+
+ // 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).
+ rotationDegrees -= deviceOrientationDegrees;
+
+ return rotationDegrees;
+ }
+
+ @override
+ void dispose() {
+ deviceOrientationSubscription.cancel();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final double rotationDegrees = _computeRotationDegrees(
+ deviceOrientation,
+ sensorOrientationDegrees: widget.sensorOrientationDegrees,
+ sign: widget.facingSign,
+ );
+ 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 5f59bd2..b75a1cb 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 @@
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<bool> 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 949830d..a6e4ede 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 @@
CameraPermissionsErrorData? requestCameraPermissions(bool enableAudio);
String getTempFilePath(String prefix, String suffix);
-
- bool isPreviewPreTransformed();
}
@FlutterApi()
@@ -297,6 +295,8 @@
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 2aa8901..d8fa612 100644
--- a/packages/camera/camera_android_camerax/pubspec.yaml
+++ b/packages/camera/camera_android_camerax/pubspec.yaml
@@ -2,7 +2,7 @@
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.14
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 89e201b..2e4a5c8 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_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 @@
Future<Camera2CameraInfo>.value(MockCamera2CameraInfo()),
getUiOrientation: () =>
Future<DeviceOrientation>.value(DeviceOrientation.portraitUp),
+ previewSurfaceProducerHandlesCropAndRotation: (_) =>
+ Future<bool>.value(false),
);
/// CameraXProxy for testing exposure and focus related controls.
@@ -919,7 +921,9 @@
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 @@
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 @@
getProxyForTestingResolutionPreset(mockProcessCameraProvider);
camera.proxy.getSensorOrientation =
(_) async => Future<int>.value(testSensorOrientation);
+ camera.proxy.previewSurfaceProducerHandlesCropAndRotation =
+ (_) async => Future<bool>.value(testHandlesCropAndRotation);
camera.proxy.getUiOrientation =
() async => Future<DeviceOrientation>.value(testUiOrientation);
@@ -960,7 +967,7 @@
await camera.createCamera(testCameraDescription, testResolutionPreset,
enableAudio: enableAudio);
- expect(camera.sensorOrientation, testSensorOrientation);
+ expect(camera.sensorOrientationDegrees, testSensorOrientation);
});
test(
@@ -1296,6 +1303,8 @@
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 @@
() => camera.buildPreview(cameraId), throwsA(isA<CameraException>()));
});
- 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',
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 316b127..fc838a4 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 @@
),
)),
) as _i17.Future<_i10.ResolutionInfo>);
+
+ @override
+ _i17.Future<bool> surfaceProducerHandlesCropAndRotation() =>
+ (super.noSuchMethod(
+ Invocation.method(
+ #surfaceProducerHandlesCropAndRotation,
+ [],
+ ),
+ returnValue: _i17.Future<bool>.value(false),
+ returnValueForMissingStub: _i17.Future<bool>.value(false),
+ ) as _i17.Future<bool>);
}
/// A class which mocks [ProcessCameraProvider].
@@ -1410,16 +1421,6 @@
),
),
) 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 0000000..e06d16c
--- /dev/null
+++ b/packages/camera/camera_android_camerax/test/preview_rotation_test.dart
@@ -0,0 +1,637 @@
+// 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 _90DegreesClockwise = 1;
+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>[mockCameraInfo]);
+ when(mockCameraSelector.filter(<MockCameraInfo>[mockCameraInfo]))
+ .thenAnswer((_) async => <MockCameraInfo>[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<DeviceOrientation> Function() getUiOrientation}) =>
+ CameraXProxy(
+ getProcessCameraProvider: () async => mockProcessCameraProvider,
+ createCameraSelector: createCameraSelector,
+ previewSurfaceProducerHandlesCropAndRotation: (_) =>
+ Future<bool>.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<void>.value(),
+ startListeningForDeviceOrientationChange: (_, __) {},
+ setPreviewSurfaceProvider: (_) => Future<int>.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.';
+
+ 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<DeviceOrientation>.value(DeviceOrientation.landscapeLeft));
+
+ // Get and create test camera.
+ final List<CameraDescription> 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<Texture>(find.byType(Texture));
+ expect(texture.textureId, cameraId);
+
+ // Verify RotatedBox was not built and thus, the Texture is not rotated.
+ expect(() => tester.widget<RotatedBox>(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<DeviceOrientation>.value(DeviceOrientation.portraitUp));
+
+ // Get and create test front camera.
+ final List<CameraDescription> 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<RotatedBox>(find.byType(RotatedBox));
+ expect(rotatedBox.child, isA<Texture>());
+ 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<DeviceOrientation>.value(
+ DeviceOrientation.landscapeRight));
+
+ // Get and create test front camera.
+ final List<CameraDescription> 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<RotatedBox>(find.byType(RotatedBox));
+ expect(rotatedBox.child, isA<Texture>());
+ 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<DeviceOrientation>.value(
+ DeviceOrientation.portraitDown));
+
+ // Get and create test front camera.
+ final List<CameraDescription> 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<RotatedBox>(find.byType(RotatedBox));
+ final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4;
+ expect(rotatedBox.child, isA<Texture>());
+ 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<DeviceOrientation>.value(
+ DeviceOrientation.landscapeLeft));
+
+ // Get and create test front camera.
+ final List<CameraDescription> 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<RotatedBox>(find.byType(RotatedBox));
+ final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4;
+ expect(rotatedBox.child, isA<Texture>());
+ 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<DeviceOrientation>.value(
+ DeviceOrientation.landscapeLeft));
+
+ // Get and create test front camera.
+ final List<CameraDescription> 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<DeviceOrientation, int> expectedRotationPerDeviceOrientation =
+ <DeviceOrientation, int>{
+ 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<RotatedBox>(find.byType(RotatedBox));
+ final int clockwiseQuarterTurns = rotatedBox.quarterTurns < 0
+ ? rotatedBox.quarterTurns + 4
+ : rotatedBox.quarterTurns;
+ expect(rotatedBox.child, isA<Texture>());
+ 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.');
+ }
+
+ await DeviceOrientationManager.deviceOrientationChangedStreamController
+ .close();
+ });
+
+ // 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<DeviceOrientation>.value(testInitialDeviceOrientation));
+
+ // Get and create test back camera.
+ final List<CameraDescription> 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<RotatedBox>(find.byType(RotatedBox));
+ final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4;
+ expect(rotatedBox.child, isA<Texture>());
+ 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<DeviceOrientation>.value(testInitialDeviceOrientation));
+
+ // Get and create test back camera.
+ final List<CameraDescription> 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<RotatedBox>(find.byType(RotatedBox));
+ final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4;
+ expect(rotatedBox.child, isA<Texture>());
+ 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<DeviceOrientation>.value(testInitialDeviceOrientation));
+
+ // Get and create test camera.
+ final List<CameraDescription> 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<RotatedBox>(find.byType(RotatedBox));
+ final int clockwiseQuarterTurns = rotatedBox.quarterTurns + 4;
+ expect(rotatedBox.child, isA<Texture>());
+ 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)
+ proxyCreateCameraSelectorForFrontCamera =
+ createCameraSelectorForBackCamera(mockBackCameraSelector);
+ camera.proxy = getProxyForCreatingTestCamera(
+ mockProcessCameraProvider: mockProcessCameraProvider,
+ createCameraSelector: proxyCreateCameraSelectorForFrontCamera,
+ handlesCropAndRotation: false,
+ getUiOrientation: () =>
+ Future<DeviceOrientation>.value(testInitialDeviceOrientation));
+
+ // Get and create test camera.
+ final List<CameraDescription> 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<RotatedBox>(find.byType(RotatedBox));
+ expect(rotatedBox.child, isA<Texture>());
+ 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 f9b5da0..4800b04 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 @@
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 8976a31..d8b7a21 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 @@
),
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 d172551..030f9ae 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 @@
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 9abb64c..ec97625 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 @@
),
),
) 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 32235b8..1007d97 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 @@
String getTempFilePath(String prefix, String suffix);
- bool isPreviewPreTransformed();
-
static void setup(TestSystemServicesHostApi? api,
{BinaryMessenger? binaryMessenger}) {
{
@@ -563,24 +561,6 @@
});
}
}
- {
- final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
- 'dev.flutter.pigeon.SystemServicesHostApi.isPreviewPreTransformed',
- codec,
- binaryMessenger: binaryMessenger);
- if (api == null) {
- _testBinaryMessengerBinding!.defaultBinaryMessenger
- .setMockDecodedMessageHandler<Object?>(channel, null);
- } else {
- _testBinaryMessengerBinding!.defaultBinaryMessenger
- .setMockDecodedMessageHandler<Object?>(channel,
- (Object? message) async {
- // ignore message
- final bool output = api.isPreviewPreTransformed();
- return <Object?>[output];
- });
- }
- }
}
}
@@ -722,6 +702,8 @@
void setTargetRotation(int identifier, int rotation);
+ bool surfaceProducerHandlesCropAndRotation();
+
static void setup(TestPreviewHostApi? api,
{BinaryMessenger? binaryMessenger}) {
{
@@ -835,6 +817,24 @@
});
}
}
+ {
+ final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
+ 'dev.flutter.pigeon.PreviewHostApi.surfaceProducerHandlesCropAndRotation',
+ codec,
+ binaryMessenger: binaryMessenger);
+ if (api == null) {
+ _testBinaryMessengerBinding!.defaultBinaryMessenger
+ .setMockDecodedMessageHandler<Object?>(channel, null);
+ } else {
+ _testBinaryMessengerBinding!.defaultBinaryMessenger
+ .setMockDecodedMessageHandler<Object?>(channel,
+ (Object? message) async {
+ // ignore message
+ final bool output = api.surfaceProducerHandlesCropAndRotation();
+ return <Object?>[output];
+ });
+ }
+ }
}
}