diff --git a/README.md b/README.md index 2fabd70..7668f6c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A dedicated Android camera app for tilt-shift photography with real-time preview ## Requirements -- Android 8.0 (API 26) or higher +- Android 15 (API 35) or higher - Device with camera - OpenGL ES 2.0 support @@ -66,8 +66,7 @@ app/src/main/java/no/naiv/tiltshift/ │ ├── ZoomControl.kt # Zoom UI component │ └── LensSwitcher.kt # Lens selection UI ├── storage/ -│ ├── PhotoSaver.kt # MediaStore integration -│ └── ExifWriter.kt # EXIF metadata handling +│ └── PhotoSaver.kt # MediaStore integration & EXIF handling └── util/ ├── OrientationDetector.kt ├── LocationProvider.kt 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 f70d6a4..b2d4e6c 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -2,6 +2,7 @@ package no.naiv.tiltshift.camera import android.content.Context import android.graphics.SurfaceTexture +import android.util.Log import android.util.Size import android.view.Surface import androidx.camera.core.Camera @@ -24,6 +25,10 @@ import java.util.concurrent.Executor */ class CameraManager(private val context: Context) { + companion object { + private const val TAG = "CameraManager" + } + private var cameraProvider: ProcessCameraProvider? = null private var camera: Camera? = null private var preview: Preview? = null @@ -32,6 +37,13 @@ class CameraManager(private val context: Context) { val lensController = LensController() + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + private val _zoomRatio = MutableStateFlow(1.0f) val zoomRatio: StateFlow = _zoomRatio.asStateFlow() @@ -82,10 +94,11 @@ class CameraManager(private val context: Context) { .setResolutionSelector(resolutionSelector) .build() - // Image capture use case for high-res photos - imageCapture = ImageCapture.Builder() + // Image capture use case + val captureBuilder = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) - .build() + + imageCapture = captureBuilder.build() // Select camera based on front/back preference val cameraSelector = if (_isFrontCamera.value) { @@ -117,8 +130,8 @@ class CameraManager(private val context: Context) { } } catch (e: Exception) { - // Camera binding failed - e.printStackTrace() + Log.e(TAG, "Camera binding failed", e) + _error.value = "Camera failed: ${e.message}" } } 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 b7a8d6b..eea6c7f 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -5,11 +5,15 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.location.Location +import android.net.Uri +import android.util.Log import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy import androidx.exifinterface.media.ExifInterface +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.storage.PhotoSaver @@ -28,12 +32,19 @@ class ImageCaptureHandler( private val photoSaver: PhotoSaver ) { + companion object { + private const val TAG = "ImageCaptureHandler" + } + /** * Holds the processed bitmap ready for saving, produced inside the * camera callback (synchronous CPU work) and consumed afterwards * in the caller's coroutine context. */ - private class ProcessedCapture(val bitmap: Bitmap) + private class ProcessedCapture( + val originalBitmap: Bitmap, + val processedBitmap: Bitmap + ) /** * Captures a photo and applies the tilt-shift effect. @@ -58,26 +69,31 @@ class ImageCaptureHandler( executor, object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(imageProxy: ImageProxy) { + var currentBitmap: Bitmap? = null try { val imageRotation = imageProxy.imageInfo.rotationDegrees - var bitmap = imageProxyToBitmap(imageProxy) + currentBitmap = imageProxyToBitmap(imageProxy) imageProxy.close() - if (bitmap == null) { + if (currentBitmap == null) { continuation.resume( SaveResult.Error("Failed to convert image") as Any ) return } - bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera) + currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera) - val processedBitmap = applyTiltShiftEffect(bitmap, blurParams) - bitmap.recycle() + val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams) + // Keep originalBitmap alive — both are recycled after saving + val original = currentBitmap + currentBitmap = null - continuation.resume(ProcessedCapture(processedBitmap)) + continuation.resume(ProcessedCapture(original, processedBitmap)) } catch (e: Exception) { + Log.e(TAG, "Image processing failed", e) + currentBitmap?.recycle() continuation.resume( SaveResult.Error("Capture failed: ${e.message}", e) as Any ) @@ -95,22 +111,46 @@ class ImageCaptureHandler( ) } - // Phase 2: save to disk in the caller's coroutine context (suspend-safe) + // Phase 2: save both original and processed to disk (suspend-safe) if (captureResult is ProcessedCapture) { return try { - photoSaver.saveBitmap( - captureResult.bitmap, - ExifInterface.ORIENTATION_NORMAL, - location + val thumbnail = createThumbnail(captureResult.processedBitmap) + val result = photoSaver.saveBitmapPair( + original = captureResult.originalBitmap, + processed = captureResult.processedBitmap, + orientation = ExifInterface.ORIENTATION_NORMAL, + location = location ) + if (result is SaveResult.Success) { + result.copy(thumbnail = thumbnail) + } else { + thumbnail?.recycle() + result + } } finally { - captureResult.bitmap.recycle() + captureResult.originalBitmap.recycle() + captureResult.processedBitmap.recycle() } } return captureResult as SaveResult } + /** + * Creates a small thumbnail copy of a bitmap for in-app preview. + */ + private fun createThumbnail(source: Bitmap, maxSize: Int = 160): Bitmap? { + return try { + val scale = maxSize.toFloat() / maxOf(source.width, source.height) + val width = (source.width * scale).toInt() + val height = (source.height * scale).toInt() + Bitmap.createScaledBitmap(source, width, height, true) + } catch (e: Exception) { + Log.w(TAG, "Failed to create thumbnail", e) + null + } + } + /** * Rotates a bitmap to the correct orientation. */ @@ -149,66 +189,200 @@ class ImageCaptureHandler( return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } + /** + * Loads a gallery image and applies EXIF rotation, returning the bitmap for preview. + * The caller owns the returned bitmap and is responsible for recycling it. + */ + suspend fun loadGalleryImage(imageUri: Uri): Bitmap? = withContext(Dispatchers.IO) { + try { + val bitmap = loadBitmapFromUri(imageUri) + ?: return@withContext null + applyExifRotation(imageUri, bitmap) + } catch (e: Exception) { + Log.e(TAG, "Failed to load gallery image for preview", e) + null + } + } + + /** + * 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. + */ + suspend fun processExistingImage( + imageUri: Uri, + blurParams: BlurParameters, + location: Location? + ): SaveResult = withContext(Dispatchers.IO) { + var originalBitmap: Bitmap? = null + var processedBitmap: Bitmap? = null + try { + originalBitmap = loadBitmapFromUri(imageUri) + ?: return@withContext SaveResult.Error("Failed to load image") + + originalBitmap = applyExifRotation(imageUri, originalBitmap) + + processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams) + + val thumbnail = createThumbnail(processedBitmap) + + val result = photoSaver.saveBitmapPair( + original = originalBitmap, + processed = processedBitmap, + orientation = ExifInterface.ORIENTATION_NORMAL, + location = location + ) + + if (result is SaveResult.Success) { + result.copy(thumbnail = thumbnail) + } else { + thumbnail?.recycle() + result + } + } catch (e: Exception) { + Log.e(TAG, "Gallery image processing failed", e) + SaveResult.Error("Processing failed: ${e.message}", e) + } finally { + originalBitmap?.recycle() + processedBitmap?.recycle() + } + } + + /** + * Loads a bitmap from a content URI. + */ + private fun loadBitmapFromUri(uri: Uri): Bitmap? { + return try { + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load bitmap from URI", e) + null + } + } + + /** + * Reads EXIF orientation from a content URI and applies the + * required rotation/flip to the bitmap. + */ + private fun applyExifRotation(uri: Uri, bitmap: Bitmap): Bitmap { + val orientation = try { + context.contentResolver.openInputStream(uri)?.use { stream -> + ExifInterface(stream).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + } ?: ExifInterface.ORIENTATION_NORMAL + } catch (e: Exception) { + Log.w(TAG, "Failed to read EXIF orientation", e) + ExifInterface.ORIENTATION_NORMAL + } + + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postRotate(90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postRotate(270f) + matrix.postScale(-1f, 1f) + } + else -> return bitmap + } + + val rotated = Bitmap.createBitmap( + bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true + ) + if (rotated != bitmap) { + bitmap.recycle() + } + return rotated + } + /** * Applies tilt-shift blur effect to a bitmap. * Supports both linear and radial modes. + * + * All intermediate bitmaps are tracked and recycled in a finally block + * so that an OOM or other exception does not leak native memory. */ private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap { val width = source.width val height = source.height - // Create output bitmap - val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + var result: Bitmap? = null + var scaled: Bitmap? = null + var blurred: Bitmap? = null + var blurredFullSize: Bitmap? = null + var mask: Bitmap? = null - // For performance, we use a scaled-down version for blur and composite - val scaleFactor = 4 // Blur a 1/4 size image for speed - val blurredWidth = width / scaleFactor - val blurredHeight = height / scaleFactor + try { + result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - // Create scaled bitmap for blur - val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) + val scaleFactor = 4 + val blurredWidth = width / scaleFactor + val blurredHeight = height / scaleFactor - // Apply stack blur (fast approximation) - val blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) - scaled.recycle() + scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) - // Scale blurred back up - val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true) - blurred.recycle() + blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) + scaled.recycle() + scaled = null - // Create gradient mask based on tilt-shift parameters - val mask = createGradientMask(width, height, params) + blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true) + blurred.recycle() + blurred = null - // Composite: blend original with blurred based on mask - val pixels = IntArray(width * height) - val blurredPixels = IntArray(width * height) - val maskPixels = IntArray(width * height) + mask = createGradientMask(width, height, params) - 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) + // Composite: blend original with blurred based on mask + val pixels = IntArray(width * height) + val blurredPixels = IntArray(width * height) + val maskPixels = IntArray(width * height) - blurredFullSize.recycle() - mask.recycle() + 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) - for (i in pixels.indices) { - 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 - val blurR = (blurredPixels[i] shr 16) and 0xFF - val blurG = (blurredPixels[i] shr 8) and 0xFF - val blurB = blurredPixels[i] and 0xFF + blurredFullSize.recycle() + blurredFullSize = null + mask.recycle() + mask = null - val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt() - val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt() - val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt() + for (i in pixels.indices) { + 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 + val blurR = (blurredPixels[i] shr 16) and 0xFF + val blurG = (blurredPixels[i] shr 8) and 0xFF + val blurB = blurredPixels[i] and 0xFF - pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt() + val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt() + val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt() + + pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + result.setPixels(pixels, 0, width, 0, 0, width, height) + + val output = result + result = null // prevent finally from recycling the returned bitmap + return output + } finally { + result?.recycle() + scaled?.recycle() + blurred?.recycle() + blurredFullSize?.recycle() + mask?.recycle() } - - result.setPixels(pixels, 0, width, 0, 0, width, height) - return result } /** diff --git a/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt b/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt index 1939561..7fb7edc 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt @@ -88,11 +88,4 @@ class LensController { return availableLenses[currentLensIndex] } - /** - * Common zoom levels that can be achieved through digital zoom. - * These are presented as quick-select buttons. - */ - fun getZoomPresets(): List { - return listOf(0.5f, 1.0f, 2.0f, 5.0f) - } } diff --git a/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt b/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt deleted file mode 100644 index 4a7431a..0000000 --- a/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt +++ /dev/null @@ -1,96 +0,0 @@ -package no.naiv.tiltshift.storage - -import android.location.Location -import androidx.exifinterface.media.ExifInterface -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * Writes EXIF metadata to captured images. - */ -class ExifWriter { - - private val dateTimeFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US) - - /** - * Writes EXIF data to the specified image file. - */ - fun writeExifData( - file: File, - orientation: Int, - location: Location?, - make: String = "Android", - model: String = android.os.Build.MODEL - ) { - try { - val exif = ExifInterface(file) - - // Orientation - exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString()) - - // Date/time - val dateTime = dateTimeFormat.format(Date()) - exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime) - exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime) - exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, dateTime) - - // Camera info - exif.setAttribute(ExifInterface.TAG_MAKE, make) - exif.setAttribute(ExifInterface.TAG_MODEL, model) - exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera") - - // GPS location - if (location != null) { - setLocationExif(exif, location) - } - - exif.saveAttributes() - } catch (e: Exception) { - e.printStackTrace() - } - } - - private fun setLocationExif(exif: ExifInterface, location: Location) { - // Latitude - val latitude = location.latitude - val latRef = if (latitude >= 0) "N" else "S" - exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, convertToDMS(Math.abs(latitude))) - exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, latRef) - - // Longitude - val longitude = location.longitude - val lonRef = if (longitude >= 0) "E" else "W" - exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, convertToDMS(Math.abs(longitude))) - exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, lonRef) - - // Altitude - if (location.hasAltitude()) { - val altitude = location.altitude - val altRef = if (altitude >= 0) "0" else "1" - exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, "${Math.abs(altitude).toLong()}/1") - exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, altRef) - } - - // Timestamp - val gpsTimeFormat = SimpleDateFormat("HH:mm:ss", Locale.US) - val gpsDateFormat = SimpleDateFormat("yyyy:MM:dd", Locale.US) - val timestamp = Date(location.time) - exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, gpsTimeFormat.format(timestamp)) - exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, gpsDateFormat.format(timestamp)) - } - - /** - * Converts decimal degrees to DMS (degrees/minutes/seconds) format for EXIF. - */ - private fun convertToDMS(coordinate: Double): String { - val degrees = coordinate.toInt() - val minutesDecimal = (coordinate - degrees) * 60 - val minutes = minutesDecimal.toInt() - val seconds = (minutesDecimal - minutes) * 60 - - // EXIF format: "degrees/1,minutes/1,seconds/1000" - return "$degrees/1,$minutes/1,${(seconds * 1000).toLong()}/1000" - } -} 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 968ef55..4bbe9b5 100644 --- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -3,28 +3,30 @@ package no.naiv.tiltshift.storage import android.content.ContentValues import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Paint import android.location.Location import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.util.Log import androidx.exifinterface.media.ExifInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream import java.text.SimpleDateFormat import java.util.Date import java.util.Locale + /** * Result of a photo save operation. */ sealed class SaveResult { - data class Success(val uri: Uri, val path: String) : SaveResult() + data class Success( + val uri: Uri, + val path: String, + val originalUri: Uri? = null, + val thumbnail: android.graphics.Bitmap? = null + ) : SaveResult() data class Error(val message: String, val exception: Exception? = null) : SaveResult() } @@ -33,7 +35,9 @@ sealed class SaveResult { */ class PhotoSaver(private val context: Context) { - private val exifWriter = ExifWriter() + companion object { + private const val TAG = "PhotoSaver" + } private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) @@ -45,100 +49,68 @@ class PhotoSaver(private val context: Context) { orientation: Int, location: Location? ): SaveResult = withContext(Dispatchers.IO) { - try { - val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" - - // Create content values for MediaStore - val contentValues = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, fileName) - put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") - put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000) - put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift") - put(MediaStore.Images.Media.IS_PENDING, 1) - } - } - - // Insert into MediaStore - val contentResolver = context.contentResolver - val uri = contentResolver.insert( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - contentValues - ) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry") - - // Write bitmap to output stream - contentResolver.openOutputStream(uri)?.use { outputStream -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream) - } ?: return@withContext SaveResult.Error("Failed to open output stream") - - // Write EXIF data - writeExifToUri(uri, orientation, location) - - // Mark as complete (API 29+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - contentValues.clear() - contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) - contentResolver.update(uri, contentValues, null, null) - } - - // Get the file path for display - val path = getPathFromUri(uri) - - SaveResult.Success(uri, path) - } catch (e: Exception) { - SaveResult.Error("Failed to save photo: ${e.message}", e) - } + val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" + saveSingleBitmap(fileName, bitmap, orientation, location) } /** - * Saves a JPEG file (from CameraX ImageCapture) to the gallery. + * Saves both original and processed bitmaps to the gallery. + * Uses a shared timestamp so paired files sort together. + * Returns the processed image's URI as the primary result. */ - suspend fun saveJpegFile( - sourceFile: File, + suspend fun saveBitmapPair( + original: Bitmap, + processed: Bitmap, orientation: Int, location: Location? ): SaveResult = withContext(Dispatchers.IO) { - try { - val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" + val timestamp = fileNameFormat.format(Date()) + val processedFileName = "TILTSHIFT_${timestamp}.jpg" + val originalFileName = "ORIGINAL_${timestamp}.jpg" + val processedResult = saveSingleBitmap(processedFileName, processed, orientation, location) + if (processedResult is SaveResult.Error) return@withContext processedResult + + val originalResult = saveSingleBitmap(originalFileName, original, orientation, location) + val originalUri = (originalResult as? SaveResult.Success)?.uri + + (processedResult as SaveResult.Success).copy(originalUri = originalUri) + } + + /** + * Core save logic: writes a single bitmap to MediaStore with EXIF data. + */ + private fun saveSingleBitmap( + fileName: String, + bitmap: Bitmap, + orientation: Int, + location: Location? + ): SaveResult { + return try { val contentValues = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, fileName) put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000) put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift") - put(MediaStore.Images.Media.IS_PENDING, 1) - } + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift") + put(MediaStore.Images.Media.IS_PENDING, 1) } val contentResolver = context.contentResolver val uri = contentResolver.insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues - ) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry") + ) ?: return SaveResult.Error("Failed to create MediaStore entry") - // Copy file to MediaStore contentResolver.openOutputStream(uri)?.use { outputStream -> - sourceFile.inputStream().use { inputStream -> - inputStream.copyTo(outputStream) - } - } ?: return@withContext SaveResult.Error("Failed to open output stream") + bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream) + } ?: return SaveResult.Error("Failed to open output stream") - // Write EXIF data writeExifToUri(uri, orientation, location) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - contentValues.clear() - contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) - contentResolver.update(uri, contentValues, null, null) - } - - // Clean up source file - sourceFile.delete() + contentValues.clear() + contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) + contentResolver.update(uri, contentValues, null, null) val path = getPathFromUri(uri) SaveResult.Success(uri, path) @@ -169,7 +141,7 @@ class PhotoSaver(private val context: Context) { exif.saveAttributes() } } catch (e: Exception) { - e.printStackTrace() + 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 c667bca..a01d11e 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -1,15 +1,20 @@ package no.naiv.tiltshift.ui +import android.content.Intent +import android.graphics.Bitmap import android.graphics.SurfaceTexture import android.location.Location +import android.net.Uri import android.opengl.GLSurfaceView import android.view.Surface import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.systemGestureExclusion import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,10 +30,14 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FlipCameraAndroid +import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Slider @@ -46,8 +55,11 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.Image import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.unit.dp @@ -97,13 +109,53 @@ fun CameraScreen( var showSaveError by remember { mutableStateOf(null) } var showControls by remember { mutableStateOf(false) } + // Thumbnail state for last captured photo + var lastSavedUri by remember { mutableStateOf(null) } + var lastThumbnailBitmap by remember { mutableStateOf(null) } + + // Gallery preview mode: non-null means we're previewing a gallery image + var galleryBitmap by remember { mutableStateOf(null) } + var galleryImageUri by remember { mutableStateOf(null) } + val isGalleryPreview = galleryBitmap != null + var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } var currentLocation by remember { mutableStateOf(null) } + // Gallery picker: load image for interactive preview before processing + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null && !isCapturing && !isGalleryPreview) { + scope.launch { + val bitmap = captureHandler.loadGalleryImage(uri) + if (bitmap != null) { + galleryBitmap = bitmap + galleryImageUri = uri + } else { + haptics.error() + showSaveError = "Failed to load image" + delay(2000) + showSaveError = null + } + } + } + } + val zoomRatio by cameraManager.zoomRatio.collectAsState() val minZoom by cameraManager.minZoomRatio.collectAsState() val maxZoom by cameraManager.maxZoomRatio.collectAsState() val isFrontCamera by cameraManager.isFrontCamera.collectAsState() + val cameraError by cameraManager.error.collectAsState() + + // Show camera errors via the existing error UI + LaunchedEffect(cameraError) { + cameraError?.let { message -> + showSaveError = message + cameraManager.clearError() + delay(2000) + showSaveError = null + } + } // Collect orientation updates LaunchedEffect(Unit) { @@ -143,6 +195,8 @@ fun CameraScreen( onDispose { cameraManager.release() renderer?.release() + lastThumbnailBitmap?.recycle() + galleryBitmap?.recycle() } } @@ -151,25 +205,39 @@ fun CameraScreen( .fillMaxSize() .background(Color.Black) ) { - // OpenGL Surface for camera preview with effect - AndroidView( - factory = { ctx -> - GLSurfaceView(ctx).apply { - setEGLContextClientVersion(2) + // Main view: gallery preview image or camera GL surface + if (isGalleryPreview) { + galleryBitmap?.let { bmp -> + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Gallery preview", + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) + } + } else { + // OpenGL Surface for camera preview with effect + AndroidView( + factory = { ctx -> + GLSurfaceView(ctx).apply { + setEGLContextClientVersion(2) - val newRenderer = TiltShiftRenderer(ctx) { st -> - surfaceTexture = st + val newRenderer = TiltShiftRenderer(ctx) { st -> + surfaceTexture = st + } + renderer = newRenderer + + setRenderer(newRenderer) + renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY + + glSurfaceView = this } - renderer = newRenderer - - setRenderer(newRenderer) - renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY - - glSurfaceView = this - } - }, - modifier = Modifier.fillMaxSize() - ) + }, + modifier = Modifier.fillMaxSize() + ) + } // Tilt-shift overlay (gesture handling + visualization) TiltShiftOverlay( @@ -179,8 +247,10 @@ fun CameraScreen( haptics.tick() }, onZoomChange = { zoomDelta -> - val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) - cameraManager.setZoom(newZoom) + if (!isGalleryPreview) { + val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) + cameraManager.setZoom(newZoom) + } }, modifier = Modifier.fillMaxSize() ) @@ -197,22 +267,28 @@ fun CameraScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // Zoom indicator - ZoomIndicator(currentZoom = zoomRatio) + if (!isGalleryPreview) { + // Zoom indicator + ZoomIndicator(currentZoom = zoomRatio) + } else { + Spacer(modifier = Modifier.width(1.dp)) + } Row(verticalAlignment = Alignment.CenterVertically) { - // Camera flip button - IconButton( - onClick = { - cameraManager.switchCamera() - haptics.click() + if (!isGalleryPreview) { + // Camera flip button + IconButton( + onClick = { + cameraManager.switchCamera() + haptics.click() + } + ) { + Icon( + imageVector = Icons.Default.FlipCameraAndroid, + contentDescription = "Switch Camera", + tint = Color.White + ) } - ) { - Icon( - imageVector = Icons.Default.FlipCameraAndroid, - contentDescription = "Switch Camera", - tint = Color.White - ) } // Toggle controls button @@ -270,66 +346,197 @@ fun CameraScreen( modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() - .padding(bottom = 24.dp), + .padding(bottom = 48.dp) + .systemGestureExclusion(), horizontalAlignment = Alignment.CenterHorizontally ) { - // Zoom presets (only show for back camera) - if (!isFrontCamera) { - ZoomControl( - currentZoom = zoomRatio, - minZoom = minZoom, - maxZoom = maxZoom, - onZoomSelected = { zoom -> - cameraManager.setZoom(zoom) - haptics.click() + if (isGalleryPreview) { + // Gallery preview mode: Cancel | Apply + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(48.dp) + ) { + // Cancel button + IconButton( + onClick = { + galleryBitmap?.recycle() + galleryBitmap = null + galleryImageUri = null + }, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0x80000000)) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) } - ) - Spacer(modifier = Modifier.height(24.dp)) - } - - // Capture button - CaptureButton( - isCapturing = isCapturing, - onClick = { - if (!isCapturing) { - isCapturing = true - haptics.heavyClick() - - scope.launch { - val imageCapture = cameraManager.imageCapture - if (imageCapture != null) { - val result = captureHandler.capturePhoto( - imageCapture = imageCapture, - executor = cameraManager.getExecutor(), - blurParams = blurParams, - deviceRotation = currentRotation, - location = currentLocation, - isFrontCamera = isFrontCamera - ) - - when (result) { - is SaveResult.Success -> { - haptics.success() - showSaveSuccess = true - delay(1500) - showSaveSuccess = false - } - is SaveResult.Error -> { - haptics.error() - showSaveError = result.message - delay(2000) - showSaveError = null + // Apply button + IconButton( + onClick = { + val uri = galleryImageUri ?: return@IconButton + if (!isCapturing) { + isCapturing = true + haptics.heavyClick() + scope.launch { + val result = captureHandler.processExistingImage( + imageUri = uri, + blurParams = blurParams, + location = currentLocation + ) + when (result) { + is SaveResult.Success -> { + haptics.success() + lastThumbnailBitmap?.recycle() + lastThumbnailBitmap = result.thumbnail + lastSavedUri = result.uri + showSaveSuccess = true + delay(1500) + showSaveSuccess = false + } + is SaveResult.Error -> { + haptics.error() + showSaveError = result.message + delay(2000) + showSaveError = null + } } + galleryBitmap?.recycle() + galleryBitmap = null + galleryImageUri = null + isCapturing = false } } - isCapturing = false - } + }, + enabled = !isCapturing, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0xFFFFB300)) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Apply effect", + tint = Color.Black, + modifier = Modifier.size(28.dp) + ) } } - ) + } else { + // Camera mode: Zoom presets + Gallery | Capture | Spacer + // Zoom presets (only show for back camera) + if (!isFrontCamera) { + ZoomControl( + currentZoom = zoomRatio, + minZoom = minZoom, + maxZoom = maxZoom, + onZoomSelected = { zoom -> + cameraManager.setZoom(zoom) + haptics.click() + } + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + + // Gallery button | Capture button | Spacer for symmetry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Gallery picker button + IconButton( + onClick = { + if (!isCapturing) { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + }, + enabled = !isCapturing, + modifier = Modifier.size(52.dp) + ) { + Icon( + imageVector = Icons.Default.PhotoLibrary, + contentDescription = "Pick from gallery", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + + // Capture button + CaptureButton( + isCapturing = isCapturing, + onClick = { + if (!isCapturing) { + isCapturing = true + haptics.heavyClick() + + scope.launch { + val imageCapture = cameraManager.imageCapture + if (imageCapture != null) { + val result = captureHandler.capturePhoto( + imageCapture = imageCapture, + executor = cameraManager.getExecutor(), + blurParams = blurParams, + deviceRotation = currentRotation, + location = currentLocation, + isFrontCamera = isFrontCamera + ) + + when (result) { + is SaveResult.Success -> { + haptics.success() + lastThumbnailBitmap?.recycle() + lastThumbnailBitmap = result.thumbnail + lastSavedUri = result.uri + showSaveSuccess = true + delay(1500) + showSaveSuccess = false + } + is SaveResult.Error -> { + haptics.error() + showSaveError = result.message + delay(2000) + showSaveError = null + } + } + } + isCapturing = false + } + } + } + ) + + // Spacer for visual symmetry with gallery button + Spacer(modifier = Modifier.size(52.dp)) + } + } } + // Last captured photo thumbnail (hidden in gallery preview mode) + if (!isGalleryPreview) LastPhotoThumbnail( + thumbnail = lastThumbnailBitmap, + onTap = { + lastSavedUri?.let { uri -> + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "image/jpeg") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(intent) + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(bottom = 48.dp, end = 16.dp) + ) + // Success indicator AnimatedVisibility( visible = showSaveSuccess, @@ -484,6 +691,7 @@ private fun ControlPanel( onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } ) } + } } @@ -552,3 +760,34 @@ private fun CaptureButton( ) } } + +/** + * Rounded thumbnail of the last captured photo. + * Tapping opens the image in the default photo viewer. + */ +@Composable +private fun LastPhotoThumbnail( + thumbnail: Bitmap?, + onTap: () -> Unit, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = thumbnail != null, + enter = fadeIn() + scaleIn(initialScale = 0.6f), + exit = fadeOut(), + modifier = modifier + ) { + thumbnail?.let { bmp -> + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Last captured photo", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(10.dp)) + .border(2.dp, Color.White, RoundedCornerShape(10.dp)) + .clickable(onClick = onTap) + ) + } + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt index d356512..c2ca806 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt @@ -9,9 +9,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CameraRear -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -90,28 +87,3 @@ private fun LensButton( ) } } - -/** - * Simple camera flip button (for future front camera support). - */ -@Composable -fun CameraFlipButton( - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .size(48.dp) - .clip(CircleShape) - .background(Color(0x80000000)) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.CameraRear, - contentDescription = "Switch Camera", - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } -} diff --git a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt index 551ede7..801b275 100644 --- a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt +++ b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt @@ -1,98 +1,55 @@ package no.naiv.tiltshift.util import android.content.Context -import android.os.Build import android.os.VibrationEffect -import android.os.Vibrator import android.os.VibratorManager -import android.view.HapticFeedbackConstants -import android.view.View /** * Provides haptic feedback for user interactions. */ class HapticFeedback(private val context: Context) { - private val vibrator: Vibrator by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager - vibratorManager.defaultVibrator - } else { - @Suppress("DEPRECATION") - context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator - } + private val vibrator by lazy { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator } /** * Light tick for UI feedback (button press, slider change). */ fun tick() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(10L) - } + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) } /** * Click feedback for confirmations. */ fun click() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(20L) - } + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) } /** * Heavy click for important actions (photo capture). */ fun heavyClick() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(40L) - } + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) } /** * Success feedback pattern. */ fun success() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val timings = longArrayOf(0, 30, 50, 30) - val amplitudes = intArrayOf(0, 100, 0, 200) - vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(longArrayOf(0, 30, 50, 30), -1) - } + val timings = longArrayOf(0, 30, 50, 30) + val amplitudes = intArrayOf(0, 100, 0, 200) + vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) } /** * Error feedback pattern. */ fun error() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val timings = longArrayOf(0, 50, 30, 50, 30, 50) - val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150) - vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(longArrayOf(0, 50, 30, 50, 30, 50), -1) - } - } - - companion object { - /** - * Use system haptic feedback on a View for standard interactions. - */ - fun performHapticFeedback(view: View, feedbackConstant: Int = HapticFeedbackConstants.VIRTUAL_KEY) { - view.performHapticFeedback(feedbackConstant) - } + val timings = longArrayOf(0, 50, 30, 50, 30, 50) + val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150) + vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) } }