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:
parent
12051b2a83
commit
11a79076bc
13 changed files with 292 additions and 159 deletions
9
app/proguard-rules.pro
vendored
9
app/proguard-rules.pro
vendored
|
|
@ -1,10 +1,5 @@
|
||||||
# Add project specific ProGuard rules here.
|
# Add project specific ProGuard rules here.
|
||||||
|
# CameraX and GMS Location ship their own consumer ProGuard rules.
|
||||||
|
|
||||||
# Keep CameraX classes
|
# Keep OpenGL shader-related code (accessed via reflection by GLSL pipeline)
|
||||||
-keep class androidx.camera.** { *; }
|
|
||||||
|
|
||||||
# Keep OpenGL shader-related code
|
|
||||||
-keep class no.naiv.tiltshift.effect.** { *; }
|
-keep class no.naiv.tiltshift.effect.** { *; }
|
||||||
|
|
||||||
# Keep location provider
|
|
||||||
-keep class com.google.android.gms.location.** { *; }
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<!-- Location for EXIF GPS data -->
|
<!-- Location for EXIF GPS data (coarse is sufficient for geotags) -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
<!-- Required to read GPS from gallery images on Android 10+ -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||||
|
|
||||||
<!-- Vibration for haptic feedback -->
|
<!-- Vibration for haptic feedback -->
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
|
@ -30,7 +33,7 @@
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="fullSensor"
|
android:screenOrientation="fullSensor"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustNothing"
|
||||||
android:theme="@style/Theme.TiltShiftCamera">
|
android:theme="@style/Theme.TiltShiftCamera">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ import androidx.lifecycle.LifecycleOwner
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,11 +60,12 @@ class CameraManager(private val context: Context) {
|
||||||
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
|
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.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: Executor = Executors.newSingleThreadExecutor()
|
private val captureExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
|
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
|
||||||
private var surfaceSize: Size = Size(1920, 1080)
|
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.
|
* Starts the camera with the given lifecycle owner.
|
||||||
|
|
@ -73,7 +76,7 @@ class CameraManager(private val context: Context) {
|
||||||
surfaceTextureProvider: () -> SurfaceTexture?
|
surfaceTextureProvider: () -> SurfaceTexture?
|
||||||
) {
|
) {
|
||||||
this.surfaceTextureProvider = surfaceTextureProvider
|
this.surfaceTextureProvider = surfaceTextureProvider
|
||||||
this.lifecycleOwnerRef = lifecycleOwner
|
this.lifecycleOwnerRef = WeakReference(lifecycleOwner)
|
||||||
|
|
||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
|
|
@ -89,7 +92,10 @@ class CameraManager(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindCameraUseCases(lifecycleOwner: LifecycleOwner) {
|
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
|
// Unbind all use cases before rebinding
|
||||||
provider.unbindAll()
|
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) {
|
fun setZoom(ratio: Float) {
|
||||||
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
|
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
|
||||||
camera?.cameraControl?.setZoomRatio(clamped)
|
val future = camera?.cameraControl?.setZoomRatio(clamped)
|
||||||
_zoomRatio.value = 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() {
|
fun switchCamera() {
|
||||||
_isFrontCamera.value = !_isFrontCamera.value
|
_isFrontCamera.value = !_isFrontCamera.value
|
||||||
_zoomRatio.value = 1.0f // Reset zoom when switching
|
_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() {
|
fun release() {
|
||||||
cameraProvider?.unbindAll()
|
cameraProvider?.unbindAll()
|
||||||
|
captureExecutor.shutdown()
|
||||||
camera = null
|
camera = null
|
||||||
preview = null
|
preview = null
|
||||||
imageCapture = null
|
imageCapture = null
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ class ImageCaptureHandler(
|
||||||
private const val TAG = "ImageCaptureHandler"
|
private const val TAG = "ImageCaptureHandler"
|
||||||
/** Maximum decoded image dimension to prevent OOM from huge gallery images. */
|
/** Maximum decoded image dimension to prevent OOM from huge gallery images. */
|
||||||
private const val MAX_IMAGE_DIMENSION = 4096
|
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.
|
* Captures a photo and applies the tilt-shift effect.
|
||||||
*
|
*
|
||||||
* Phase 1 (inside suspendCancellableCoroutine / camera callback):
|
* 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):
|
* Phase 2 (after continuation resumes, back in coroutine context):
|
||||||
* save bitmap via PhotoSaver (suspend-safe)
|
* save bitmap via PhotoSaver (suspend-safe)
|
||||||
|
|
@ -75,7 +77,6 @@ class ImageCaptureHandler(
|
||||||
val imageRotation = imageProxy.imageInfo.rotationDegrees
|
val imageRotation = imageProxy.imageInfo.rotationDegrees
|
||||||
|
|
||||||
currentBitmap = imageProxyToBitmap(imageProxy)
|
currentBitmap = imageProxyToBitmap(imageProxy)
|
||||||
imageProxy.close()
|
|
||||||
|
|
||||||
if (currentBitmap == null) {
|
if (currentBitmap == null) {
|
||||||
continuation.resume(
|
continuation.resume(
|
||||||
|
|
@ -97,6 +98,8 @@ class ImageCaptureHandler(
|
||||||
continuation.resume(
|
continuation.resume(
|
||||||
CaptureOutcome.Failed(SaveResult.Error("Failed to process image. Please try again.", e))
|
CaptureOutcome.Failed(SaveResult.Error("Failed to process image. Please try again.", e))
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
|
imageProxy.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,8 +117,9 @@ class ImageCaptureHandler(
|
||||||
return when (captureResult) {
|
return when (captureResult) {
|
||||||
is CaptureOutcome.Failed -> captureResult.result
|
is CaptureOutcome.Failed -> captureResult.result
|
||||||
is CaptureOutcome.Processed -> {
|
is CaptureOutcome.Processed -> {
|
||||||
|
var thumbnail: Bitmap? = null
|
||||||
try {
|
try {
|
||||||
val thumbnail = createThumbnail(captureResult.processed)
|
thumbnail = createThumbnail(captureResult.processed)
|
||||||
val result = photoSaver.saveBitmapPair(
|
val result = photoSaver.saveBitmapPair(
|
||||||
original = captureResult.original,
|
original = captureResult.original,
|
||||||
processed = captureResult.processed,
|
processed = captureResult.processed,
|
||||||
|
|
@ -123,12 +127,14 @@ class ImageCaptureHandler(
|
||||||
location = location
|
location = location
|
||||||
)
|
)
|
||||||
if (result is SaveResult.Success) {
|
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 {
|
} else {
|
||||||
thumbnail?.recycle()
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
thumbnail?.recycle()
|
||||||
captureResult.original.recycle()
|
captureResult.original.recycle()
|
||||||
captureResult.processed.recycle()
|
captureResult.processed.recycle()
|
||||||
}
|
}
|
||||||
|
|
@ -206,7 +212,7 @@ class ImageCaptureHandler(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes an existing image from the gallery through the tilt-shift pipeline.
|
* 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(
|
suspend fun processExistingImage(
|
||||||
imageUri: Uri,
|
imageUri: Uri,
|
||||||
|
|
@ -215,6 +221,7 @@ class ImageCaptureHandler(
|
||||||
): SaveResult = withContext(Dispatchers.IO) {
|
): SaveResult = withContext(Dispatchers.IO) {
|
||||||
var originalBitmap: Bitmap? = null
|
var originalBitmap: Bitmap? = null
|
||||||
var processedBitmap: Bitmap? = null
|
var processedBitmap: Bitmap? = null
|
||||||
|
var thumbnail: Bitmap? = null
|
||||||
try {
|
try {
|
||||||
originalBitmap = loadBitmapFromUri(imageUri)
|
originalBitmap = loadBitmapFromUri(imageUri)
|
||||||
?: return@withContext SaveResult.Error("Failed to load image")
|
?: return@withContext SaveResult.Error("Failed to load image")
|
||||||
|
|
@ -223,7 +230,7 @@ class ImageCaptureHandler(
|
||||||
|
|
||||||
processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams)
|
processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams)
|
||||||
|
|
||||||
val thumbnail = createThumbnail(processedBitmap)
|
thumbnail = createThumbnail(processedBitmap)
|
||||||
|
|
||||||
val result = photoSaver.saveBitmap(
|
val result = photoSaver.saveBitmap(
|
||||||
bitmap = processedBitmap,
|
bitmap = processedBitmap,
|
||||||
|
|
@ -232,9 +239,10 @@ class ImageCaptureHandler(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (result is SaveResult.Success) {
|
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 {
|
} else {
|
||||||
thumbnail?.recycle()
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
|
|
@ -244,6 +252,7 @@ class ImageCaptureHandler(
|
||||||
Log.e(TAG, "Gallery image processing failed", e)
|
Log.e(TAG, "Gallery image processing failed", e)
|
||||||
SaveResult.Error("Failed to process image. Please try again.", e)
|
SaveResult.Error("Failed to process image. Please try again.", e)
|
||||||
} finally {
|
} finally {
|
||||||
|
thumbnail?.recycle()
|
||||||
originalBitmap?.recycle()
|
originalBitmap?.recycle()
|
||||||
processedBitmap?.recycle()
|
processedBitmap?.recycle()
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +268,14 @@ class ImageCaptureHandler(
|
||||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
BitmapFactory.decodeStream(stream, null, options)
|
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
|
// Calculate sample size to stay within MAX_IMAGE_DIMENSION
|
||||||
|
|
@ -271,9 +288,15 @@ class ImageCaptureHandler(
|
||||||
|
|
||||||
// Second pass: decode with sample size
|
// Second pass: decode with sample size
|
||||||
val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
|
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)
|
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) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "Permission denied loading bitmap from URI", e)
|
Log.e(TAG, "Permission denied loading bitmap from URI", e)
|
||||||
null
|
null
|
||||||
|
|
@ -340,6 +363,9 @@ class ImageCaptureHandler(
|
||||||
* Applies tilt-shift blur effect to a bitmap.
|
* Applies tilt-shift blur effect to a bitmap.
|
||||||
* Supports both linear and radial modes.
|
* 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
|
* All intermediate bitmaps are tracked and recycled in a finally block
|
||||||
* so that an OOM or other exception does not leak native memory.
|
* so that an OOM or other exception does not leak native memory.
|
||||||
*/
|
*/
|
||||||
|
|
@ -351,14 +377,12 @@ class ImageCaptureHandler(
|
||||||
var scaled: Bitmap? = null
|
var scaled: Bitmap? = null
|
||||||
var blurred: Bitmap? = null
|
var blurred: Bitmap? = null
|
||||||
var blurredFullSize: Bitmap? = null
|
var blurredFullSize: Bitmap? = null
|
||||||
var mask: Bitmap? = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
val scaleFactor = 4
|
val blurredWidth = width / SCALE_FACTOR
|
||||||
val blurredWidth = width / scaleFactor
|
val blurredHeight = height / SCALE_FACTOR
|
||||||
val blurredHeight = height / scaleFactor
|
|
||||||
|
|
||||||
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
||||||
|
|
||||||
|
|
@ -370,24 +394,22 @@ class ImageCaptureHandler(
|
||||||
blurred.recycle()
|
blurred.recycle()
|
||||||
blurred = null
|
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
|
// Composite: blend original with blurred based on mask
|
||||||
val pixels = IntArray(width * height)
|
val pixels = IntArray(width * height)
|
||||||
val blurredPixels = IntArray(width * height)
|
val blurredPixels = IntArray(width * height)
|
||||||
val maskPixels = IntArray(width * height)
|
|
||||||
|
|
||||||
source.getPixels(pixels, 0, width, 0, 0, width, height)
|
source.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||||
blurredFullSize.getPixels(blurredPixels, 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.recycle()
|
||||||
blurredFullSize = null
|
blurredFullSize = null
|
||||||
mask.recycle()
|
|
||||||
mask = null
|
|
||||||
|
|
||||||
for (i in pixels.indices) {
|
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 origR = (pixels[i] shr 16) and 0xFF
|
||||||
val origG = (pixels[i] shr 8) and 0xFF
|
val origG = (pixels[i] shr 8) and 0xFF
|
||||||
val origB = pixels[i] and 0xFF
|
val origB = pixels[i] and 0xFF
|
||||||
|
|
@ -412,16 +434,14 @@ class ImageCaptureHandler(
|
||||||
scaled?.recycle()
|
scaled?.recycle()
|
||||||
blurred?.recycle()
|
blurred?.recycle()
|
||||||
blurredFullSize?.recycle()
|
blurredFullSize?.recycle()
|
||||||
mask?.recycle()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a gradient mask for the tilt-shift effect.
|
* Creates a gradient mask as a pixel array at the given dimensions.
|
||||||
* Supports both linear and radial modes.
|
* Returns packed ARGB ints where the blue channel encodes blur amount.
|
||||||
*/
|
*/
|
||||||
private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap {
|
private fun createGradientMaskPixels(width: Int, height: Int, params: BlurParameters): IntArray {
|
||||||
val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
|
||||||
val pixels = IntArray(width * height)
|
val pixels = IntArray(width * height)
|
||||||
|
|
||||||
val centerX = width * params.positionX
|
val centerX = width * params.positionX
|
||||||
|
|
@ -437,32 +457,22 @@ class ImageCaptureHandler(
|
||||||
for (x in 0 until width) {
|
for (x in 0 until width) {
|
||||||
val dist = when (params.mode) {
|
val dist = when (params.mode) {
|
||||||
BlurMode.LINEAR -> {
|
BlurMode.LINEAR -> {
|
||||||
// Rotate point around focus center
|
|
||||||
val dx = x - centerX
|
val dx = x - centerX
|
||||||
val dy = y - centerY
|
val dy = y - centerY
|
||||||
val rotatedY = -dx * sinAngle + dy * cosAngle
|
val rotatedY = -dx * sinAngle + dy * cosAngle
|
||||||
kotlin.math.abs(rotatedY)
|
kotlin.math.abs(rotatedY)
|
||||||
}
|
}
|
||||||
BlurMode.RADIAL -> {
|
BlurMode.RADIAL -> {
|
||||||
// Calculate elliptical distance from center
|
|
||||||
var dx = x - centerX
|
var dx = x - centerX
|
||||||
var dy = y - centerY
|
var dy = y - centerY
|
||||||
|
|
||||||
// Adjust for screen aspect ratio
|
|
||||||
dx *= screenAspect
|
dx *= screenAspect
|
||||||
|
|
||||||
// Rotate
|
|
||||||
val rotatedX = dx * cosAngle - dy * sinAngle
|
val rotatedX = dx * cosAngle - dy * sinAngle
|
||||||
val rotatedY = dx * sinAngle + dy * cosAngle
|
val rotatedY = dx * sinAngle + dy * cosAngle
|
||||||
|
|
||||||
// Apply ellipse aspect ratio
|
|
||||||
val adjustedX = rotatedX / params.aspectRatio
|
val adjustedX = rotatedX / params.aspectRatio
|
||||||
|
|
||||||
sqrt(adjustedX * adjustedX + rotatedY * rotatedY)
|
sqrt(adjustedX * adjustedX + rotatedY * rotatedY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate blur amount based on distance from focus region
|
|
||||||
val blurAmount = when {
|
val blurAmount = when {
|
||||||
dist < focusSize -> 0f
|
dist < focusSize -> 0f
|
||||||
dist < focusSize + transitionSize -> {
|
dist < focusSize + transitionSize -> {
|
||||||
|
|
@ -476,8 +486,48 @@ class ImageCaptureHandler(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mask.setPixels(pixels, 0, width, 0, 0, width, height)
|
return pixels
|
||||||
return mask
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ class TiltShiftRenderer(
|
||||||
1f, 0f // Top right of screen
|
1f, 0f // Top right of screen
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var currentTexCoords = texCoordsBack
|
private var currentTexCoords = texCoordsBack
|
||||||
|
|
||||||
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import android.content.Context
|
||||||
import android.opengl.GLES11Ext
|
import android.opengl.GLES11Ext
|
||||||
import android.opengl.GLES20
|
import android.opengl.GLES20
|
||||||
import no.naiv.tiltshift.R
|
import no.naiv.tiltshift.R
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
|
|
@ -33,6 +35,8 @@ class TiltShiftShader(private val context: Context) {
|
||||||
private var uFalloffLocation: Int = 0
|
private var uFalloffLocation: Int = 0
|
||||||
private var uAspectRatioLocation: Int = 0
|
private var uAspectRatioLocation: Int = 0
|
||||||
private var uResolutionLocation: Int = 0
|
private var uResolutionLocation: Int = 0
|
||||||
|
private var uCosAngleLocation: Int = 0
|
||||||
|
private var uSinAngleLocation: Int = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compiles and links the shader program.
|
* Compiles and links the shader program.
|
||||||
|
|
@ -75,6 +79,8 @@ class TiltShiftShader(private val context: Context) {
|
||||||
uFalloffLocation = GLES20.glGetUniformLocation(programId, "uFalloff")
|
uFalloffLocation = GLES20.glGetUniformLocation(programId, "uFalloff")
|
||||||
uAspectRatioLocation = GLES20.glGetUniformLocation(programId, "uAspectRatio")
|
uAspectRatioLocation = GLES20.glGetUniformLocation(programId, "uAspectRatio")
|
||||||
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
|
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
|
||||||
|
uCosAngleLocation = GLES20.glGetUniformLocation(programId, "uCosAngle")
|
||||||
|
uSinAngleLocation = GLES20.glGetUniformLocation(programId, "uSinAngle")
|
||||||
|
|
||||||
// Clean up shaders (they're linked into program now)
|
// Clean up shaders (they're linked into program now)
|
||||||
GLES20.glDeleteShader(vertexShader)
|
GLES20.glDeleteShader(vertexShader)
|
||||||
|
|
@ -103,6 +109,16 @@ class TiltShiftShader(private val context: Context) {
|
||||||
GLES20.glUniform1f(uFalloffLocation, params.falloff)
|
GLES20.glUniform1f(uFalloffLocation, params.falloff)
|
||||||
GLES20.glUniform1f(uAspectRatioLocation, params.aspectRatio)
|
GLES20.glUniform1f(uAspectRatioLocation, params.aspectRatio)
|
||||||
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,10 @@ class PhotoSaver(private val context: Context) {
|
||||||
) ?: return SaveResult.Error("Failed to create MediaStore entry")
|
) ?: return SaveResult.Error("Failed to create MediaStore entry")
|
||||||
|
|
||||||
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
|
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)) {
|
||||||
|
Log.e(TAG, "Bitmap compression returned false")
|
||||||
|
return SaveResult.Error("Failed to compress image")
|
||||||
|
}
|
||||||
} ?: return SaveResult.Error("Failed to open output stream")
|
} ?: return SaveResult.Error("Failed to open output stream")
|
||||||
|
|
||||||
writeExifToUri(uri, orientation, location)
|
writeExifToUri(uri, orientation, location)
|
||||||
|
|
@ -121,8 +124,11 @@ class PhotoSaver(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeExifToUri(uri: Uri, orientation: Int, location: Location?) {
|
/**
|
||||||
try {
|
* Writes EXIF metadata to a saved image. Returns false if writing failed.
|
||||||
|
*/
|
||||||
|
private fun writeExifToUri(uri: Uri, orientation: Int, location: Location?): Boolean {
|
||||||
|
return try {
|
||||||
context.contentResolver.openFileDescriptor(uri, "rw")?.use { pfd ->
|
context.contentResolver.openFileDescriptor(uri, "rw")?.use { pfd ->
|
||||||
val exif = ExifInterface(pfd.fileDescriptor)
|
val exif = ExifInterface(pfd.fileDescriptor)
|
||||||
|
|
||||||
|
|
@ -142,8 +148,10 @@ class PhotoSaver(private val context: Context) {
|
||||||
|
|
||||||
exif.saveAttributes()
|
exif.saveAttributes()
|
||||||
}
|
}
|
||||||
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to write EXIF data", e)
|
Log.e(TAG, "Failed to write EXIF data", e)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,10 +165,19 @@ fun CameraScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup GL resources (ViewModel handles its own cleanup in onCleared)
|
// 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)
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
renderer?.release()
|
glSurfaceView?.queueEvent { renderer?.release() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -460,6 +469,7 @@ fun CameraScreen(
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
} catch (e: android.content.ActivityNotFoundException) {
|
} catch (e: android.content.ActivityNotFoundException) {
|
||||||
Log.w("CameraScreen", "No activity found to view image", e)
|
Log.w("CameraScreen", "No activity found to view image", e)
|
||||||
|
viewModel.showCameraError("No app available to view photos")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -28,6 +29,12 @@ import no.naiv.tiltshift.util.OrientationDetector
|
||||||
/**
|
/**
|
||||||
* ViewModel for the camera screen.
|
* ViewModel for the camera screen.
|
||||||
* Survives configuration changes (rotation) and process death (via SavedStateHandle for primitives).
|
* 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) {
|
class CameraViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
|
@ -35,6 +42,8 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
|
||||||
private const val TAG = "CameraViewModel"
|
private const val TAG = "CameraViewModel"
|
||||||
/** Max dimension for the preview source bitmap to keep effect computation fast. */
|
/** Max dimension for the preview source bitmap to keep effect computation fast. */
|
||||||
private const val PREVIEW_MAX_DIMENSION = 1024
|
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)
|
val cameraManager = CameraManager(application)
|
||||||
|
|
@ -75,12 +84,16 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
|
||||||
val galleryImageUri: StateFlow<Uri?> = _galleryImageUri.asStateFlow()
|
val galleryImageUri: StateFlow<Uri?> = _galleryImageUri.asStateFlow()
|
||||||
|
|
||||||
/** Downscaled source for fast preview recomputation. */
|
/** Downscaled source for fast preview recomputation. */
|
||||||
|
@Volatile
|
||||||
private var galleryPreviewSource: Bitmap? = null
|
private var galleryPreviewSource: Bitmap? = null
|
||||||
|
|
||||||
/** Processed preview bitmap shown in the UI. */
|
/** Processed preview bitmap shown in the UI. */
|
||||||
private val _galleryPreviewBitmap = MutableStateFlow<Bitmap?>(null)
|
private val _galleryPreviewBitmap = MutableStateFlow<Bitmap?>(null)
|
||||||
val galleryPreviewBitmap: StateFlow<Bitmap?> = _galleryPreviewBitmap.asStateFlow()
|
val galleryPreviewBitmap: StateFlow<Bitmap?> = _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
|
private var previewJob: Job? = null
|
||||||
|
|
||||||
val isGalleryPreview: Boolean get() = _galleryBitmap.value != null
|
val isGalleryPreview: Boolean get() = _galleryBitmap.value != null
|
||||||
|
|
@ -96,6 +109,10 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
|
||||||
private val _isProcessing = MutableStateFlow(false)
|
private val _isProcessing = MutableStateFlow(false)
|
||||||
val isProcessing: StateFlow<Boolean> = _isProcessing.asStateFlow()
|
val isProcessing: StateFlow<Boolean> = _isProcessing.asStateFlow()
|
||||||
|
|
||||||
|
// Dismiss jobs for timed indicators
|
||||||
|
private var errorDismissJob: Job? = null
|
||||||
|
private var successDismissJob: Job? = null
|
||||||
|
|
||||||
fun updateBlurParams(params: BlurParameters) {
|
fun updateBlurParams(params: BlurParameters) {
|
||||||
_blurParams.value = params
|
_blurParams.value = params
|
||||||
}
|
}
|
||||||
|
|
@ -127,22 +144,34 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
|
||||||
_galleryImageUri.value = uri
|
_galleryImageUri.value = uri
|
||||||
|
|
||||||
// Create downscaled source for fast preview recomputation
|
// Create downscaled source for fast preview recomputation
|
||||||
galleryPreviewSource = withContext(Dispatchers.IO) {
|
val previewSource = try {
|
||||||
val maxDim = maxOf(bitmap.width, bitmap.height)
|
withContext(Dispatchers.IO) {
|
||||||
if (maxDim > PREVIEW_MAX_DIMENSION) {
|
val maxDim = maxOf(bitmap.width, bitmap.height)
|
||||||
val scale = PREVIEW_MAX_DIMENSION.toFloat() / maxDim
|
if (maxDim > PREVIEW_MAX_DIMENSION) {
|
||||||
Bitmap.createScaledBitmap(
|
val scale = PREVIEW_MAX_DIMENSION.toFloat() / maxDim
|
||||||
bitmap,
|
Bitmap.createScaledBitmap(
|
||||||
(bitmap.width * scale).toInt(),
|
bitmap,
|
||||||
(bitmap.height * scale).toInt(),
|
(bitmap.width * scale).toInt(),
|
||||||
true
|
(bitmap.height * scale).toInt(),
|
||||||
)
|
true
|
||||||
} else {
|
)
|
||||||
bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, false)
|
} else {
|
||||||
|
bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to create preview source", e)
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
startPreviewLoop()
|
if (previewSource != null) {
|
||||||
|
galleryPreviewSource = previewSource
|
||||||
|
startPreviewLoop()
|
||||||
|
} else {
|
||||||
|
haptics.error()
|
||||||
|
showError("Failed to prepare image for preview")
|
||||||
|
cancelGalleryPreview()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
haptics.error()
|
haptics.error()
|
||||||
showError("Failed to load image")
|
showError("Failed to load image")
|
||||||
|
|
@ -150,38 +179,55 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reactively recomputes the tilt-shift preview when blur params change. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
private fun startPreviewLoop() {
|
private fun startPreviewLoop() {
|
||||||
previewJob?.cancel()
|
previewJob?.cancel()
|
||||||
previewJob = viewModelScope.launch {
|
previewJob = viewModelScope.launch {
|
||||||
_blurParams.collectLatest { params ->
|
_blurParams.collectLatest { params ->
|
||||||
|
delay(PREVIEW_DEBOUNCE_MS)
|
||||||
val source = galleryPreviewSource ?: return@collectLatest
|
val source = galleryPreviewSource ?: return@collectLatest
|
||||||
try {
|
try {
|
||||||
val processed = captureHandler.applyTiltShiftPreview(source, params)
|
val processed = captureHandler.applyTiltShiftPreview(source, params)
|
||||||
val old = _galleryPreviewBitmap.value
|
// 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
|
||||||
_galleryPreviewBitmap.value = processed
|
_galleryPreviewBitmap.value = processed
|
||||||
old?.recycle()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Preview computation failed", e)
|
Log.e(TAG, "Preview computation failed", e)
|
||||||
|
showError("Preview update failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelGalleryPreview() {
|
fun cancelGalleryPreview() {
|
||||||
previewJob?.cancel()
|
// 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 = null
|
previewJob = null
|
||||||
|
job?.cancel()
|
||||||
|
|
||||||
val oldGallery = _galleryBitmap.value
|
viewModelScope.launch {
|
||||||
val oldPreview = _galleryPreviewBitmap.value
|
job?.join()
|
||||||
_galleryBitmap.value = null
|
|
||||||
_galleryImageUri.value = null
|
|
||||||
_galleryPreviewBitmap.value = null
|
|
||||||
|
|
||||||
oldGallery?.recycle()
|
val oldGallery = _galleryBitmap.value
|
||||||
oldPreview?.recycle()
|
val oldPreview = _galleryPreviewBitmap.value
|
||||||
galleryPreviewSource?.recycle()
|
_galleryBitmap.value = null
|
||||||
galleryPreviewSource = null
|
_galleryImageUri.value = null
|
||||||
|
_galleryPreviewBitmap.value = null
|
||||||
|
|
||||||
|
oldGallery?.recycle()
|
||||||
|
oldPreview?.recycle()
|
||||||
|
pendingRecyclePreview?.recycle()
|
||||||
|
pendingRecyclePreview = null
|
||||||
|
galleryPreviewSource?.recycle()
|
||||||
|
galleryPreviewSource = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyGalleryEffect() {
|
fun applyGalleryEffect() {
|
||||||
|
|
@ -226,17 +272,22 @@ 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) {
|
private fun handleSaveResult(result: SaveResult) {
|
||||||
when (result) {
|
when (result) {
|
||||||
is SaveResult.Success -> {
|
is SaveResult.Success -> {
|
||||||
haptics.success()
|
haptics.success()
|
||||||
val oldThumb = _lastThumbnailBitmap.value
|
// Recycle the thumbnail from two updates ago (safe from Compose)
|
||||||
|
pendingRecycleThumbnail?.recycle()
|
||||||
|
pendingRecycleThumbnail = _lastThumbnailBitmap.value
|
||||||
_lastThumbnailBitmap.value = result.thumbnail
|
_lastThumbnailBitmap.value = result.thumbnail
|
||||||
_lastSavedUri.value = result.uri
|
_lastSavedUri.value = result.uri
|
||||||
oldThumb?.recycle()
|
successDismissJob?.cancel()
|
||||||
viewModelScope.launch {
|
successDismissJob = viewModelScope.launch {
|
||||||
_showSaveSuccess.value = true
|
_showSaveSuccess.value = true
|
||||||
kotlinx.coroutines.delay(1500)
|
delay(1500)
|
||||||
_showSaveSuccess.value = false
|
_showSaveSuccess.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -248,9 +299,10 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showError(message: String) {
|
private fun showError(message: String) {
|
||||||
viewModelScope.launch {
|
errorDismissJob?.cancel()
|
||||||
_showSaveError.value = message
|
_showSaveError.value = message
|
||||||
kotlinx.coroutines.delay(2000)
|
errorDismissJob = viewModelScope.launch {
|
||||||
|
delay(2000)
|
||||||
_showSaveError.value = null
|
_showSaveError.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -263,8 +315,10 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
cameraManager.release()
|
cameraManager.release()
|
||||||
_lastThumbnailBitmap.value?.recycle()
|
_lastThumbnailBitmap.value?.recycle()
|
||||||
|
pendingRecycleThumbnail?.recycle()
|
||||||
_galleryBitmap.value?.recycle()
|
_galleryBitmap.value?.recycle()
|
||||||
_galleryPreviewBitmap.value?.recycle()
|
_galleryPreviewBitmap.value?.recycle()
|
||||||
|
pendingRecyclePreview?.recycle()
|
||||||
galleryPreviewSource?.recycle()
|
galleryPreviewSource?.recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ class LocationProvider(private val context: Context) {
|
||||||
return ContextCompat.checkSelfPermission(
|
return ContextCompat.checkSelfPermission(
|
||||||
context,
|
context,
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED ||
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ uniform float uFalloff; // Transition sharpness (0-1, higher = more grad
|
||||||
uniform float uAspectRatio; // Ellipse aspect ratio for radial mode
|
uniform float uAspectRatio; // Ellipse aspect ratio for radial mode
|
||||||
uniform vec2 uResolution; // Texture resolution for proper sampling
|
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;
|
varying vec2 vTexCoord;
|
||||||
|
|
||||||
// Calculate signed distance from the focus region for LINEAR mode
|
// Calculate signed distance from the focus region for LINEAR mode
|
||||||
|
|
@ -37,25 +41,11 @@ float linearFocusDistance(vec2 uv) {
|
||||||
vec2 offset = uv - center;
|
vec2 offset = uv - center;
|
||||||
|
|
||||||
// Correct for screen aspect ratio to make coordinate space square
|
// 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;
|
float screenAspect = uResolution.x / uResolution.y;
|
||||||
offset.y *= screenAspect;
|
offset.y *= screenAspect;
|
||||||
|
|
||||||
// Adjust angle to compensate for the coordinate transformation
|
// Use precomputed cos/sin for the adjusted angle
|
||||||
// Back camera: +90° for the 90° CW rotation
|
float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle;
|
||||||
// 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);
|
return abs(rotatedY);
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +53,6 @@ float linearFocusDistance(vec2 uv) {
|
||||||
// Calculate signed distance from the focus region for RADIAL mode
|
// Calculate signed distance from the focus region for RADIAL mode
|
||||||
float radialFocusDistance(vec2 uv) {
|
float radialFocusDistance(vec2 uv) {
|
||||||
// Center point of the focus region
|
// Center point of the focus region
|
||||||
// Transform from screen coordinates to texture coordinates
|
|
||||||
vec2 center;
|
vec2 center;
|
||||||
if (uIsFrontCamera == 1) {
|
if (uIsFrontCamera == 1) {
|
||||||
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
|
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
|
||||||
|
|
@ -72,24 +61,14 @@ float radialFocusDistance(vec2 uv) {
|
||||||
}
|
}
|
||||||
vec2 offset = uv - center;
|
vec2 offset = uv - center;
|
||||||
|
|
||||||
// Correct for screen aspect ratio to make coordinate space square
|
// Correct for screen aspect ratio
|
||||||
// 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;
|
float screenAspect = uResolution.x / uResolution.y;
|
||||||
offset.y *= screenAspect;
|
offset.y *= screenAspect;
|
||||||
|
|
||||||
// Apply rotation with angle adjustment for coordinate transformation
|
// Use precomputed cos/sin for rotation
|
||||||
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(
|
vec2 rotated = vec2(
|
||||||
offset.x * cosA - offset.y * sinA,
|
offset.x * uCosAngle - offset.y * uSinAngle,
|
||||||
offset.x * sinA + offset.y * cosA
|
offset.x * uSinAngle + offset.y * uCosAngle
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply ellipse aspect ratio
|
// Apply ellipse aspect ratio
|
||||||
|
|
@ -114,26 +93,12 @@ float blurFactor(float dist) {
|
||||||
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
|
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Gaussian weight for blur kernel (9-tap, sigma ~= 2.0)
|
// Sample with Gaussian blur (9-tap, sigma ~= 2.0, unrolled for GLSL ES 1.00 compatibility)
|
||||||
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) {
|
vec4 sampleBlurred(vec2 uv, float blur) {
|
||||||
if (blur < 0.01) {
|
if (blur < 0.01) {
|
||||||
return texture2D(uTexture, uv);
|
return texture2D(uTexture, uv);
|
||||||
}
|
}
|
||||||
|
|
||||||
vec4 color = vec4(0.0);
|
|
||||||
vec2 texelSize = 1.0 / uResolution;
|
vec2 texelSize = 1.0 / uResolution;
|
||||||
|
|
||||||
// For radial mode, blur in radial direction from center
|
// For radial mode, blur in radial direction from center
|
||||||
|
|
@ -141,7 +106,6 @@ vec4 sampleBlurred(vec2 uv, float blur) {
|
||||||
vec2 blurDir;
|
vec2 blurDir;
|
||||||
if (uMode == 1) {
|
if (uMode == 1) {
|
||||||
// Radial: blur away from center
|
// Radial: blur away from center
|
||||||
// Transform from screen coordinates to texture coordinates
|
|
||||||
vec2 center;
|
vec2 center;
|
||||||
if (uIsFrontCamera == 1) {
|
if (uIsFrontCamera == 1) {
|
||||||
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
|
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
|
||||||
|
|
@ -156,26 +120,25 @@ vec4 sampleBlurred(vec2 uv, float blur) {
|
||||||
blurDir = vec2(1.0, 0.0);
|
blurDir = vec2(1.0, 0.0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Linear: blur perpendicular to focus line
|
// Linear: blur perpendicular to focus line using precomputed trig
|
||||||
// Adjust angle for coordinate transformation
|
blurDir = vec2(uCosAngle, uSinAngle);
|
||||||
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
|
// Scale blur radius by blur amount
|
||||||
float radius = blur * 20.0;
|
float radius = blur * 20.0;
|
||||||
|
vec2 step = blurDir * texelSize * radius;
|
||||||
|
|
||||||
// 9-tap Gaussian blur
|
// Unrolled 9-tap Gaussian blur (avoids integer-branched weight lookup)
|
||||||
for (int i = 0; i < 9; i++) {
|
vec4 color = vec4(0.0);
|
||||||
float offset = float(i) - 4.0;
|
color += texture2D(uTexture, uv + step * -4.0) * 0.0162;
|
||||||
vec2 samplePos = uv + blurDir * texelSize * offset * radius;
|
color += texture2D(uTexture, uv + step * -3.0) * 0.0540;
|
||||||
color += texture2D(uTexture, samplePos) * getWeight(i);
|
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;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
app/src/main/res/values-night/themes.xml
Normal file
9
app/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.TiltShiftCamera" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.TiltShiftCamera" parent="android:Theme.Material.NoActionBar">
|
<style name="Theme.TiltShiftCamera" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue