@@ -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