Skip to content

Commit 4aaf0b7

Browse files
committed
fix issues with image orientation
1 parent 65117ac commit 4aaf0b7

1 file changed

Lines changed: 113 additions & 11 deletions

File tree

src/keepass2android-app/CapturePhotoActivity.cs

Lines changed: 113 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -203,24 +203,33 @@ private void TakePhoto()
203203
{
204204
var btnCapture = FindViewById<ImageButton>(Resource.Id.btn_capture)!;
205205
btnCapture.Enabled = false; // prevent double-tap
206-
_imageCapture?.TakePicture(new MainThreadExecutor(), new InMemoryCaptureCallback(this));
206+
207+
// Snapshot the current display rotation so we can correct for it during
208+
// encoding. CameraX 1.4.x always uses ROTATION_0 as its internal target
209+
// rotation (because this activity never recreates), so its EXIF orientation
210+
// tag is written for ROTATION_0. We apply the delta between the actual
211+
// display rotation and ROTATION_0 during post-processing to ensure both
212+
// portrait and landscape photos come out correctly oriented.
213+
#pragma warning disable CS0618 // DefaultDisplay deprecated in API 30
214+
int displayRotation = (int)(WindowManager!.DefaultDisplay!.Rotation);
215+
#pragma warning restore CS0618
216+
_imageCapture?.TakePicture(new MainThreadExecutor(), new InMemoryCaptureCallback(this, displayRotation));
207217
}
208218

