Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: correct android preview orientation #1329

Merged
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ group 'dev.steenbakker.mobile_scanner'
version '1.0-SNAPSHOT'

buildscript {
ext.kotlin_version = '1.7.22'
ext.kotlin_version = '1.8.0'
repositories {
google()
mavenCentral()
Expand All @@ -29,7 +29,7 @@ android {
namespace 'dev.steenbakker.mobile_scanner'
}

compileSdk 34
compileSdk 35

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ class MobileScanner(
}
val paint = Paint().apply { colorFilter = ColorMatrixColorFilter(colorMatrix) }

val invertedBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, bitmap.config)
val invertedBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, bitmap.config!!)
Copy link
Collaborator Author

@navaronbracke navaronbracke Feb 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a change that wasn't surfaced when using Kotlin 1.7, hence the bump to Kotlin 1.8. For some reason the compiler now checked for null specifically for this case (and probably others but I didn't see any changes). The bitmap config is provided when creating the original bitmap

val canvas = Canvas(invertedBitmap)
canvas.drawBitmap(bitmap, 0f, 0f, paint)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ class MobileScannerHandler(
"textureId" to it.id,
"size" to mapOf("width" to it.width, "height" to it.height),
"naturalDeviceOrientation" to it.naturalDeviceOrientation,
"isPreviewPreTransformed" to it.isPreviewPreTransformed,
"handlesCropAndRotation" to it.handlesCropAndRotation,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to match the actual name from the SurfaceProducer API.

"sensorOrientation" to it.sensorOrientation,
"currentTorchState" to it.currentTorchState,
"numberOfCameras" to it.numberOfCameras,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class MobileScannerStartParameters(
val height: Double,
val naturalDeviceOrientation: String,
val sensorOrientation: Int,
val isPreviewPreTransformed: Boolean,
val handlesCropAndRotation: Boolean,
val currentTorchState: Int,
val id: Long,
val numberOfCameras: Int,
Expand Down
2 changes: 2 additions & 0 deletions example/android/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks

**/.cxx
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to add this line, since Flutter 3.29 would add a .cxx folder when compiling on Android. New flutter created templates did include a change like this, too.

5 changes: 3 additions & 2 deletions example/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ if (flutterVersionName == null) {

android {
namespace "dev.steenbakker.mobile_scanner_example"
compileSdk 34
compileSdk 35

compileOptions {
sourceCompatibility JavaVersion.VERSION_17
Expand All @@ -43,7 +43,8 @@ android {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "dev.steenbakker.mobile_scanner_example"
minSdkVersion 24
targetSdkVersion 34
targetSdkVersion 35
ndkVersion flutter.ndkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ dependencies:
flutter:
sdk: flutter

image_picker: ^1.0.4
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bumping image picker (and updating the pubspec.lock locally) fixed a build issue with the removal of the Android v1 embedding.

image_picker: ^1.1.2
mobile_scanner:
# When depending on this package from a real application you should use:
# mobile_scanner: ^x.y.z
Expand Down
152 changes: 20 additions & 132 deletions lib/src/method_channel/android_surface_producer_delegate.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import 'dart:async';

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:mobile_scanner/src/enums/camera_facing.dart';
import 'package:mobile_scanner/src/enums/mobile_scanner_error_code.dart';
import 'package:mobile_scanner/src/mobile_scanner_exception.dart';
Expand All @@ -12,10 +9,10 @@ import 'package:mobile_scanner/src/utils/parse_device_orientation_extension.dart
class AndroidSurfaceProducerDelegate {
/// Construct a new [AndroidSurfaceProducerDelegate].
AndroidSurfaceProducerDelegate({
required this.cameraIsFrontFacing,
required this.isPreviewPreTransformed,
required this.naturalOrientation,
required this.sensorOrientation,
required this.cameraFacingDirection,
required this.handlesCropAndRotation,
required this.initialDeviceOrientation,
required this.sensorOrientationDegrees,
});

/// Construct a new [AndroidSurfaceProducerDelegate]
Expand All @@ -28,18 +25,19 @@ class AndroidSurfaceProducerDelegate {
) {
if (config
case {
'isPreviewPreTransformed': final bool isPreviewPreTransformed,
'handlesCropAndRotation': final bool handlesCropAndRotation,
'naturalDeviceOrientation': final String naturalDeviceOrientation,
'sensorOrientation': final int sensorOrientation
'sensorOrientation': final int sensorOrientation,
}) {
final DeviceOrientation naturalOrientation =
naturalDeviceOrientation.parseDeviceOrientation();

return AndroidSurfaceProducerDelegate(
cameraIsFrontFacing: cameraDirection == CameraFacing.front,
isPreviewPreTransformed: isPreviewPreTransformed,
naturalOrientation: naturalOrientation,
sensorOrientation: sensorOrientation,
cameraFacingDirection: cameraDirection,
handlesCropAndRotation: handlesCropAndRotation,
initialDeviceOrientation: naturalOrientation,
// FIXME: This is bad, will cause a flash/frame in the wrong rotation if started in another rotation.
sensorOrientationDegrees: sensorOrientation.toDouble(),
);
}

Expand All @@ -51,127 +49,17 @@ class AndroidSurfaceProducerDelegate {
);
}

/// The rotation degrees corresponding to each device orientation.
static const Map<DeviceOrientation, int> _degreesForDeviceOrientation =
<DeviceOrientation, int>{
DeviceOrientation.portraitUp: 0,
DeviceOrientation.landscapeRight: 90,
DeviceOrientation.portraitDown: 180,
DeviceOrientation.landscapeLeft: 270,
};

// TODO: remove this flag once the rotation correction functions correctly on Impeller.
// See https://github.com/juliansteenbakker/mobile_scanner/pull/1283#discussion_r1927798329
static const bool _rotationCorrectionEnabled = false;

/// The subscription that listens to device orientation changes.
StreamSubscription<Object?>? _deviceOrientationSubscription;

/// Whether the current camera is a front facing camera.
///
/// This is used to determine whether the orientation correction
/// should apply an additional correction for front facing cameras.
final bool cameraIsFrontFacing;

/// The current orientation of the device.
///
/// When the orientation changes this field is updated by notifications from
/// the [_deviceOrientationSubscription].
DeviceOrientation? currentDeviceOrientation;

/// Whether the camera preview is pre-transformed,
/// and thus does not need an orientation correction.
final bool isPreviewPreTransformed;
/// The facing direction of the active camera.
final CameraFacing cameraFacingDirection;

/// The initial orientation of the device, when the camera was started.
/// Whether the underlying surface producer handles crop and rotation.
///
/// The camera preview will use this orientation as the natural orientation
/// to correct its rotation with respect to, if necessary.
final DeviceOrientation naturalOrientation;

/// The sensor orientation of the current camera, in degrees.
final int sensorOrientation;

/// Apply a rotation correction to the given [texture] widget.
Widget applyRotationCorrection(Widget texture) {
if (!_rotationCorrectionEnabled) {
return texture;
}

int naturalDeviceOrientationDegrees =
_degreesForDeviceOrientation[naturalOrientation]!;

if (isPreviewPreTransformed) {
// If the camera preview is backed by a SurfaceTexture, the transformation
// needed to correctly rotate the preview has already been applied.
//
// However, the camera preview rotation may need to be corrected if the
// device is naturally landscape-oriented.
if (naturalOrientation == DeviceOrientation.landscapeLeft ||
naturalOrientation == DeviceOrientation.landscapeRight) {
final int quarterTurns = (-naturalDeviceOrientationDegrees + 360) ~/ 4;

return RotatedBox(
quarterTurns: quarterTurns,
child: texture,
);
}

return texture;
}

// If the camera preview is not backed by a SurfaceTexture,
// the camera preview rotation needs to be manually applied,
// while also taking into account devices that are naturally landscape-oriented.
final int signForCameraDirection = cameraIsFrontFacing ? 1 : -1;

// For front-facing cameras, the preview is rotated counterclockwise,
// so determine the rotation needed to correct the camera preview with
// respect to the natural orientation of the device, based on the inverse of
// of the natural orientation.
if (signForCameraDirection == 1 &&
(currentDeviceOrientation == DeviceOrientation.landscapeLeft ||
currentDeviceOrientation == DeviceOrientation.landscapeRight)) {
naturalDeviceOrientationDegrees += 180;
}
/// If this is false, the preview needs to be manually rotated.
final bool handlesCropAndRotation;

// See https://developer.android.com/media/camera/camera2/camera-preview#orientation_calculation
final double rotation = (sensorOrientation +
naturalDeviceOrientationDegrees * signForCameraDirection +
360) %
360;
/// The initial device orientation when this [AndroidSurfaceProducerDelegate] is created.
final DeviceOrientation initialDeviceOrientation;

int quarterTurnsToCorrectPreview = rotation ~/ 90;

// Correct the camera preview rotation for devices that are naturally landscape-oriented.
if (naturalOrientation == DeviceOrientation.landscapeLeft ||
naturalOrientation == DeviceOrientation.landscapeRight) {
quarterTurnsToCorrectPreview +=
(-naturalDeviceOrientationDegrees + 360) ~/ 4;
}

return RotatedBox(
quarterTurns: quarterTurnsToCorrectPreview,
child: texture,
);
}

/// Start listening to device orientation changes,
/// which are provided by the given [stream].
void startListeningToDeviceOrientation(Stream<DeviceOrientation> stream) {
if (!_rotationCorrectionEnabled) {
return;
}

_deviceOrientationSubscription ??=
stream.listen((DeviceOrientation newOrientation) {
currentDeviceOrientation = newOrientation;
});
}

/// Dispose of this delegate.
void dispose() {
_deviceOrientationSubscription?.cancel();
_deviceOrientationSubscription = null;
}
/// The orientation of the camera sensor on the device, in degrees.
final double sensorOrientationDegrees;
}
20 changes: 14 additions & 6 deletions lib/src/method_channel/mobile_scanner_method_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:mobile_scanner/src/enums/mobile_scanner_authorization_state.dart
import 'package:mobile_scanner/src/enums/mobile_scanner_error_code.dart';
import 'package:mobile_scanner/src/enums/torch_state.dart';
import 'package:mobile_scanner/src/method_channel/android_surface_producer_delegate.dart';
import 'package:mobile_scanner/src/method_channel/rotated_preview.dart';
import 'package:mobile_scanner/src/mobile_scanner_exception.dart';
import 'package:mobile_scanner/src/mobile_scanner_platform_interface.dart';
import 'package:mobile_scanner/src/mobile_scanner_view_attributes.dart';
Expand Down Expand Up @@ -239,9 +240,20 @@ class MethodChannelMobileScanner extends MobileScannerPlatform {

final Widget texture = Texture(textureId: _textureId!);

// If the preview needs manual orientation corrections,
// correct the preview orientation based on the currently reported device orientation.
// On Android, the underlying device orientation stream will emit the current orientation
// when the first listener is attached.
if (_surfaceProducerDelegate
case final AndroidSurfaceProducerDelegate delegate) {
return delegate.applyRotationCorrection(texture);
case final AndroidSurfaceProducerDelegate delegate
when !delegate.handlesCropAndRotation) {
return RotatedPreview.fromCameraDirection(
delegate.cameraFacingDirection,
deviceOrientationStream: deviceOrientationChangedStream,
initialDeviceOrientation: delegate.initialDeviceOrientation,
sensorOrientationDegrees: delegate.sensorOrientationDegrees,
child: texture,
);
}

return texture;
Expand Down Expand Up @@ -319,9 +331,6 @@ class MethodChannelMobileScanner extends MobileScannerPlatform {
startResult,
cameraDirection,
);
_surfaceProducerDelegate?.startListeningToDeviceOrientation(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now handled by a separate widget.

deviceOrientationChangedStream,
);
}

final int? numberOfCameras = startResult['numberOfCameras'] as int?;
Expand Down Expand Up @@ -356,7 +365,6 @@ class MethodChannelMobileScanner extends MobileScannerPlatform {

_textureId = null;
_pausing = false;
_surfaceProducerDelegate?.dispose();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disposal of the "delegate" is no longer needed, since the stream subscription is moved to a widget.

_surfaceProducerDelegate = null;

await methodChannel.invokeMethod<void>('stop');
Expand Down
Loading
Loading