Compare commits

..

4 commits

Author SHA1 Message Date
2a82629461 Document orientation policy and emulator-testing pitfalls in CLAUDE.md
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) <noreply@anthropic.com>
2026-05-11 17:06:37 +02:00
b057613bac Write the user's physical tilt into the saved photo's EXIF orientation
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) <noreply@anthropic.com>
2026-05-11 16:59:28 +02:00
e4892c4b12 Lock activity to portrait; drop all camera-image rotation tracking
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) <noreply@anthropic.com>
2026-05-11 16:44:23 +02:00
5b553c7196 Drive renderer rotation from Display.rotation, not OrientationEventListener
OrientationEventListener fires continuously on the raw accelerometer
tilt and crosses the ROTATION_90 / ROTATION_270 boundary at 45° — well
before the system actually rotates the activity. The renderer was
swapping its texcoord buffer at 45° tilt while the GL surface and
Compose layout were still in the previous orientation, so for the few
degrees between "OrientationEventListener fires" and "activity
rotates" the camera image rendered at the wrong rotation. Past that
window it snapped back into sync.

Use LocalConfiguration + Display.rotation to source the renderer's
rotation. Configuration only changes when the activity has actually
rotated, so the texcoord buffer flips in lock-step with the GL surface
and there is no transient mis-orientation. OrientationEventListener
is still used by capture for EXIF metadata.

Bump to 1.1.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:22:54 +02:00
6 changed files with 50 additions and 66 deletions

View file

