Fix concurrency, lifecycle, performance, and config issues from audit

Concurrency & bitmap lifecycle:
- Defer bitmap recycling by one cycle so Compose finishes drawing before
  native memory is freed (preview bitmaps, thumbnails)
- Make galleryPreviewSource @Volatile for cross-thread visibility
- Join preview job before recycling source bitmap in cancelGalleryPreview()
  to prevent use-after-free during CPU blur loop
- Add @Volatile to TiltShiftRenderer.currentTexCoords (UI/GL thread race)
- Fix error dismiss race with cancellable Job tracking

Lifecycle & resource management:
- Release GL resources via glSurfaceView.queueEvent (must run on GL thread)
- Pause GLSurfaceView when entering gallery preview mode
- Shut down captureExecutor in CameraManager.release() (thread leak)
- Use WeakReference for lifecycleOwnerRef to avoid Activity GC delay
- Fix thumbnail bitmap leak on coroutine cancellation (add to finally)
- Guarantee imageProxy.close() in finally block

Performance:
- Compute gradient mask at 1/4 resolution with bilinear upscale (~93%
  less per-pixel trig work, ~75% less mask memory)
- Precompute cos/sin on CPU, pass as uCosAngle/uSinAngle uniforms
  (eliminates per-fragment transcendental calls in GLSL)
- Unroll 9-tap Gaussian blur kernel (avoids integer-branched weight
  lookup that de-optimizes on mobile GPUs)
- Add 80ms debounce to preview recomputation during slider drags

Silent failure fixes:
- Check bitmap.compress() return value; report error on failure
- Log all loadBitmapFromUri null paths (stream, dimensions, decode)
- Surface preview computation errors and ActivityNotFoundException to user
- Return boolean from writeExifToUri, log at ERROR level
- Wrap gallery preview downscale in try-catch (OOM protection)

Config:
- Add ACCESS_MEDIA_LOCATION permission (GPS EXIF on Android 10+)
- Accept coarse-only location grant for geotags
- Remove dead adjustResize (no effect with edge-to-edge)
- Set windowBackground to black (eliminates white flash on cold start)
- Add values-night theme for dark mode
- Remove overly broad ProGuard keeps (CameraX/GMS ship consumer rules)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 13:44:12 +01:00
commit 11a79076bc
13 changed files with 292 additions and 159 deletions

View file

@ -36,6 +36,8 @@ 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
}
/**
@ -51,7 +53,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)
@ -75,7 +77,6 @@ class ImageCaptureHandler(
val imageRotation = imageProxy.imageInfo.rotationDegrees
currentBitmap = imageProxyToBitmap(imageProxy)
imageProxy.close()
if (currentBitmap == null) {
continuation.resume(
@ -97,6 +98,8 @@ class ImageCaptureHandler(
continuation.resume(
CaptureOutcome.Failed(SaveResult.Error("Failed to process image. Please try again.", e))
)
} finally {
imageProxy.close()
}
}
@ -114,8 +117,9 @@ class ImageCaptureHandler(
return when (captureResult) {
is CaptureOutcome.Failed -> captureResult.result
is CaptureOutcome.Processed -> {
var thumbnail: Bitmap? = null
try {
val thumbnail = createThumbnail(captureResult.processed)
thumbnail = createThumbnail(captureResult.processed)
val result = photoSaver.saveBitmapPair(
original = captureResult.original,
processed = captureResult.processed,
@ -123,12 +127,14 @@ class ImageCaptureHandler(
location = location
)
if (result is SaveResult.Success) {
result.copy(thumbnail = thumbnail)
val output = result.copy(thumbnail = thumbnail)
thumbnail = null // prevent finally from recycling the returned thumbnail
output
} else {
thumbnail?.recycle()
result
}
} finally {
thumbnail?.recycle()
captureResult.original.recycle()
captureResult.processed.recycle()
}
@ -206,7 +212,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 both versions.
* Loads the image, applies EXIF rotation, processes the effect, and saves the result.
*/
suspend fun processExistingImage(
imageUri: Uri,
@ -215,6 +221,7 @@ 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")
@ -223,7 +230,7 @@ class ImageCaptureHandler(
processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams)
val thumbnail = createThumbnail(processedBitmap)
thumbnail = createThumbnail(processedBitmap)
val result = photoSaver.saveBitmap(
bitmap = processedBitmap,
@ -232,9 +239,10 @@ class ImageCaptureHandler(
)
if (result is SaveResult.Success) {
result.copy(thumbnail = thumbnail)
val output = result.copy(thumbnail = thumbnail)
thumbnail = null // prevent finally from recycling the returned thumbnail
output
} else {
thumbnail?.recycle()
result
}
} catch (e: SecurityException) {
@ -244,6 +252,7 @@ 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()
}
@ -259,6 +268,14 @@ 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
@ -271,9 +288,15 @@ class ImageCaptureHandler(
// Second pass: decode with sample size
val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
context.contentResolver.openInputStream(uri)?.use { stream ->
val bitmap = 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
@ -340,6 +363,9 @@ 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.
*/
@ -351,14 +377,12 @@ 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 scaleFactor = 4
val blurredWidth = width / scaleFactor
val blurredHeight = height / scaleFactor
val blurredWidth = width / SCALE_FACTOR
val blurredHeight = height / SCALE_FACTOR
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
@ -370,24 +394,22 @@ class ImageCaptureHandler(
blurred.recycle()
blurred = null
mask = createGradientMask(width, height, params)
// 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)
// 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 = (maskPixels[i] and 0xFF) / 255f
val maskAlpha = (fullMaskPixels[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
@ -412,16 +434,14 @@ class ImageCaptureHandler(
scaled?.recycle()
blurred?.recycle()
blurredFullSize?.recycle()
mask?.recycle()
}
}
/**
* Creates a gradient mask for the tilt-shift effect.
* Supports both linear and radial modes.
* Creates a gradient mask as a pixel array at the given dimensions.
* Returns packed ARGB ints where the blue channel encodes blur amount.
*/
private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap {
val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
private fun createGradientMaskPixels(width: Int, height: Int, params: BlurParameters): IntArray {
val pixels = IntArray(width * height)
val centerX = width * params.positionX
@ -437,32 +457,22 @@ 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 -> {
@ -476,8 +486,48 @@ class ImageCaptureHandler(
}
}
mask.setPixels(pixels, 0, width, 0, 0, width, height)
return mask
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
}
/**