From e4892c4b12a63f02ac804253ee4a5e8c63594d29 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 16:44:23 +0200 Subject: [PATCH 1/3] Lock activity to portrait; drop all camera-image rotation tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop trying to rotate the camera image based on device orientation. The activity is now locked to portrait (screenOrientation="portrait"), so the GL surface stays portrait-sized regardless of how the device is held, and the camera passthrough goes back to the simple texCoordsBack 90° rotation that was working before any of the v1.1.6–1.1.13 attempts at landscape support. Net effect: the camera image stays in the device's portrait frame and visually follows the phone as it tilts (since there is no inverse rotation cancelling it). The UI is also locked to the portrait layout for now — a follow-up will add Modifier.graphicsLayer rotations to the icon overlays so they stay readable when the phone is held sideways. screenOrientation switched from fullSensor to portrait; the rest of the file changes are reverts of the orientation plumbing introduced in v1.1.6 and its follow-ups. Bump to 1.1.14. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/main/AndroidManifest.xml | 2 +- .../tiltshift/effect/TiltShiftRenderer.kt | 75 ++++++------------- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 21 ------ version.properties | 4 +- 4 files changed, 24 insertions(+), 78 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c56c4e9..9fed438 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt index dc2b332..d170d58 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -6,7 +6,6 @@ import android.opengl.GLES11Ext import android.opengl.GLES20 import android.opengl.GLSurfaceView import android.util.Log -import android.view.Surface import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.FloatBuffer @@ -70,32 +69,26 @@ class TiltShiftRenderer( @Volatile private var vertexBufferDirty: Boolean = false - // Texture coordinates for the back camera, indexed by Surface.ROTATION_*. - // The base orientation (index 0) applies the 90° CCW rotation that maps - // the landscape sensor frame to a portrait display. Indices 1/2/3 layer - // additional CCW rotations on top so the activity's rotation is - // compensated and world-up stays at clip-space-top. - private val texCoordsBackByRotation = arrayOf( - floatArrayOf(1f, 1f, 1f, 0f, 0f, 1f, 0f, 0f), // ROTATION_0 - floatArrayOf(1f, 0f, 0f, 0f, 1f, 1f, 0f, 1f), // ROTATION_90 - floatArrayOf(0f, 0f, 0f, 1f, 1f, 0f, 1f, 1f), // ROTATION_180 - floatArrayOf(0f, 1f, 1f, 1f, 0f, 0f, 1f, 0f) // ROTATION_270 + // Texture coordinates rotated 90° for portrait mode (back camera) + // (Camera sensors are landscape-oriented, we rotate to portrait) + private val texCoordsBack = floatArrayOf( + 1f, 1f, // Bottom left of screen -> bottom right of texture + 1f, 0f, // Bottom right of screen -> top right of texture + 0f, 1f, // Top left of screen -> bottom left of texture + 0f, 0f // Top right of screen -> top left of texture ) - // Front camera variants: same as back, but horizontally mirrored - // for the natural selfie view. - private val texCoordsFrontByRotation = arrayOf( - floatArrayOf(0f, 1f, 0f, 0f, 1f, 1f, 1f, 0f), // ROTATION_0 - floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f), // ROTATION_90 - floatArrayOf(1f, 0f, 1f, 1f, 0f, 0f, 0f, 1f), // ROTATION_180 - floatArrayOf(1f, 1f, 0f, 1f, 1f, 0f, 0f, 0f) // ROTATION_270 + // Texture coordinates for front camera (mirrored + rotated) + // Front camera needs horizontal mirror for natural selfie view + private val texCoordsFront = floatArrayOf( + 0f, 1f, // Bottom left of screen + 0f, 0f, // Bottom right of screen + 1f, 1f, // Top left of screen + 1f, 0f // Top right of screen ) @Volatile - private var displayRotation: Int = Surface.ROTATION_0 - - @Volatile - private var currentTexCoords = texCoordsBackByRotation[Surface.ROTATION_0] + private var currentTexCoords = texCoordsBack @Volatile private var updateTexCoordBuffer = false @@ -212,7 +205,8 @@ class TiltShiftRenderer( fun setFrontCamera(front: Boolean) { if (isFrontCamera != front) { isFrontCamera = front - refreshTexCoords() + currentTexCoords = if (front) texCoordsFront else texCoordsBack + updateTexCoordBuffer = true } } @@ -224,27 +218,6 @@ class TiltShiftRenderer( } } - /** - * Updates the active display rotation. The texture-coordinate buffer is - * rebuilt so the camera image stays world-aligned as the activity rotates - * with the device under screenOrientation="fullSensor", and the - * crop-to-fill math picks the correct effective aspect ratio. - */ - fun setDisplayRotation(rotation: Int) { - if (displayRotation != rotation) { - displayRotation = rotation - refreshTexCoords() - vertexBufferDirty = true - } - } - - private fun refreshTexCoords() { - val table = if (isFrontCamera) texCoordsFrontByRotation else texCoordsBackByRotation - val idx = displayRotation.coerceIn(0, table.size - 1) - currentTexCoords = table[idx] - updateTexCoordBuffer = true - } - fun release() { shader.release() surfaceTexture?.release() @@ -281,22 +254,16 @@ class TiltShiftRenderer( /** * Recomputes camera vertex positions to achieve crop-to-fill. * - * The camera sensor is landscape; after the orientation-dependent texcoord - * rotation, the effective dimensions seen on screen are either swapped - * (portrait orientations) or kept (landscape orientations). We scale the - * vertex quad so the camera frame fills the surface without stretching — - * the GPU clips the overflow. + * The camera sensor is landscape; after the 90° rotation applied via texture coordinates, + * the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex + * quad so the camera frame fills the surface without stretching — the GPU clips the overflow. */ private fun recomputeVertices() { var scaleX = 1f var scaleY = 1f if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { - val isPortrait = displayRotation == Surface.ROTATION_0 || - displayRotation == Surface.ROTATION_180 - val effectiveW = if (isPortrait) cameraHeight else cameraWidth - val effectiveH = if (isPortrait) cameraWidth else cameraHeight - val cameraRatio = effectiveW.toFloat() / effectiveH + val cameraRatio = cameraHeight.toFloat() / cameraWidth val screenRatio = surfaceWidth.toFloat() / surfaceHeight if (cameraRatio > screenRatio) { diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index 763d4e4..bbb706d 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -1,13 +1,9 @@ package no.naiv.tiltshift.ui -import android.content.Context import android.content.Intent import android.graphics.SurfaceTexture -import android.hardware.display.DisplayManager import android.opengl.GLSurfaceView import android.util.Log -import android.view.Display -import android.view.Surface import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -168,23 +164,6 @@ fun CameraScreen( } } - // Forward the activity's actual rotation (Display.rotation) to the - // renderer so the camera image stays world-aligned as the activity rotates - // with the device. Don't drive this from OrientationEventListener — its - // 45° threshold fires *before* the activity has rotated, briefly leaving - // the texcoord set out of sync with the GL surface orientation. - // LocalConfiguration triggers a recomposition on configuration change, - // which is when Display.rotation can have changed. - val configuration = androidx.compose.ui.platform.LocalConfiguration.current - val displayRotation = remember(configuration) { - val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - displayManager.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0 - } - LaunchedEffect(displayRotation, renderer) { - renderer?.setDisplayRotation(displayRotation) - glSurfaceView?.requestRender() - } - // Start camera when surface texture is available LaunchedEffect(surfaceTexture) { surfaceTexture?.let { diff --git a/version.properties b/version.properties index 93b21cf..f37cebb 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=13 -versionCode=15 +versionPatch=14 +versionCode=16 From b057613bac81a799ac977a1290f506e9a301b46b Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 16:59:28 +0200 Subject: [PATCH 2/3] 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 3/3] 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 |