diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index d9a4edf..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,102 +0,0 @@
-# Tilt-Shift Camera
-
-Android camera app that applies a real-time tilt-shift (miniature/diorama) blur effect to the camera preview and to imported gallery images. Built with Kotlin, Jetpack Compose, CameraX, and OpenGL ES 2.0.
-
-## What it does
-
-- Live camera preview with GPU-accelerated tilt-shift blur via GLSL fragment shader
-- Gallery import with CPU-based preview that updates in real time as you adjust parameters
-- Supports both **linear** (band) and **radial** (elliptical) blur modes
-- Gesture controls: drag to position, pinch to resize, two-finger rotate
-- Slider panel for precise control of blur, falloff, size, angle, aspect ratio
-- Multi-lens support on devices with multiple back cameras
-- EXIF GPS tagging from device location
-- Saves processed images to MediaStore (scoped storage)
-
-## Architecture
-
-```
-no.naiv.tiltshift/
- MainActivity.kt # Entry point, permissions, edge-to-edge setup
- ui/
- CameraScreen.kt # Main Compose UI, GL surface, controls
- CameraViewModel.kt # State management, gallery preview loop, bitmap lifecycle
- TiltShiftOverlay.kt # Gesture handling and visual guides
- ZoomControl.kt # Zoom presets and indicator
- LensSwitcher.kt # Multi-lens picker
- theme/AppColors.kt # Color constants
- camera/
- CameraManager.kt # CameraX lifecycle, zoom, lens binding
- ImageCaptureHandler.kt # Capture pipeline, CPU blur/mask, gallery processing
- LensController.kt # Enumerates physical camera lenses
- effect/
- TiltShiftRenderer.kt # GLSurfaceView.Renderer for live camera preview
- TiltShiftShader.kt # Compiles GLSL, sets uniforms (incl. precomputed trig)
- BlurParameters.kt # Data class for all effect parameters
- storage/
- PhotoSaver.kt # MediaStore writes, EXIF metadata, IS_PENDING pattern
- SaveResult.kt # Sealed class for save outcomes
- util/
- LocationProvider.kt # FusedLocationProvider flow (accepts coarse or fine)
- OrientationDetector.kt # Device rotation for EXIF
- HapticFeedback.kt # Null-safe vibration wrapper
-```
-
-### Rendering pipeline
-
-- **Camera preview**: OpenGL ES 2.0 via `GLSurfaceView` + `TiltShiftRenderer`. Camera frames arrive as `GL_TEXTURE_EXTERNAL_OES` from a `SurfaceTexture`. The fragment shader (`tiltshift_fragment.glsl`) applies blur per-fragment using precomputed `uCosAngle`/`uSinAngle` uniforms and an unrolled 9-tap Gaussian kernel.
-- **Gallery preview**: CPU-based. A 1024px-max downscaled source is kept in `galleryPreviewSource`. `CameraViewModel.startPreviewLoop()` uses `collectLatest` on blur params (with 80ms debounce) to reactively recompute the preview via `ImageCaptureHandler.applyTiltShiftPreview()`.
-- **Final save**: Full-resolution CPU pipeline — stack blur at 1/4 scale, gradient mask at 1/4 scale with bilinear upscale, per-pixel compositing. Camera captures save both original + processed; gallery imports save only the processed version (original already on device).
-
-## Build & run
-
-```bash
-./gradlew assembleRelease # Build release APK
-./gradlew compileDebugKotlin # Quick compile check
-adb install -r app/build/outputs/apk/release/naiv-tilt-shift-release.apk
-```
-
-Signing config is loaded from `local.properties` (not committed).
-
-## Key design decisions and patterns
-
-### Bitmap lifecycle (important!)
-
-Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after replacement. Compose may still be drawing the old bitmap in the current frame. Instead:
-
-- A `pendingRecyclePreview` / `pendingRecycleThumbnail` field holds the bitmap from the *previous* update
-- On the *next* update, the pending bitmap is recycled (Compose has had a full frame to finish)
-- Final cleanup happens in `cancelGalleryPreview()` (which `join()`s the preview job first) and `onCleared()`
-
-### Thread safety
-
-- `galleryPreviewSource` is `@Volatile` (accessed from Main thread, IO dispatcher, and cancel path)
-- `TiltShiftRenderer.currentTexCoords` is `@Volatile` (written by UI thread, read by GL thread)
-- `cancelGalleryPreview()` cancels + `join()`s the preview job before recycling the source bitmap, because `applyTiltShiftEffect` is a long CPU loop with no suspension points
-- GL resources are released via `glSurfaceView.queueEvent {}` (must run on GL thread)
-- `CameraManager.captureExecutor` is shut down in `release()` to prevent thread leaks
-
-### Error handling
-
-- `bitmap.compress()` return value is checked; failure reported to user
-- `loadBitmapFromUri()` logs all null-return paths (stream open, dimensions, decode)
-- Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions
-- `writeExifToUri()` returns boolean and logs at ERROR level on failure
-
-## Permissions
-
-| Permission | Purpose | Notes |
-|-----------|---------|-------|
-| `CAMERA` | Camera preview and capture | Required |
-| `ACCESS_FINE_LOCATION` | GPS EXIF tagging | Optional; coarse-only grant also works |
-| `ACCESS_COARSE_LOCATION` | GPS EXIF tagging | Fallback if fine denied |
-| `ACCESS_MEDIA_LOCATION` | Read GPS from gallery images | Required on Android 10+ |
-| `VIBRATE` | Haptic feedback | Always granted |
-
-## Known limitations / future work
-
-- `minSdk = 35` (Android 15) — intentional for personal use. Lower to 26-29 if distributing.
-- Accompanist Permissions (`0.36.0`) is deprecated; should migrate to first-party `activity-compose` API.
-- No user-facing toggle to disable GPS tagging — location is embedded whenever permission is granted.
-- Dependencies are pinned to late-2024 versions; periodic bumps recommended.
-- Fragment shader uses `int` uniform branching in GLSL ES 1.00 — works but could be cleaner with ES 3.00.
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 2fb4752..f72d4d0 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,5 +1,10 @@
# Add project specific ProGuard rules here.
-# CameraX and GMS Location ship their own consumer ProGuard rules.
-# Keep OpenGL shader-related code (accessed via reflection by GLSL pipeline)
+# Keep CameraX classes
+-keep class androidx.camera.** { *; }
+
+# Keep OpenGL shader-related code
-keep class no.naiv.tiltshift.effect.** { *; }
+
+# Keep location provider
+-keep class com.google.android.gms.location.** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c56c4e9..8cd08b7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,13 +6,10 @@
-
+
-
-
-
@@ -33,7 +30,7 @@
android:exported="true"
android:screenOrientation="fullSensor"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
- android:windowSoftInputMode="adjustNothing"
+ android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.TiltShiftCamera">
diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
index 9c440ac..20104bd 100644
--- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
+++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
@@ -18,9 +18,7 @@ import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import java.lang.ref.WeakReference
import java.util.concurrent.Executor
-import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/**
@@ -60,12 +58,11 @@ class CameraManager(private val context: Context) {
val isFrontCamera: StateFlow = _isFrontCamera.asStateFlow()
/** Background executor for image capture callbacks to avoid blocking the main thread. */
- private val captureExecutor: ExecutorService = Executors.newSingleThreadExecutor()
+ private val captureExecutor: Executor = Executors.newSingleThreadExecutor()
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
private var surfaceSize: Size = Size(1920, 1080)
- /** Weak reference to avoid preventing Activity GC across config changes. */
- private var lifecycleOwnerRef: WeakReference? = null
+ private var lifecycleOwnerRef: LifecycleOwner? = null
/**
* Starts the camera with the given lifecycle owner.
@@ -76,7 +73,7 @@ class CameraManager(private val context: Context) {
surfaceTextureProvider: () -> SurfaceTexture?
) {
this.surfaceTextureProvider = surfaceTextureProvider
- this.lifecycleOwnerRef = WeakReference(lifecycleOwner)
+ this.lifecycleOwnerRef = lifecycleOwner
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
@@ -92,10 +89,7 @@ class CameraManager(private val context: Context) {
}
private fun bindCameraUseCases(lifecycleOwner: LifecycleOwner) {
- val provider = cameraProvider ?: run {
- Log.w(TAG, "bindCameraUseCases called before camera provider initialized")
- return
- }
+ val provider = cameraProvider ?: return
// Unbind all use cases before rebinding
provider.unbindAll()
@@ -170,24 +164,12 @@ class CameraManager(private val context: Context) {
}
/**
- * Sets the zoom ratio. Updates UI state only after the camera confirms the change.
+ * Sets the zoom ratio.
*/
fun setZoom(ratio: Float) {
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
- val future = camera?.cameraControl?.setZoomRatio(clamped)
- if (future != null) {
- future.addListener({
- try {
- future.get()
- _zoomRatio.value = clamped
- } catch (e: Exception) {
- Log.w(TAG, "Zoom operation failed", e)
- }
- }, ContextCompat.getMainExecutor(context))
- } else {
- // Optimistic update when camera not available (e.g. during init)
- _zoomRatio.value = clamped
- }
+ camera?.cameraControl?.setZoomRatio(clamped)
+ _zoomRatio.value = clamped
}
/**
@@ -203,7 +185,7 @@ class CameraManager(private val context: Context) {
fun switchCamera() {
_isFrontCamera.value = !_isFrontCamera.value
_zoomRatio.value = 1.0f // Reset zoom when switching
- lifecycleOwnerRef?.get()?.let { bindCameraUseCases(it) }
+ lifecycleOwnerRef?.let { bindCameraUseCases(it) }
}
/**
@@ -225,11 +207,10 @@ class CameraManager(private val context: Context) {
}
/**
- * Releases camera resources and shuts down the background executor.
+ * Releases camera resources.
*/
fun release() {
cameraProvider?.unbindAll()
- captureExecutor.shutdown()
camera = null
preview = null
imageCapture = null
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 4736e52..04cc1dd 100644
--- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt
+++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt
@@ -36,8 +36,6 @@ class ImageCaptureHandler(
private const val TAG = "ImageCaptureHandler"
/** Maximum decoded image dimension to prevent OOM from huge gallery images. */
private const val MAX_IMAGE_DIMENSION = 4096
- /** Scale factor for downscaling blur and mask computations. */
- private const val SCALE_FACTOR = 4
}
/**
@@ -53,7 +51,7 @@ class ImageCaptureHandler(
* Captures a photo and applies the tilt-shift effect.
*
* Phase 1 (inside suspendCancellableCoroutine / camera callback):
- * decode -> rotate -> apply effect (synchronous CPU work only)
+ * decode → rotate → apply effect (synchronous CPU work only)
*
* Phase 2 (after continuation resumes, back in coroutine context):
* save bitmap via PhotoSaver (suspend-safe)
@@ -77,6 +75,7 @@ class ImageCaptureHandler(
val imageRotation = imageProxy.imageInfo.rotationDegrees
currentBitmap = imageProxyToBitmap(imageProxy)
+ imageProxy.close()
if (currentBitmap == null) {
continuation.resume(
@@ -98,8 +97,6 @@ class ImageCaptureHandler(
continuation.resume(
CaptureOutcome.Failed(SaveResult.Error("Failed to process image. Please try again.", e))
)
- } finally {
- imageProxy.close()
}
}
@@ -117,9 +114,8 @@ class ImageCaptureHandler(
return when (captureResult) {
is CaptureOutcome.Failed -> captureResult.result
is CaptureOutcome.Processed -> {
- var thumbnail: Bitmap? = null
try {
- thumbnail = createThumbnail(captureResult.processed)
+ val thumbnail = createThumbnail(captureResult.processed)
val result = photoSaver.saveBitmapPair(
original = captureResult.original,
processed = captureResult.processed,
@@ -127,14 +123,12 @@ class ImageCaptureHandler(
location = location
)
if (result is SaveResult.Success) {
- val output = result.copy(thumbnail = thumbnail)
- thumbnail = null // prevent finally from recycling the returned thumbnail
- output
+ result.copy(thumbnail = thumbnail)
} else {
+ thumbnail?.recycle()
result
}
} finally {
- thumbnail?.recycle()
captureResult.original.recycle()
captureResult.processed.recycle()
}
@@ -212,7 +206,7 @@ class ImageCaptureHandler(
/**
* Processes an existing image from the gallery through the tilt-shift pipeline.
- * Loads the image, applies EXIF rotation, processes the effect, and saves the result.
+ * Loads the image, applies EXIF rotation, processes the effect, and saves both versions.
*/
suspend fun processExistingImage(
imageUri: Uri,
@@ -221,7 +215,6 @@ class ImageCaptureHandler(
): SaveResult = withContext(Dispatchers.IO) {
var originalBitmap: Bitmap? = null
var processedBitmap: Bitmap? = null
- var thumbnail: Bitmap? = null
try {
originalBitmap = loadBitmapFromUri(imageUri)
?: return@withContext SaveResult.Error("Failed to load image")
@@ -230,7 +223,7 @@ class ImageCaptureHandler(
processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams)
- thumbnail = createThumbnail(processedBitmap)
+ val thumbnail = createThumbnail(processedBitmap)
val result = photoSaver.saveBitmap(
bitmap = processedBitmap,
@@ -239,10 +232,9 @@ class ImageCaptureHandler(
)
if (result is SaveResult.Success) {
- val output = result.copy(thumbnail = thumbnail)
- thumbnail = null // prevent finally from recycling the returned thumbnail
- output
+ result.copy(thumbnail = thumbnail)
} else {
+ thumbnail?.recycle()
result
}
} catch (e: SecurityException) {
@@ -252,7 +244,6 @@ class ImageCaptureHandler(
Log.e(TAG, "Gallery image processing failed", e)
SaveResult.Error("Failed to process image. Please try again.", e)
} finally {
- thumbnail?.recycle()
originalBitmap?.recycle()
processedBitmap?.recycle()
}
@@ -268,14 +259,6 @@ class ImageCaptureHandler(
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, options)
- } ?: run {
- Log.e(TAG, "Could not open input stream for URI (dimensions pass): $uri")
- return null
- }
-
- if (options.outWidth <= 0 || options.outHeight <= 0) {
- Log.e(TAG, "Image has invalid dimensions: ${options.outWidth}x${options.outHeight}, mime: ${options.outMimeType}")
- return null
}
// Calculate sample size to stay within MAX_IMAGE_DIMENSION
@@ -288,15 +271,9 @@ class ImageCaptureHandler(
// Second pass: decode with sample size
val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
- val bitmap = context.contentResolver.openInputStream(uri)?.use { stream ->
+ context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, decodeOptions)
}
-
- if (bitmap == null) {
- Log.e(TAG, "BitmapFactory.decodeStream returned null for URI: $uri (mime: ${options.outMimeType})")
- }
-
- bitmap
} catch (e: SecurityException) {
Log.e(TAG, "Permission denied loading bitmap from URI", e)
null
@@ -363,9 +340,6 @@ class ImageCaptureHandler(
* Applies tilt-shift blur effect to a bitmap.
* Supports both linear and radial modes.
*
- * The gradient mask is computed at 1/4 resolution (matching the blur downscale)
- * and upscaled for compositing, reducing peak memory by ~93%.
- *
* All intermediate bitmaps are tracked and recycled in a finally block
* so that an OOM or other exception does not leak native memory.
*/
@@ -377,12 +351,14 @@ class ImageCaptureHandler(
var scaled: Bitmap? = null
var blurred: Bitmap? = null
var blurredFullSize: Bitmap? = null
+ var mask: Bitmap? = null
try {
result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
- val blurredWidth = width / SCALE_FACTOR
- val blurredHeight = height / SCALE_FACTOR
+ val scaleFactor = 4
+ val blurredWidth = width / scaleFactor
+ val blurredHeight = height / scaleFactor
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
@@ -394,22 +370,24 @@ class ImageCaptureHandler(
blurred.recycle()
blurred = null
- // Compute mask at reduced resolution and upscale to avoid full-res per-pixel trig
- val maskPixels = createGradientMaskPixels(blurredWidth, blurredHeight, params)
- val fullMaskPixels = upscaleMask(maskPixels, blurredWidth, blurredHeight, width, height)
+ mask = createGradientMask(width, height, params)
// Composite: blend original with blurred based on mask
val pixels = IntArray(width * height)
val blurredPixels = IntArray(width * height)
+ val maskPixels = IntArray(width * height)
source.getPixels(pixels, 0, width, 0, 0, width, height)
blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height)
+ mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
blurredFullSize.recycle()
blurredFullSize = null
+ mask.recycle()
+ mask = null
for (i in pixels.indices) {
- val maskAlpha = (fullMaskPixels[i] and 0xFF) / 255f
+ val maskAlpha = (maskPixels[i] and 0xFF) / 255f
val origR = (pixels[i] shr 16) and 0xFF
val origG = (pixels[i] shr 8) and 0xFF
val origB = pixels[i] and 0xFF
@@ -434,14 +412,16 @@ class ImageCaptureHandler(
scaled?.recycle()
blurred?.recycle()
blurredFullSize?.recycle()
+ mask?.recycle()
}
}
/**
- * Creates a gradient mask as a pixel array at the given dimensions.
- * Returns packed ARGB ints where the blue channel encodes blur amount.
+ * Creates a gradient mask for the tilt-shift effect.
+ * Supports both linear and radial modes.
*/
- private fun createGradientMaskPixels(width: Int, height: Int, params: BlurParameters): IntArray {
+ private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap {
+ val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val pixels = IntArray(width * height)
val centerX = width * params.positionX
@@ -457,22 +437,32 @@ class ImageCaptureHandler(
for (x in 0 until width) {
val dist = when (params.mode) {
BlurMode.LINEAR -> {
+ // Rotate point around focus center
val dx = x - centerX
val dy = y - centerY
val rotatedY = -dx * sinAngle + dy * cosAngle
kotlin.math.abs(rotatedY)
}
BlurMode.RADIAL -> {
+ // Calculate elliptical distance from center
var dx = x - centerX
var dy = y - centerY
+
+ // Adjust for screen aspect ratio
dx *= screenAspect
+
+ // Rotate
val rotatedX = dx * cosAngle - dy * sinAngle
val rotatedY = dx * sinAngle + dy * cosAngle
+
+ // Apply ellipse aspect ratio
val adjustedX = rotatedX / params.aspectRatio
+
sqrt(adjustedX * adjustedX + rotatedY * rotatedY)
}
}
+ // Calculate blur amount based on distance from focus region
val blurAmount = when {
dist < focusSize -> 0f
dist < focusSize + transitionSize -> {
@@ -486,48 +476,8 @@ class ImageCaptureHandler(
}
}
- return pixels
- }
-
- /**
- * Bilinear upscale of a mask pixel array from small dimensions to full dimensions.
- */
- private fun upscaleMask(
- smallPixels: IntArray,
- smallW: Int, smallH: Int,
- fullW: Int, fullH: Int
- ): IntArray {
- val fullPixels = IntArray(fullW * fullH)
- val xRatio = smallW.toFloat() / fullW
- val yRatio = smallH.toFloat() / fullH
-
- for (y in 0 until fullH) {
- val srcY = y * yRatio
- val y0 = srcY.toInt().coerceIn(0, smallH - 1)
- val y1 = (y0 + 1).coerceIn(0, smallH - 1)
- val yFrac = srcY - y0
-
- for (x in 0 until fullW) {
- val srcX = x * xRatio
- val x0 = srcX.toInt().coerceIn(0, smallW - 1)
- val x1 = (x0 + 1).coerceIn(0, smallW - 1)
- val xFrac = srcX - x0
-
- // Bilinear interpolation on the blue channel (all channels are equal)
- val v00 = smallPixels[y0 * smallW + x0] and 0xFF
- val v10 = smallPixels[y0 * smallW + x1] and 0xFF
- val v01 = smallPixels[y1 * smallW + x0] and 0xFF
- val v11 = smallPixels[y1 * smallW + x1] and 0xFF
-
- val top = v00 + (v10 - v00) * xFrac
- val bottom = v01 + (v11 - v01) * xFrac
- val gray = (top + (bottom - top) * yFrac).toInt().coerceIn(0, 255)
-
- fullPixels[y * fullW + x] = (0xFF shl 24) or (gray shl 16) or (gray shl 8) or gray
- }
- }
-
- return fullPixels
+ mask.setPixels(pixels, 0, width, 0, 0, width, height)
+ return mask
}
/**
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 b3bf963..cd1873c 100644
--- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt
+++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt
@@ -65,7 +65,6 @@ class TiltShiftRenderer(
1f, 0f // Top right of screen
)
- @Volatile
private var currentTexCoords = texCoordsBack
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt
index 2c3a7d5..6b962b5 100644
--- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt
+++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt
@@ -4,8 +4,6 @@ import android.content.Context
import android.opengl.GLES11Ext
import android.opengl.GLES20
import no.naiv.tiltshift.R
-import kotlin.math.cos
-import kotlin.math.sin
import java.io.BufferedReader
import java.io.InputStreamReader
@@ -35,8 +33,6 @@ class TiltShiftShader(private val context: Context) {
private var uFalloffLocation: Int = 0
private var uAspectRatioLocation: Int = 0
private var uResolutionLocation: Int = 0
- private var uCosAngleLocation: Int = 0
- private var uSinAngleLocation: Int = 0
/**
* Compiles and links the shader program.
@@ -79,8 +75,6 @@ class TiltShiftShader(private val context: Context) {
uFalloffLocation = GLES20.glGetUniformLocation(programId, "uFalloff")
uAspectRatioLocation = GLES20.glGetUniformLocation(programId, "uAspectRatio")
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
- uCosAngleLocation = GLES20.glGetUniformLocation(programId, "uCosAngle")
- uSinAngleLocation = GLES20.glGetUniformLocation(programId, "uSinAngle")
// Clean up shaders (they're linked into program now)
GLES20.glDeleteShader(vertexShader)
@@ -109,16 +103,6 @@ class TiltShiftShader(private val context: Context) {
GLES20.glUniform1f(uFalloffLocation, params.falloff)
GLES20.glUniform1f(uAspectRatioLocation, params.aspectRatio)
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
-
- // Precompute angle trig on CPU to avoid per-fragment transcendental calls.
- // The adjusted angle accounts for the 90deg coordinate transform.
- val adjustedAngle = if (isFrontCamera) {
- -params.angle - (Math.PI / 2).toFloat()
- } else {
- params.angle + (Math.PI / 2).toFloat()
- }
- GLES20.glUniform1f(uCosAngleLocation, cos(adjustedAngle))
- GLES20.glUniform1f(uSinAngleLocation, sin(adjustedAngle))
}
/**
diff --git a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt
index 8facb4c..083f6cd 100644
--- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt
+++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt
@@ -102,10 +102,7 @@ class PhotoSaver(private val context: Context) {
) ?: return SaveResult.Error("Failed to create MediaStore entry")
contentResolver.openOutputStream(uri)?.use { outputStream ->
- if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)) {
- Log.e(TAG, "Bitmap compression returned false")
- return SaveResult.Error("Failed to compress image")
- }
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
} ?: return SaveResult.Error("Failed to open output stream")
writeExifToUri(uri, orientation, location)
@@ -124,11 +121,8 @@ class PhotoSaver(private val context: Context) {
}
}
- /**
- * Writes EXIF metadata to a saved image. Returns false if writing failed.
- */
- private fun writeExifToUri(uri: Uri, orientation: Int, location: Location?): Boolean {
- return try {
+ private fun writeExifToUri(uri: Uri, orientation: Int, location: Location?) {
+ try {
context.contentResolver.openFileDescriptor(uri, "rw")?.use { pfd ->
val exif = ExifInterface(pfd.fileDescriptor)
@@ -148,10 +142,8 @@ class PhotoSaver(private val context: Context) {
exif.saveAttributes()
}
- true
} catch (e: Exception) {
- Log.e(TAG, "Failed to write EXIF data", e)
- false
+ Log.w(TAG, "Failed to write EXIF data", e)
}
}
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 6cb7705..7c2838e 100644
--- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt
+++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt
@@ -165,19 +165,10 @@ fun CameraScreen(
}
}
- // Pause/resume GLSurfaceView when entering/leaving gallery preview
- LaunchedEffect(isGalleryPreview) {
- if (isGalleryPreview) {
- glSurfaceView?.onPause()
- } else {
- glSurfaceView?.onResume()
- }
- }
-
- // Cleanup GL resources on GL thread (ViewModel handles its own cleanup in onCleared)
+ // Cleanup GL resources (ViewModel handles its own cleanup in onCleared)
DisposableEffect(Unit) {
onDispose {
- glSurfaceView?.queueEvent { renderer?.release() }
+ renderer?.release()
}
}
@@ -469,7 +460,6 @@ fun CameraScreen(
context.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) {
Log.w("CameraScreen", "No activity found to view image", e)
- viewModel.showCameraError("No app available to view photos")
}
}
},
diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt
index 9595e21..cc9eef8 100644
--- a/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt
+++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt
@@ -10,7 +10,6 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -29,12 +28,6 @@ import no.naiv.tiltshift.util.OrientationDetector
/**
* ViewModel for the camera screen.
* Survives configuration changes (rotation) and process death (via SavedStateHandle for primitives).
- *
- * Bitmap lifecycle: bitmaps emitted to StateFlows are never eagerly recycled,
- * because Compose may still be drawing them on the next frame. Instead, the
- * previous bitmap is stored and recycled only when the *next* replacement arrives,
- * giving Compose at least one full frame to finish. Final cleanup happens in
- * cancelGalleryPreview() and onCleared().
*/
class CameraViewModel(application: Application) : AndroidViewModel(application) {
@@ -42,8 +35,6 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
private const val TAG = "CameraViewModel"
/** Max dimension for the preview source bitmap to keep effect computation fast. */
private const val PREVIEW_MAX_DIMENSION = 1024
- /** Debounce delay before recomputing preview to reduce GC pressure during slider drags. */
- private const val PREVIEW_DEBOUNCE_MS = 80L
}
val cameraManager = CameraManager(application)
@@ -84,16 +75,12 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
val galleryImageUri: StateFlow = _galleryImageUri.asStateFlow()
/** Downscaled source for fast preview recomputation. */
- @Volatile
private var galleryPreviewSource: Bitmap? = null
/** Processed preview bitmap shown in the UI. */
private val _galleryPreviewBitmap = MutableStateFlow(null)
val galleryPreviewBitmap: StateFlow = _galleryPreviewBitmap.asStateFlow()
- /** Previous preview bitmap, kept alive one extra cycle so Compose can finish drawing it. */
- private var pendingRecyclePreview: Bitmap? = null
-
private var previewJob: Job? = null
val isGalleryPreview: Boolean get() = _galleryBitmap.value != null
@@ -109,10 +96,6 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
private val _isProcessing = MutableStateFlow(false)
val isProcessing: StateFlow = _isProcessing.asStateFlow()
- // Dismiss jobs for timed indicators
- private var errorDismissJob: Job? = null
- private var successDismissJob: Job? = null
-
fun updateBlurParams(params: BlurParameters) {
_blurParams.value = params
}
@@ -144,34 +127,22 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
_galleryImageUri.value = uri
// Create downscaled source for fast preview recomputation
- val previewSource = try {
- withContext(Dispatchers.IO) {
- val maxDim = maxOf(bitmap.width, bitmap.height)
- if (maxDim > PREVIEW_MAX_DIMENSION) {
- val scale = PREVIEW_MAX_DIMENSION.toFloat() / maxDim
- Bitmap.createScaledBitmap(
- bitmap,
- (bitmap.width * scale).toInt(),
- (bitmap.height * scale).toInt(),
- true
- )
- } else {
- bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, false)
- }
+ galleryPreviewSource = withContext(Dispatchers.IO) {
+ val maxDim = maxOf(bitmap.width, bitmap.height)
+ if (maxDim > PREVIEW_MAX_DIMENSION) {
+ val scale = PREVIEW_MAX_DIMENSION.toFloat() / maxDim
+ Bitmap.createScaledBitmap(
+ bitmap,
+ (bitmap.width * scale).toInt(),
+ (bitmap.height * scale).toInt(),
+ true
+ )
+ } else {
+ bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, false)
}
- } catch (e: Exception) {
- Log.e(TAG, "Failed to create preview source", e)
- null
}
- if (previewSource != null) {
- galleryPreviewSource = previewSource
- startPreviewLoop()
- } else {
- haptics.error()
- showError("Failed to prepare image for preview")
- cancelGalleryPreview()
- }
+ startPreviewLoop()
} else {
haptics.error()
showError("Failed to load image")
@@ -179,55 +150,38 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
}
}
- /**
- * Reactively recomputes the tilt-shift preview when blur params change.
- * Uses debounce to reduce allocations during rapid slider drags.
- * Old preview bitmaps are recycled one cycle late to avoid racing with Compose draws.
- */
+ /** Reactively recomputes the tilt-shift preview when blur params change. */
private fun startPreviewLoop() {
previewJob?.cancel()
previewJob = viewModelScope.launch {
_blurParams.collectLatest { params ->
- delay(PREVIEW_DEBOUNCE_MS)
val source = galleryPreviewSource ?: return@collectLatest
try {
val processed = captureHandler.applyTiltShiftPreview(source, params)
- // Recycle the bitmap from two updates ago (Compose has had time to finish)
- pendingRecyclePreview?.recycle()
- // The current preview becomes pending; the new one becomes current
- pendingRecyclePreview = _galleryPreviewBitmap.value
+ val old = _galleryPreviewBitmap.value
_galleryPreviewBitmap.value = processed
+ old?.recycle()
} catch (e: Exception) {
- Log.e(TAG, "Preview computation failed", e)
- showError("Preview update failed")
+ Log.w(TAG, "Preview computation failed", e)
}
}
}
}
fun cancelGalleryPreview() {
- // Cancel the preview job and wait for its CPU work to finish
- // so we don't recycle galleryPreviewSource while it's being read
- val job = previewJob
+ previewJob?.cancel()
previewJob = null
- job?.cancel()
- viewModelScope.launch {
- job?.join()
+ val oldGallery = _galleryBitmap.value
+ val oldPreview = _galleryPreviewBitmap.value
+ _galleryBitmap.value = null
+ _galleryImageUri.value = null
+ _galleryPreviewBitmap.value = null
- val oldGallery = _galleryBitmap.value
- val oldPreview = _galleryPreviewBitmap.value
- _galleryBitmap.value = null
- _galleryImageUri.value = null
- _galleryPreviewBitmap.value = null
-
- oldGallery?.recycle()
- oldPreview?.recycle()
- pendingRecyclePreview?.recycle()
- pendingRecyclePreview = null
- galleryPreviewSource?.recycle()
- galleryPreviewSource = null
- }
+ oldGallery?.recycle()
+ oldPreview?.recycle()
+ galleryPreviewSource?.recycle()
+ galleryPreviewSource = null
}
fun applyGalleryEffect() {
@@ -272,22 +226,17 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
}
}
- /** Previous thumbnail kept one cycle for Compose to finish drawing. */
- private var pendingRecycleThumbnail: Bitmap? = null
-
private fun handleSaveResult(result: SaveResult) {
when (result) {
is SaveResult.Success -> {
haptics.success()
- // Recycle the thumbnail from two updates ago (safe from Compose)
- pendingRecycleThumbnail?.recycle()
- pendingRecycleThumbnail = _lastThumbnailBitmap.value
+ val oldThumb = _lastThumbnailBitmap.value
_lastThumbnailBitmap.value = result.thumbnail
_lastSavedUri.value = result.uri
- successDismissJob?.cancel()
- successDismissJob = viewModelScope.launch {
+ oldThumb?.recycle()
+ viewModelScope.launch {
_showSaveSuccess.value = true
- delay(1500)
+ kotlinx.coroutines.delay(1500)
_showSaveSuccess.value = false
}
}
@@ -299,10 +248,9 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
}
private fun showError(message: String) {
- errorDismissJob?.cancel()
- _showSaveError.value = message
- errorDismissJob = viewModelScope.launch {
- delay(2000)
+ viewModelScope.launch {
+ _showSaveError.value = message
+ kotlinx.coroutines.delay(2000)
_showSaveError.value = null
}
}
@@ -315,10 +263,8 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
super.onCleared()
cameraManager.release()
_lastThumbnailBitmap.value?.recycle()
- pendingRecycleThumbnail?.recycle()
_galleryBitmap.value?.recycle()
_galleryPreviewBitmap.value?.recycle()
- pendingRecyclePreview?.recycle()
galleryPreviewSource?.recycle()
}
}
diff --git a/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt b/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt
index ae8fb34..f8d7b53 100644
--- a/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt
+++ b/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt
@@ -79,10 +79,6 @@ class LocationProvider(private val context: Context) {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
- ) == PackageManager.PERMISSION_GRANTED ||
- ContextCompat.checkSelfPermission(
- context,
- Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
}
diff --git a/app/src/main/res/raw/tiltshift_fragment.glsl b/app/src/main/res/raw/tiltshift_fragment.glsl
index f1618e4..db1b415 100644
--- a/app/src/main/res/raw/tiltshift_fragment.glsl
+++ b/app/src/main/res/raw/tiltshift_fragment.glsl
@@ -20,10 +20,6 @@ uniform float uFalloff; // Transition sharpness (0-1, higher = more grad
uniform float uAspectRatio; // Ellipse aspect ratio for radial mode
uniform vec2 uResolution; // Texture resolution for proper sampling
-// Precomputed trig for the adjusted angle (avoids per-fragment cos/sin calls)
-uniform float uCosAngle;
-uniform float uSinAngle;
-
varying vec2 vTexCoord;
// Calculate signed distance from the focus region for LINEAR mode
@@ -41,11 +37,25 @@ float linearFocusDistance(vec2 uv) {
vec2 offset = uv - center;
// Correct for screen aspect ratio to make coordinate space square
+ // After transform: offset.x = screen Y direction, offset.y = screen X direction
+ // Scale offset.y to match the scale of offset.x (height units)
float screenAspect = uResolution.x / uResolution.y;
offset.y *= screenAspect;
- // Use precomputed cos/sin for the adjusted angle
- float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle;
+ // Adjust angle to compensate for the coordinate transformation
+ // Back camera: +90° for the 90° CW rotation
+ // Front camera: -90° (negated due to X flip mirror effect)
+ float adjustedAngle;
+ if (uIsFrontCamera == 1) {
+ adjustedAngle = -uAngle - 1.5707963;
+ } else {
+ adjustedAngle = uAngle + 1.5707963;
+ }
+ float cosA = cos(adjustedAngle);
+ float sinA = sin(adjustedAngle);
+
+ // After rotation, measure perpendicular distance from center line
+ float rotatedY = -offset.x * sinA + offset.y * cosA;
return abs(rotatedY);
}
@@ -53,6 +63,7 @@ float linearFocusDistance(vec2 uv) {
// Calculate signed distance from the focus region for RADIAL mode
float radialFocusDistance(vec2 uv) {
// Center point of the focus region
+ // Transform from screen coordinates to texture coordinates
vec2 center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
@@ -61,14 +72,24 @@ float radialFocusDistance(vec2 uv) {
}
vec2 offset = uv - center;
- // Correct for screen aspect ratio
+ // Correct for screen aspect ratio to make coordinate space square
+ // After transform: offset.x = screen Y direction, offset.y = screen X direction
+ // Scale offset.y to match the scale of offset.x (height units)
float screenAspect = uResolution.x / uResolution.y;
offset.y *= screenAspect;
- // Use precomputed cos/sin for rotation
+ // Apply rotation with angle adjustment for coordinate transformation
+ float adjustedAngle;
+ if (uIsFrontCamera == 1) {
+ adjustedAngle = -uAngle - 1.5707963;
+ } else {
+ adjustedAngle = uAngle + 1.5707963;
+ }
+ float cosA = cos(adjustedAngle);
+ float sinA = sin(adjustedAngle);
vec2 rotated = vec2(
- offset.x * uCosAngle - offset.y * uSinAngle,
- offset.x * uSinAngle + offset.y * uCosAngle
+ offset.x * cosA - offset.y * sinA,
+ offset.x * sinA + offset.y * cosA
);
// Apply ellipse aspect ratio
@@ -93,12 +114,26 @@ float blurFactor(float dist) {
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
}
-// Sample with Gaussian blur (9-tap, sigma ~= 2.0, unrolled for GLSL ES 1.00 compatibility)
+// Get Gaussian weight for blur kernel (9-tap, sigma ~= 2.0)
+float getWeight(int i) {
+ if (i == 0) return 0.0162;
+ if (i == 1) return 0.0540;
+ if (i == 2) return 0.1216;
+ if (i == 3) return 0.1933;
+ if (i == 4) return 0.2258;
+ if (i == 5) return 0.1933;
+ if (i == 6) return 0.1216;
+ if (i == 7) return 0.0540;
+ return 0.0162; // i == 8
+}
+
+// Sample with Gaussian blur
vec4 sampleBlurred(vec2 uv, float blur) {
if (blur < 0.01) {
return texture2D(uTexture, uv);
}
+ vec4 color = vec4(0.0);
vec2 texelSize = 1.0 / uResolution;
// For radial mode, blur in radial direction from center
@@ -106,6 +141,7 @@ vec4 sampleBlurred(vec2 uv, float blur) {
vec2 blurDir;
if (uMode == 1) {
// Radial: blur away from center
+ // Transform from screen coordinates to texture coordinates
vec2 center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
@@ -120,25 +156,26 @@ vec4 sampleBlurred(vec2 uv, float blur) {
blurDir = vec2(1.0, 0.0);
}
} else {
- // Linear: blur perpendicular to focus line using precomputed trig
- blurDir = vec2(uCosAngle, uSinAngle);
+ // Linear: blur perpendicular to focus line
+ // Adjust angle for coordinate transformation
+ float blurAngle;
+ if (uIsFrontCamera == 1) {
+ blurAngle = -uAngle - 1.5707963;
+ } else {
+ blurAngle = uAngle + 1.5707963;
+ }
+ blurDir = vec2(cos(blurAngle), sin(blurAngle));
}
// Scale blur radius by blur amount
float radius = blur * 20.0;
- vec2 step = blurDir * texelSize * radius;
- // Unrolled 9-tap Gaussian blur (avoids integer-branched weight lookup)
- vec4 color = vec4(0.0);
- color += texture2D(uTexture, uv + step * -4.0) * 0.0162;
- color += texture2D(uTexture, uv + step * -3.0) * 0.0540;
- color += texture2D(uTexture, uv + step * -2.0) * 0.1216;
- color += texture2D(uTexture, uv + step * -1.0) * 0.1933;
- color += texture2D(uTexture, uv) * 0.2258;
- color += texture2D(uTexture, uv + step * 1.0) * 0.1933;
- color += texture2D(uTexture, uv + step * 2.0) * 0.1216;
- color += texture2D(uTexture, uv + step * 3.0) * 0.0540;
- color += texture2D(uTexture, uv + step * 4.0) * 0.0162;
+ // 9-tap Gaussian blur
+ for (int i = 0; i < 9; i++) {
+ float offset = float(i) - 4.0;
+ vec2 samplePos = uv + blurDir * texelSize * offset * radius;
+ color += texture2D(uTexture, samplePos) * getWeight(i);
+ }
return color;
}
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
deleted file mode 100644
index e92f290..0000000
--- a/app/src/main/res/values-night/themes.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 50888f8..c1f6d5a 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,9 +1,8 @@
-