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

@ -18,7 +18,9 @@ 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
/**
@ -58,11 +60,12 @@ class CameraManager(private val context: Context) {
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
/** Background executor for image capture callbacks to avoid blocking the main thread. */
private val captureExecutor: Executor = Executors.newSingleThreadExecutor()
private val captureExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
private var surfaceSize: Size = Size(1920, 1080)
private var lifecycleOwnerRef: LifecycleOwner? = null
/** Weak reference to avoid preventing Activity GC across config changes. */
private var lifecycleOwnerRef: WeakReference<LifecycleOwner>? = null
/**
* Starts the camera with the given lifecycle owner.
@ -73,7 +76,7 @@ class CameraManager(private val context: Context) {
surfaceTextureProvider: () -> SurfaceTexture?
) {
this.surfaceTextureProvider = surfaceTextureProvider
this.lifecycleOwnerRef = lifecycleOwner
this.lifecycleOwnerRef = WeakReference(lifecycleOwner)
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
@ -89,7 +92,10 @@ class CameraManager(private val context: Context) {
}
private fun bindCameraUseCases(lifecycleOwner: LifecycleOwner) {
val provider = cameraProvider ?: return
val provider = cameraProvider ?: run {
Log.w(TAG, "bindCameraUseCases called before camera provider initialized")
return
}
// Unbind all use cases before rebinding
provider.unbindAll()
@ -164,12 +170,24 @@ class CameraManager(private val context: Context) {
}
/**
* Sets the zoom ratio.
* Sets the zoom ratio. Updates UI state only after the camera confirms the change.
*/
fun setZoom(ratio: Float) {
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
camera?.cameraControl?.setZoomRatio(clamped)
_zoomRatio.value = clamped
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
}
}
/**
@ -185,7 +203,7 @@ class CameraManager(private val context: Context) {
fun switchCamera() {
_isFrontCamera.value = !_isFrontCamera.value
_zoomRatio.value = 1.0f // Reset zoom when switching
lifecycleOwnerRef?.let { bindCameraUseCases(it) }
lifecycleOwnerRef?.get()?.let { bindCameraUseCases(it) }
}
/**
@ -207,10 +225,11 @@ class CameraManager(private val context: Context) {
}
/**
* Releases camera resources.
* Releases camera resources and shuts down the background executor.
*/
fun release() {
cameraProvider?.unbindAll()
captureExecutor.shutdown()
camera = null
preview = null
imageCapture = null