@ -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 - Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions
- `writeExifToUri()` returns boolean and logs at ERROR level on failure - `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 ## Permissions
| Permission | Purpose | Notes | | Permission | Purpose | Notes |

View file

@ -31,7 +31,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="fullSensor" android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustNothing" android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.TiltShiftCamera"> android:theme="@style/Theme.TiltShiftCamera">

View file

@ -24,6 +24,7 @@ import kotlin.coroutines.resume
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt import kotlin.math.sqrt
import no.naiv.tiltshift.util.OrientationDetector
/** /**
* Handles capturing photos with the tilt-shift effect applied. * Handles capturing photos with the tilt-shift effect applied.
@ -121,10 +122,19 @@ class ImageCaptureHandler(
var thumbnail: Bitmap? = null var thumbnail: Bitmap? = null
try { try {
thumbnail = createThumbnail(captureResult.processed) 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( val result = photoSaver.saveBitmapPair(
original = captureResult.original, original = captureResult.original,
processed = captureResult.processed, processed = captureResult.processed,
orientation = ExifInterface.ORIENTATION_NORMAL, orientation = exifOrientation,
location = location location = location
) )
if (result is SaveResult.Success) { if (result is SaveResult.Success) {

View file

@ -6,7 +6,6 @@ import android.opengl.GLES11Ext
import android.opengl.GLES20 import android.opengl.GLES20
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.util.Log import android.util.Log
import android.view.Surface
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.nio.FloatBuffer import java.nio.FloatBuffer
@ -70,32 +69,26 @@ class TiltShiftRenderer(
@Volatile @Volatile
private var vertexBufferDirty: Boolean = false private var vertexBufferDirty: Boolean = false
// Texture coordinates for the back camera, indexed by Surface.ROTATION_*. // Texture coordinates rotated 90° for portrait mode (back camera)
// The base orientation (index 0) applies the 90° CCW rotation that maps // (Camera sensors are landscape-oriented, we rotate to portrait)
// the landscape sensor frame to a portrait display. Indices 1/2/3 layer private val texCoordsBack = floatArrayOf(
// additional CCW rotations on top so the activity's rotation is 1f, 1f, // Bottom left of screen -> bottom right of texture
// compensated and world-up stays at clip-space-top. 1f, 0f, // Bottom right of screen -> top right of texture
private val texCoordsBackByRotation = arrayOf( 0f, 1f, // Top left of screen -> bottom left of texture
floatArrayOf(1f, 1f, 1f, 0f, 0f, 1f, 0f, 0f), // ROTATION_0 0f, 0f // Top right of screen -> top left of texture
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
) )
// Front camera variants: same as back, but horizontally mirrored // Texture coordinates for front camera (mirrored + rotated)
// for the natural selfie view. // Front camera needs horizontal mirror for natural selfie view
private val texCoordsFrontByRotation = arrayOf( private val texCoordsFront = floatArrayOf(
floatArrayOf(0f, 1f, 0f, 0f, 1f, 1f, 1f, 0f), // ROTATION_0 0f, 1f, // Bottom left of screen
floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f), // ROTATION_90 0f, 0f, // Bottom right of screen
floatArrayOf(1f, 0f, 1f, 1f, 0f, 0f, 0f, 1f), // ROTATION_180 1f, 1f, // Top left of screen
floatArrayOf(1f, 1f, 0f, 1f, 1f, 0f, 0f, 0f) // ROTATION_270 1f, 0f // Top right of screen
) )
@Volatile @Volatile
private var displayRotation: Int = Surface.ROTATION_0 private var currentTexCoords = texCoordsBack
@Volatile
private var currentTexCoords = texCoordsBackByRotation[Surface.ROTATION_0]
@Volatile @Volatile
private var updateTexCoordBuffer = false private var updateTexCoordBuffer = false
@ -212,7 +205,8 @@ class TiltShiftRenderer(
fun setFrontCamera(front: Boolean) { fun setFrontCamera(front: Boolean) {
if (isFrontCamera != front) { if (isFrontCamera != front) {
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() { fun release() {
shader.release() shader.release()
surfaceTexture?.release() surfaceTexture?.release()
@ -281,22 +254,16 @@ class TiltShiftRenderer(
/** /**
* Recomputes camera vertex positions to achieve crop-to-fill. * Recomputes camera vertex positions to achieve crop-to-fill.
* *
* The camera sensor is landscape; after the orientation-dependent texcoord * The camera sensor is landscape; after the 90° rotation applied via texture coordinates,
* rotation, the effective dimensions seen on screen are either swapped * the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex
* (portrait orientations) or kept (landscape orientations). We scale the * quad so the camera frame fills the surface without stretching the GPU clips the overflow.
* vertex quad so the camera frame fills the surface without stretching
* the GPU clips the overflow.
*/ */
private fun recomputeVertices() { private fun recomputeVertices() {
var scaleX = 1f var scaleX = 1f
var scaleY = 1f var scaleY = 1f
if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) {
val isPortrait = displayRotation == Surface.ROTATION_0 || val cameraRatio = cameraHeight.toFloat() / cameraWidth
displayRotation == Surface.ROTATION_180
val effectiveW = if (isPortrait) cameraHeight else cameraWidth
val effectiveH = if (isPortrait) cameraWidth else cameraHeight
val cameraRatio = effectiveW.toFloat() / effectiveH
val screenRatio = surfaceWidth.toFloat() / surfaceHeight val screenRatio = surfaceWidth.toFloat() / surfaceHeight
if (cameraRatio > screenRatio) { if (cameraRatio > screenRatio) {

View file

@ -164,14 +164,6 @@ fun CameraScreen(
} }
} }
// Forward device rotation to renderer so the camera image stays
// world-aligned as the activity rotates with the device.
val currentRotation by viewModel.currentRotation.collectAsState()
LaunchedEffect(currentRotation, renderer) {
renderer?.setDisplayRotation(currentRotation)
glSurfaceView?.requestRender()
}
// Start camera when surface texture is available // Start camera when surface texture is available
LaunchedEffect(surfaceTexture) { LaunchedEffect(surfaceTexture) {
surfaceTexture?.let { surfaceTexture?.let {

View file

@ -1,4 +1,4 @@
versionMajor=1 versionMajor=1
versionMinor=1 versionMinor=1
versionPatch=12 versionPatch=15
versionCode=14 versionCode=17