diff --git a/README.md b/README.md index 7668f6c..2fabd70 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 15 (API 35) or higher +- Android 8.0 (API 26) or higher - Device with camera - OpenGL ES 2.0 support @@ -66,7 +66,8 @@ app/src/main/java/no/naiv/tiltshift/ │ ├── ZoomControl.kt # Zoom UI component │ └── LensSwitcher.kt # Lens selection UI ├── storage/ -│ └── PhotoSaver.kt # MediaStore integration & EXIF handling +│ ├── PhotoSaver.kt # MediaStore integration +│ └── ExifWriter.kt # EXIF metadata 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 b2d4e6c..f70d6a4 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -2,7 +2,6 @@ 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 @@ -25,10 +24,6 @@ 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 @@ -37,13 +32,6 @@ 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() @@ -94,11 +82,10 @@ class CameraManager(private val context: Context) { .setResolutionSelector(resolutionSelector) .build() - // Image capture use case - val captureBuilder = ImageCapture.Builder() + // Image capture use case for high-res photos + imageCapture = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) - - imageCapture = captureBuilder.build() + .build() // Select camera based on front/back preference val cameraSelector = if (_isFrontCamera.value) { @@ -130,8 +117,8 @@ class CameraManager(private val context: Context) { } } catch (e: Exception) { - Log.e(TAG, "Camera binding failed", e) - _error.value = "Camera failed: ${e.message}" + // Camera binding failed + e.printStackTrace() } } 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 eea6c7f..b7a8d6b 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -5,15 +5,11 @@ 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 @@ -32,19 +28,12 @@ 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 originalBitmap: Bitmap, - val processedBitmap: Bitmap - ) + private class ProcessedCapture(val bitmap: Bitmap) /** * Captures a photo and applies the tilt-shift effect. @@ -69,31 +58,26 @@ class ImageCaptureHandler( executor, object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(imageProxy: ImageProxy) { - var currentBitmap: Bitmap? = null try { val imageRotation = imageProxy.imageInfo.rotationDegrees - currentBitmap = imageProxyToBitmap(imageProxy) + var bitmap = imageProxyToBitmap(imageProxy) imageProxy.close() - if (currentBitmap == null) { + if (bitmap == null) { continuation.resume( SaveResult.Error("Failed to convert image") as Any ) return } - currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera) + bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera) - val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams) - // Keep originalBitmap alive — both are recycled after saving - val original = currentBitmap - currentBitmap = null + val processedBitmap = applyTiltShiftEffect(bitmap, blurParams) + bitmap.recycle() - continuation.resume(ProcessedCapture(original, processedBitmap)) + continuation.resume(ProcessedCapture(processedBitmap)) } catch (e: Exception) { - Log.e(TAG, "Image processing failed", e) - currentBitmap?.recycle() continuation.resume( SaveResult.Error("Capture failed: ${e.message}", e) as Any ) @@ -111,46 +95,22 @@ class ImageCaptureHandler( ) } - // Phase 2: save both original and processed to disk (suspend-safe) + // Phase 2: save to disk in the caller's coroutine context (suspend-safe) if (captureResult is ProcessedCapture) { return try { - val thumbnail = createThumbnail(captureResult.processedBitmap) - val result = photoSaver.saveBitmapPair( - original = captureResult.originalBitmap, - processed = captureResult.processedBitmap, - orientation = ExifInterface.ORIENTATION_NORMAL, - location = location + photoSaver.saveBitmap( + captureResult.bitmap, + ExifInterface.ORIENTATION_NORMAL, + location ) - if (result is SaveResult.Success) { - result.copy(thumbnail = thumbnail) - } else { - thumbnail?.recycle() - result - } } finally { - captureResult.originalBitmap.recycle() - captureResult.processedBitmap.recycle() + captureResult.bitmap.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. */ @@ -189,200 +149,66 @@ 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 - var result: Bitmap? = null - var scaled: Bitmap? = null - var blurred: Bitmap? = null - var blurredFullSize: Bitmap? = null - var mask: Bitmap? = null + // Create output bitmap + val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - try { - result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + // 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 - val scaleFactor = 4 - val blurredWidth = width / scaleFactor - val blurredHeight = height / scaleFactor + // Create scaled bitmap for blur + val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) - scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) + // Apply stack blur (fast approximation) + val blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) + scaled.recycle() - blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) - scaled.recycle() - scaled = null + // Scale blurred back up + val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true) + blurred.recycle() - blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true) - blurred.recycle() - blurred = null + // Create gradient mask based on tilt-shift parameters + val mask = createGradientMask(width, height, params) - mask = createGradientMask(width, height, params) + // Composite: blend original with blurred based on mask + val pixels = IntArray(width * height) + val blurredPixels = IntArray(width * height) + val maskPixels = IntArray(width * height) - // Composite: blend original with blurred based on mask - val pixels = IntArray(width * height) - val blurredPixels = IntArray(width * height) - val maskPixels = IntArray(width * height) + source.getPixels(pixels, 0, width, 0, 0, width, height) + blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height) + mask.getPixels(maskPixels, 0, width, 0, 0, width, height) - source.getPixels(pixels, 0, width, 0, 0, width, height) - blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height) - mask.getPixels(maskPixels, 0, width, 0, 0, width, height) + blurredFullSize.recycle() + mask.recycle() - blurredFullSize.recycle() - blurredFullSize = null - mask.recycle() - mask = null + 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 - 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 + val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt() + val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt() + val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt() - 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() + pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b } + + 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 7fb7edc..1939561 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt @@ -88,4 +88,11 @@ 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 new file mode 100644 index 0000000..4a7431a --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt @@ -0,0 +1,96 @@ +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 4bbe9b5..968ef55 100644 --- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -3,30 +3,28 @@ 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, - val originalUri: Uri? = null, - val thumbnail: android.graphics.Bitmap? = null - ) : SaveResult() + data class Success(val uri: Uri, val path: String) : SaveResult() data class Error(val message: String, val exception: Exception? = null) : SaveResult() } @@ -35,9 +33,7 @@ sealed class SaveResult { */ class PhotoSaver(private val context: Context) { - companion object { - private const val TAG = "PhotoSaver" - } + private val exifWriter = ExifWriter() private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) @@ -49,68 +45,100 @@ class PhotoSaver(private val context: Context) { orientation: Int, location: Location? ): SaveResult = withContext(Dispatchers.IO) { - val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" - saveSingleBitmap(fileName, bitmap, orientation, location) - } + try { + val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" - /** - * 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 saveBitmapPair( - original: Bitmap, - processed: Bitmap, - orientation: Int, - location: Location? - ): SaveResult = withContext(Dispatchers.IO) { - 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 { + // 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()) - put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift") - put(MediaStore.Images.Media.IS_PENDING, 1) + + 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) + } + } + + /** + * Saves a JPEG file (from CameraX ImageCapture) to the gallery. + */ + suspend fun saveJpegFile( + sourceFile: File, + orientation: Int, + location: Location? + ): SaveResult = withContext(Dispatchers.IO) { + try { + val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" + + 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) + } } val contentResolver = context.contentResolver val uri = contentResolver.insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues - ) ?: return SaveResult.Error("Failed to create MediaStore entry") + ) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry") + // Copy file to MediaStore contentResolver.openOutputStream(uri)?.use { outputStream -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream) - } ?: return SaveResult.Error("Failed to open output stream") + sourceFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } ?: return@withContext SaveResult.Error("Failed to open output stream") + // Write EXIF data writeExifToUri(uri, orientation, location) - contentValues.clear() - contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) - contentResolver.update(uri, contentValues, null, null) + 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() val path = getPathFromUri(uri) SaveResult.Success(uri, path) @@ -141,7 +169,7 @@ class PhotoSaver(private val context: Context) { exif.saveAttributes() } } catch (e: Exception) { - Log.w(TAG, "Failed to write EXIF data", e) + e.printStackTrace() } } 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 a01d11e..c667bca 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -1,20 +1,15 @@ 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 @@ -30,14 +25,10 @@ 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 @@ -55,11 +46,8 @@ 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 @@ -109,53 +97,13 @@ 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) { @@ -195,8 +143,6 @@ fun CameraScreen( onDispose { cameraManager.release() renderer?.release() - lastThumbnailBitmap?.recycle() - galleryBitmap?.recycle() } } @@ -205,39 +151,25 @@ fun CameraScreen( .fillMaxSize() .background(Color.Black) ) { - // 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) + // OpenGL Surface for camera preview with effect + AndroidView( + factory = { ctx -> + GLSurfaceView(ctx).apply { + setEGLContextClientVersion(2) - val newRenderer = TiltShiftRenderer(ctx) { st -> - surfaceTexture = st - } - renderer = newRenderer - - setRenderer(newRenderer) - renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY - - glSurfaceView = this + val newRenderer = TiltShiftRenderer(ctx) { st -> + surfaceTexture = st } - }, - modifier = Modifier.fillMaxSize() - ) - } + renderer = newRenderer + + setRenderer(newRenderer) + renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY + + glSurfaceView = this + } + }, + modifier = Modifier.fillMaxSize() + ) // Tilt-shift overlay (gesture handling + visualization) TiltShiftOverlay( @@ -247,10 +179,8 @@ fun CameraScreen( haptics.tick() }, onZoomChange = { zoomDelta -> - if (!isGalleryPreview) { - val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) - cameraManager.setZoom(newZoom) - } + val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) + cameraManager.setZoom(newZoom) }, modifier = Modifier.fillMaxSize() ) @@ -267,28 +197,22 @@ fun CameraScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - if (!isGalleryPreview) { - // Zoom indicator - ZoomIndicator(currentZoom = zoomRatio) - } else { - Spacer(modifier = Modifier.width(1.dp)) - } + // Zoom indicator + ZoomIndicator(currentZoom = zoomRatio) Row(verticalAlignment = Alignment.CenterVertically) { - if (!isGalleryPreview) { - // Camera flip button - IconButton( - onClick = { - cameraManager.switchCamera() - haptics.click() - } - ) { - Icon( - imageVector = Icons.Default.FlipCameraAndroid, - contentDescription = "Switch Camera", - tint = Color.White - ) + // Camera flip button + IconButton( + onClick = { + cameraManager.switchCamera() + haptics.click() } + ) { + Icon( + imageVector = Icons.Default.FlipCameraAndroid, + contentDescription = "Switch Camera", + tint = Color.White + ) } // Toggle controls button @@ -346,196 +270,65 @@ fun CameraScreen( modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() - .padding(bottom = 48.dp) - .systemGestureExclusion(), + .padding(bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - 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) - ) + // Zoom presets (only show for back camera) + if (!isFrontCamera) { + ZoomControl( + currentZoom = zoomRatio, + minZoom = minZoom, + maxZoom = maxZoom, + onZoomSelected = { zoom -> + cameraManager.setZoom(zoom) + haptics.click() } + ) - // 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 - } - } - }, - 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)) - } + Spacer(modifier = Modifier.height(24.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) + // 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 + } + } + } + isCapturing = false + } } - context.startActivity(intent) } - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .navigationBarsPadding() - .padding(bottom = 48.dp, end = 16.dp) - ) + ) + } // Success indicator AnimatedVisibility( @@ -691,7 +484,6 @@ private fun ControlPanel( onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } ) } - } } @@ -760,34 +552,3 @@ 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 c2ca806..d356512 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt @@ -9,6 +9,9 @@ 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 @@ -87,3 +90,28 @@ 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 801b275..551ede7 100644 --- a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt +++ b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt @@ -1,55 +1,98 @@ 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 by lazy { - val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager - vibratorManager.defaultVibrator + 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 + } } /** * Light tick for UI feedback (button press, slider change). */ fun tick() { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(10L) + } } /** * Click feedback for confirmations. */ fun click() { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(20L) + } } /** * Heavy click for important actions (photo capture). */ fun heavyClick() { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(40L) + } } /** * Success feedback pattern. */ fun success() { - val timings = longArrayOf(0, 30, 50, 30) - val amplitudes = intArrayOf(0, 100, 0, 200) - vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) + 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) + } } /** * Error feedback pattern. */ fun error() { - 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)) + 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) + } } }