Compare commits

..

3 commits

Author SHA1 Message Date
1212604cf7 Bump version to 1.1.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:58:40 +01:00
52af9f6047 Add version management with auto-bump script
Version is tracked in version.properties and read by build.gradle.kts.
Run ./bump-version.sh [major|minor|patch] before release builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:58:22 +01:00
7979ebd029 Fix CameraX GL surface: render-on-demand, crop-to-fill, lifecycle
- Switch GLSurfaceView from RENDERMODE_CONTINUOUSLY to RENDERMODE_WHEN_DIRTY
  with OnFrameAvailableListener, halving GPU work
- Add crop-to-fill aspect ratio correction so camera preview is not
  stretched on displays with non-16:9 aspect ratios
- Add LifecycleEventObserver to pause/resume GLSurfaceView with Activity
  lifecycle, preventing background rendering and GL context issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:58:17 +01:00
6 changed files with 174 additions and 20 deletions

View file

@ -14,6 +14,15 @@ val keystoreProperties = Properties().apply {
} }
} }
// Load version from version.properties
val versionProperties = Properties().apply {
load(rootProject.file("version.properties").inputStream())
}
val vMajor = versionProperties["versionMajor"].toString().toInt()
val vMinor = versionProperties["versionMinor"].toString().toInt()
val vPatch = versionProperties["versionPatch"].toString().toInt()
val vCode = versionProperties["versionCode"].toString().toInt()
android { android {
namespace = "no.naiv.tiltshift" namespace = "no.naiv.tiltshift"
compileSdk = 35 compileSdk = 35
@ -34,8 +43,8 @@ android {
applicationId = "no.naiv.tiltshift" applicationId = "no.naiv.tiltshift"
minSdk = 35 minSdk = 35
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = vCode
versionName = "1.0.0" versionName = "$vMajor.$vMinor.$vPatch"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true

View file

@ -59,6 +59,9 @@ class CameraManager(private val context: Context) {
private val _isFrontCamera = MutableStateFlow(false) private val _isFrontCamera = MutableStateFlow(false)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow() val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
private val _previewResolution = MutableStateFlow(Size(0, 0))
val previewResolution: StateFlow<Size> = _previewResolution.asStateFlow()
/** Background executor for image capture callbacks to avoid blocking the main thread. */ /** Background executor for image capture callbacks to avoid blocking the main thread. */
private val captureExecutor: ExecutorService = Executors.newSingleThreadExecutor() private val captureExecutor: ExecutorService = Executors.newSingleThreadExecutor()
@ -161,6 +164,7 @@ class CameraManager(private val context: Context) {
} }
surfaceSize = request.resolution surfaceSize = request.resolution
_previewResolution.value = surfaceSize
surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height) surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
val surface = Surface(surfaceTexture) val surface = Surface(surfaceTexture)

View file

@ -19,7 +19,8 @@ import javax.microedition.khronos.opengles.GL10
*/ */
class TiltShiftRenderer( class TiltShiftRenderer(
private val context: Context, private val context: Context,
private val onSurfaceTextureAvailable: (SurfaceTexture) -> Unit private val onSurfaceTextureAvailable: (SurfaceTexture) -> Unit,
private val onFrameAvailable: () -> Unit
) : GLSurfaceView.Renderer { ) : GLSurfaceView.Renderer {
private lateinit var shader: TiltShiftShader private lateinit var shader: TiltShiftShader
@ -39,13 +40,13 @@ class TiltShiftRenderer(
@Volatile @Volatile
private var isFrontCamera: Boolean = false private var isFrontCamera: Boolean = false
// Quad vertices (full screen) // Camera resolution for aspect ratio correction (set from UI thread)
private val vertices = floatArrayOf( @Volatile
-1f, -1f, // Bottom left private var cameraWidth: Int = 0
1f, -1f, // Bottom right @Volatile
-1f, 1f, // Top left private var cameraHeight: Int = 0
1f, 1f // Top right @Volatile
) private var vertexBufferDirty: Boolean = false
// Texture coordinates rotated 90° for portrait mode (back camera) // Texture coordinates rotated 90° for portrait mode (back camera)
// (Camera sensors are landscape-oriented, we rotate to portrait) // (Camera sensors are landscape-oriented, we rotate to portrait)
@ -75,11 +76,12 @@ class TiltShiftRenderer(
shader = TiltShiftShader(context) shader = TiltShiftShader(context)
shader.initialize() shader.initialize()
// Create vertex buffer // Allocate vertex buffer (8 floats = 4 vertices × 2 components)
vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4) vertexBuffer = ByteBuffer.allocateDirect(8 * 4)
.order(ByteOrder.nativeOrder()) .order(ByteOrder.nativeOrder())
.asFloatBuffer() .asFloatBuffer()
.put(vertices) // Fill with default full-screen quad; will be recomputed when camera resolution is known
vertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
vertexBuffer.position(0) vertexBuffer.position(0)
// Create texture coordinate buffer // Create texture coordinate buffer
@ -102,6 +104,7 @@ class TiltShiftRenderer(
// Create SurfaceTexture for camera frames // Create SurfaceTexture for camera frames
surfaceTexture = SurfaceTexture(cameraTextureId).also { surfaceTexture = SurfaceTexture(cameraTextureId).also {
it.setOnFrameAvailableListener { onFrameAvailable() }
onSurfaceTextureAvailable(it) onSurfaceTextureAvailable(it)
} }
} }
@ -110,12 +113,19 @@ class TiltShiftRenderer(
GLES20.glViewport(0, 0, width, height) GLES20.glViewport(0, 0, width, height)
surfaceWidth = width surfaceWidth = width
surfaceHeight = height surfaceHeight = height
vertexBufferDirty = true
} }
override fun onDrawFrame(gl: GL10?) { override fun onDrawFrame(gl: GL10?) {
// Update texture with latest camera frame // Update texture with latest camera frame
surfaceTexture?.updateTexImage() surfaceTexture?.updateTexImage()
// Recompute vertex buffer for crop-to-fill when camera or surface dimensions change
if (vertexBufferDirty) {
recomputeVertices()
vertexBufferDirty = false
}
// Update texture coordinate buffer if camera changed // Update texture coordinate buffer if camera changed
if (updateTexCoordBuffer) { if (updateTexCoordBuffer) {
texCoordBuffer.clear() texCoordBuffer.clear()
@ -182,6 +192,53 @@ class TiltShiftRenderer(
@Volatile @Volatile
private var updateTexCoordBuffer = false private var updateTexCoordBuffer = false
/**
* Sets the camera preview resolution for crop-to-fill aspect ratio correction.
* Thread-safe vertex buffer is recomputed on the next frame.
*/
fun setCameraResolution(width: Int, height: Int) {
if (cameraWidth != width || cameraHeight != height) {
cameraWidth = width
cameraHeight = height
vertexBufferDirty = true
}
}
/**
* Recomputes vertex positions to achieve crop-to-fill.
*
* 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 screen 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) {
// After 90° rotation: portrait width = cameraHeight, portrait height = cameraWidth
val cameraRatio = cameraHeight.toFloat() / cameraWidth
val screenRatio = surfaceWidth.toFloat() / surfaceHeight
if (cameraRatio > screenRatio) {
// Camera wider than screen → crop sides
scaleX = cameraRatio / screenRatio
} else {
// Camera taller than screen → crop top/bottom
scaleY = screenRatio / cameraRatio
}
}
vertexBuffer.clear()
vertexBuffer.put(floatArrayOf(
-scaleX, -scaleY,
scaleX, -scaleY,
-scaleX, scaleY,
scaleX, scaleY
))
vertexBuffer.position(0)
}
/** /**
* Releases OpenGL resources. * Releases OpenGL resources.
* Must be called from GL thread. * Must be called from GL thread.

View file

@ -74,6 +74,8 @@ import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -116,6 +118,7 @@ fun CameraScreen(
val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState() val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState()
val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState() val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState() val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
val previewResolution by viewModel.cameraManager.previewResolution.collectAsState()
val cameraError by viewModel.cameraManager.error.collectAsState() val cameraError by viewModel.cameraManager.error.collectAsState()
// Gallery picker // Gallery picker
@ -161,6 +164,14 @@ fun CameraScreen(
glSurfaceView?.requestRender() glSurfaceView?.requestRender()
} }
// Update renderer with camera preview resolution for crop-to-fill
LaunchedEffect(previewResolution) {
if (previewResolution.width > 0) {
renderer?.setCameraResolution(previewResolution.width, previewResolution.height)
glSurfaceView?.requestRender()
}
}
// Start camera when surface texture is available // Start camera when surface texture is available
LaunchedEffect(surfaceTexture) { LaunchedEffect(surfaceTexture) {
surfaceTexture?.let { surfaceTexture?.let {
@ -172,14 +183,28 @@ fun CameraScreen(
LaunchedEffect(isGalleryPreview) { LaunchedEffect(isGalleryPreview) {
if (isGalleryPreview) { if (isGalleryPreview) {
glSurfaceView?.onPause() glSurfaceView?.onPause()
} else { } else if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
glSurfaceView?.onResume() glSurfaceView?.onResume()
} }
} }
// Cleanup GL resources on GL thread (ViewModel handles its own cleanup in onCleared) // Tie GLSurfaceView lifecycle to Activity lifecycle to prevent background rendering
DisposableEffect(Unit) { val currentIsGalleryPreview by rememberUpdatedState(isGalleryPreview)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
if (!currentIsGalleryPreview) {
glSurfaceView?.onResume()
}
}
Lifecycle.Event.ON_PAUSE -> glSurfaceView?.onPause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
glSurfaceView?.queueEvent { renderer?.release() } glSurfaceView?.queueEvent { renderer?.release() }
} }
} }
@ -208,13 +233,16 @@ fun CameraScreen(
GLSurfaceView(ctx).apply { GLSurfaceView(ctx).apply {
setEGLContextClientVersion(2) setEGLContextClientVersion(2)
val newRenderer = TiltShiftRenderer(ctx) { st -> val view = this
surfaceTexture = st val newRenderer = TiltShiftRenderer(
} context = ctx,
onSurfaceTextureAvailable = { st -> surfaceTexture = st },
onFrameAvailable = { view.requestRender() }
)
renderer = newRenderer renderer = newRenderer
setRenderer(newRenderer) setRenderer(newRenderer)
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
glSurfaceView = this glSurfaceView = this
} }

52
bump-version.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Bumps the version in version.properties before a release build.
# Usage: ./bump-version.sh [major|minor|patch]
# Default: patch
set -euo pipefail
PROPS_FILE="$(dirname "$0")/version.properties"
if [[ ! -f "$PROPS_FILE" ]]; then
echo "Error: $PROPS_FILE not found" >&2
exit 1
fi
# Read current values
major=$(grep '^versionMajor=' "$PROPS_FILE" | cut -d= -f2)
minor=$(grep '^versionMinor=' "$PROPS_FILE" | cut -d= -f2)
patch=$(grep '^versionPatch=' "$PROPS_FILE" | cut -d= -f2)
code=$(grep '^versionCode=' "$PROPS_FILE" | cut -d= -f2)
bump_type="${1:-patch}"
case "$bump_type" in
major)
major=$((major + 1))
minor=0
patch=0
;;
minor)
minor=$((minor + 1))
patch=0
;;
patch)
patch=$((patch + 1))
;;
*)
echo "Usage: $0 [major|minor|patch]" >&2
exit 1
;;
esac
code=$((code + 1))
# Write updated values
cat > "$PROPS_FILE" << EOF
versionMajor=$major
versionMinor=$minor
versionPatch=$patch
versionCode=$code
EOF
echo "$major.$minor.$patch (versionCode=$code)"

4
version.properties Normal file
View file

@ -0,0 +1,4 @@
versionMajor=1
versionMinor=1
versionPatch=1
versionCode=3