209219
/// <summary>
210-
/// Called on the UI thread once the raw JPEG bytes are available.
211220
/// Spawns a background thread to compute the three compressed variants,
212221
/// then shows the size-selection dialog on the UI thread.
213222
/// </summary>
214-
private void OnPhotoCaptured(byte[] rawJpeg)
223+
private void OnPhotoCaptured(byte[] rawJpeg, int displayRotation)
215224
{
216225
ThreadPool.QueueUserWorkItem(_ =>
217226
{
218227
byte[]? smallJpeg = null, mediumJpeg = null, originalJpeg = null;
219228
try
220229
{
221-
smallJpeg = ResizeAndEncode(rawJpeg, MaxPxSmall, QualitySmall);
222-
mediumJpeg = ResizeAndEncode(rawJpeg, MaxPxMedium, QualityMedium);
223-
originalJpeg = ResizeAndEncode(rawJpeg, int.MaxValue, QualityOriginal);
230+
smallJpeg = ResizeAndEncode(rawJpeg, MaxPxSmall, QualitySmall, displayRotation);
231+
mediumJpeg = ResizeAndEncode(rawJpeg, MaxPxMedium, QualityMedium, displayRotation);
232+
originalJpeg = ResizeAndEncode(rawJpeg, int.MaxValue, QualityOriginal, displayRotation);
224233
}
225234
catch (Exception ex)
226235
{
@@ -282,15 +291,25 @@ private void FinishWithBytes(byte[] jpeg)
282291
// -----------------------------------------------------------------
283292

284293
/// <summary>
285-
/// Decodes the source JPEG, optionally scales it down so that its longest
286-
/// side does not exceed <paramref name="maxPx"/>, then re-encodes in RAM.
294+
/// Decodes the source JPEG, applies any EXIF rotation so the pixel data matches
295+
/// the visual orientation (important for viewers that ignore EXIF, e.g. KeePassXC
296+
/// on desktop), optionally scales it down so that its longest side does not exceed
297+
/// <paramref name="maxPx"/>, then re-encodes as JPEG in RAM.
287298
/// Pass <see cref="int.MaxValue"/> for <paramref name="maxPx"/> to skip scaling.
288299
/// </summary>
289-
private static byte[] ResizeAndEncode(byte[] jpegBytes, int maxPx, int quality)
300+
private static byte[] ResizeAndEncode(byte[] jpegBytes, int maxPx, int quality, int displayRotation)
290301
{
291302
var bmp = BitmapFactory.DecodeByteArray(jpegBytes, 0, jpegBytes.Length)
292303
?? throw new InvalidOperationException("Failed to decode captured image.");
293304

305+
// Read EXIF orientation and rotate/flip the bitmap so the pixel data is
306+
// already in the correct visual orientation. This ensures that applications
307+
// that do not honour EXIF metadata (e.g. KeePassXC on desktop) display the
308+
// image correctly.
309+
// displayRotation is passed from the capture moment to compensate for CameraX
310+
// always encoding EXIF as if targetRotation=ROTATION_0 (portrait).
311+
bmp = ApplyExifOrientation(bmp, jpegBytes, displayRotation);
312+
294313
if (Math.Max(bmp.Width, bmp.Height) > maxPx)
295314
{
296315
float scale = (float)maxPx / Math.Max(bmp.Width, bmp.Height);
@@ -316,6 +335,87 @@ private static string FormatBytes(long bytes)
316335
return $"{bytes / (1024.0 * 1024.0):F1} MB";
317336
}
318337

338+
/// <summary>
339+
/// Reads the EXIF orientation tag from <paramref name="jpegBytes"/> and returns a
340+
/// new <see cref="Bitmap"/> with the pixels rotated/flipped to match the visual
341+
/// orientation. Returns the original bitmap unchanged when no correction is needed.
342+
/// <paramref name="displayRotation"/> is the Surface.Rotation* value at capture time
343+
/// (0=portrait, 1=landscape-90, 2=portrait-180, 3=landscape-270). CameraX always
344+
/// writes EXIF as if targetRotation=ROTATION_0, so we subtract the actual display
345+
/// rotation to get the true correction angle.
346+
/// </summary>
347+
private static Bitmap ApplyExifOrientation(Bitmap source, byte[] jpegBytes, int displayRotation)
348+
{
349+
int orientation;
350+
try
351+
{
352+
using var stream = new MemoryStream(jpegBytes);
353+
var exif = new Android.Media.ExifInterface(stream);
354+
orientation = exif.GetAttributeInt(
355+
Android.Media.ExifInterface.TagOrientation,
356+
(int)Android.Media.Orientation.Normal);
357+
}
358+
catch
359+
{
360+
return source; // If EXIF read fails, return as-is.
361+
}
362+
363+
var matrix = new Matrix();
364+
365+
// CameraX 1.4.x always encodes EXIF as if targetRotation=ROTATION_0 (portrait).
366+
// The EXIF tag therefore represents the rotation from sensor space to portrait space,
367+
// regardless of what the device was actually doing at capture time.
368+
// We must subtract the actual display rotation (in degrees) from the EXIF correction
369+
// so that the final pixels are in the true display orientation.
370+
// displayRotation: 0=portrait, 1=landscape-90°, 2=portrait-180°, 3=landscape-270°
371+
int displayDeg = displayRotation * 90;
372+
373+
// Degrees to rotate the bitmap to go from sensor space to correct visual orientation.
374+
int exifDeg = (Android.Media.Orientation)orientation switch
375+
{
376+
Android.Media.Orientation.Rotate90 => 90,
377+
Android.Media.Orientation.Rotate180 => 180,
378+
Android.Media.Orientation.Rotate270 => 270,
379+
_ => 0,
380+
};
381+
382+
// For flip cases we still need the matrix approach; handle them separately.
383+
bool isFlip = (Android.Media.Orientation)orientation is
384+
Android.Media.Orientation.FlipHorizontal or
385+
Android.Media.Orientation.FlipVertical or
386+
Android.Media.Orientation.Transpose or
387+
Android.Media.Orientation.Transverse;
388+
389+
if (isFlip)
390+
{
391+
// For flip orientations don't try to compensate display rotation —
392+
// these are rare (scanner/mirror modes) and never produced by the camera.
393+
switch ((Android.Media.Orientation)orientation)
394+
{
395+
case Android.Media.Orientation.FlipHorizontal:
396+
matrix.PreScale(-1f, 1f); break;
397+
case Android.Media.Orientation.FlipVertical:
398+
matrix.PreScale(1f, -1f); break;
399+
case Android.Media.Orientation.Transpose:
400+
matrix.PostRotate(90); matrix.PreScale(-1f, 1f); break;
401+
case Android.Media.Orientation.Transverse:
402+
matrix.PostRotate(270); matrix.PreScale(-1f, 1f); break;
403+
}
404+
}
405+
else
406+
{
407+
// Net rotation = EXIF correction − display rotation (mod 360)
408+
int netDeg = ((exifDeg - displayDeg) % 360 + 360) % 360;
409+
if (netDeg == 0) return source;
410+
matrix.PostRotate(netDeg);
411+
}
412+
413+
var rotated = Bitmap.CreateBitmap(source, 0, 0, source.Width, source.Height, matrix, true);
414+
if (!ReferenceEquals(rotated, source))
415+
source.Recycle();
416+
return rotated;
417+
}
418+
319419
// -----------------------------------------------------------------
320420
// Inner helpers
321421
// -----------------------------------------------------------------
@@ -327,22 +427,24 @@ private static string FormatBytes(long bytes)
327427
private class InMemoryCaptureCallback : ImageCapture.OnImageCapturedCallback
328428
{
329429
private readonly CapturePhotoActivity _activity;
430+
private readonly int _displayRotation;
330431

331432
// Required JNI constructor for .NET-Android interop.
332433
protected InMemoryCaptureCallback(IntPtr javaRef, JniHandleOwnership transfer)
333434
: base(javaRef, transfer) { }
334435

335-
public InMemoryCaptureCallback(CapturePhotoActivity activity)
436+
public InMemoryCaptureCallback(CapturePhotoActivity activity, int displayRotation)
336437
{
337438
_activity = activity;
439+
_displayRotation = displayRotation;
338440
}
339441

340442
public override void OnCaptureSuccess(IImageProxy image)
341443
{
342444
try
343445
{
344446
byte[] bytes = ExtractJpegBytes(image);
345-
_activity.RunOnUiThread(() => _activity.OnPhotoCaptured(bytes));
447+
_activity.RunOnUiThread(() => _activity.OnPhotoCaptured(bytes, _displayRotation));
346448
}
347449
finally
348450
{

0 commit comments

Comments
 (0)