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

@ -20,6 +20,10 @@ 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
@ -37,25 +41,11 @@ 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;
// 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;
// Use precomputed cos/sin for the adjusted angle
float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle;
return abs(rotatedY);
}
@ -63,7 +53,6 @@ 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);
@ -72,24 +61,14 @@ float radialFocusDistance(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)
// Correct for screen aspect ratio
float screenAspect = uResolution.x / uResolution.y;
offset.y *= screenAspect;
// 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);
// Use precomputed cos/sin for rotation
vec2 rotated = vec2(
offset.x * cosA - offset.y * sinA,
offset.x * sinA + offset.y * cosA
offset.x * uCosAngle - offset.y * uSinAngle,
offset.x * uSinAngle + offset.y * uCosAngle
);
// Apply ellipse aspect ratio
@ -114,26 +93,12 @@ float blurFactor(float dist) {
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
}
// 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
// Sample with Gaussian blur (9-tap, sigma ~= 2.0, unrolled for GLSL ES 1.00 compatibility)
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
@ -141,7 +106,6 @@ 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);
@ -156,26 +120,25 @@ vec4 sampleBlurred(vec2 uv, float blur) {
blurDir = vec2(1.0, 0.0);
}
} else {
// 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));
// Linear: blur perpendicular to focus line using precomputed trig
blurDir = vec2(uCosAngle, uSinAngle);
}
// Scale blur radius by blur amount
float radius = blur * 20.0;
vec2 step = blurDir * texelSize * radius;
// 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);
}
// 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;
return color;
}