Compare commits

..

No commits in common. "main" and "v1.1.13" have entirely different histories.

6 changed files with 79 additions and 50 deletions

View file

@ -86,21 +86,6 @@ 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="portrait" android:screenOrientation="fullSensor"
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,7 +24,6 @@ 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.
@ -122,19 +121,10 @@ 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 = exifOrientation, orientation = ExifInterface.ORIENTATION_NORMAL,
location = location location = location
) )
if (result is SaveResult.Success) { if (result is SaveResult.Success) {

View file

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

View file

@ -1,9 +1,13 @@
package no.naiv.tiltshift.ui package no.naiv.tiltshift.ui
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.hardware.display.DisplayManager
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.util.Log import android.util.Log
import android.view.Display
import android.view.Surface
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -164,6 +168,23 @@ 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 // 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=15 versionPatch=13
versionCode=17 versionCode=15