From b057613bac81a799ac977a1290f506e9a301b46b Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 16:59:28 +0200 Subject: [PATCH 1/2] Write the user's physical tilt into the saved photo's EXIF orientation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture saves the bitmap in the device's portrait frame (CameraX rotates the sensor 90° to match the locked-portrait activity), so a photo taken while the phone was held in landscape lands on disk as a portrait-shaped bitmap with world-up pointing to the side. Tag the EXIF orientation with the user's actual tilt at shutter time (OrientationEventListener-derived deviceRotation, which up to now was passed in but ignored), so gallery viewers rotate the photo to match how the phone was held. Bump to 1.1.15. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../no/naiv/tiltshift/camera/ImageCaptureHandler.kt | 12 +++++++++++- version.properties | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt index b0401e7..b93db9a 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -24,6 +24,7 @@ import kotlin.coroutines.resume import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt +import no.naiv.tiltshift.util.OrientationDetector /** * Handles capturing photos with the tilt-shift effect applied. @@ -121,10 +122,19 @@ class ImageCaptureHandler( var thumbnail: Bitmap? = null try { thumbnail = createThumbnail(captureResult.processed) + // Camera bitmap is in the device's portrait frame (CameraX + // rotated the sensor 90° because the activity is locked to + // portrait). Tag the EXIF with the user's physical tilt + // when they pressed the shutter so viewers display the + // photo right-side-up regardless of how the phone was held. + val exifOrientation = OrientationDetector.degreesToExifOrientation( + OrientationDetector.rotationToDegrees(deviceRotation), + isFrontCamera + ) val result = photoSaver.saveBitmapPair( original = captureResult.original, processed = captureResult.processed, - orientation = ExifInterface.ORIENTATION_NORMAL, + orientation = exifOrientation, location = location ) if (result is SaveResult.Success) { diff --git a/version.properties b/version.properties index f37cebb..214906f 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=14 -versionCode=16 +versionPatch=15 +versionCode=17 From 2a826294613e183dd2264a7d69a7a4f862b49b0b Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 17:06:37 +0200 Subject: [PATCH 2/2] Document orientation policy and emulator-testing pitfalls in CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture two things future sessions need to know up front: (1) the activity is locked to portrait and the camera image stays in the device frame on purpose — eight releases of orientation-tracking attempts (v1.1.6 → v1.1.13) all got reverted, and per-orientation correctness now lives in the EXIF tag rather than the GL pipeline; (2) the Pixel 6 emulator's default virtual scene is too symmetric to verify rotation visually, `emu rotate` decrements rather than increments, and there is a startup window where camera bitmaps look black. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 6e1a655..249fe6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,21 @@ Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after - Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions - `writeExifToUri()` returns boolean and logs at ERROR level on failure +### Orientation policy (do not change without asking) + +- Activity is `screenOrientation="portrait"` in the manifest. The GL surface and Compose layout therefore never rotate. +- The camera passthrough uses a fixed 90° texcoord rotation (`texCoordsBack`/`texCoordsFront`). There is no `setTargetRotation`/`setDisplayRotation`/`getTransformMatrix`-based rotation tracking — past attempts (v1.1.6 through v1.1.13) all introduced subtle bugs and were reverted. +- The camera image lives in the device's portrait frame and visually follows the phone as it tilts. Per-orientation correctness comes from the EXIF orientation tag written at capture (`OrientationDetector.degreesToExifOrientation(rotationToDegrees(deviceRotation), isFrontCamera)`), not from rotating the bitmap. +- `OrientationEventListener` (`viewModel.currentRotation`) is for EXIF only. It is **not** the same as `Display.rotation`: it fires at the 45° tilt threshold while the activity rotates later, so it must not drive anything that has to stay in sync with the GL surface. +- `SurfaceTexture.getTransformMatrix()` with a custom `SurfaceProvider` does not change on `Preview.targetRotation` updates; rebinding doesn't reliably help either. Don't go down that road again. + +### Local Android testing + +- AVD: `tilfluktsrom` (Pixel 6, API 35, Google APIs) at `~/.android/avd/`. Boot with `emulator -avd tilfluktsrom -no-snapshot-save -gpu swiftshader_indirect -no-audio`. +- `adb -s emulator-5554 emu rotate` cycles `ROTATION_0 → 3 → 2 → 1 → 0` (decrements by 90°), not the other way. +- The default virtual scene is too rotationally symmetric to verify which way is "up". For orientation testing, pass `-virtualscene-poster wall=/path/to/marker.png` and ensure the scene camera faces the wall, or just don't trust emulator screenshots as proof of correct orientation. +- Camera bitmaps captured at startup tend to look black for a few seconds while CameraX rebinds. Wait ≥10 s after `am start` before screenshotting. + ## Permissions | Permission | Purpose | Notes |