diff --git a/.gitignore b/.gitignore index 778ebeb..0250fef 100644 --- a/.gitignore +++ b/.gitignore @@ -42,8 +42,9 @@ captures/ *.iws # Keystore files -*.jks -*.keystore +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8cd08b7..6961041 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ { continuation -> + val captureResult = suspendCancellableCoroutine { continuation -> imageCapture.takePicture( executor, object : ImageCapture.OnImageCapturedCallback() { @@ -79,7 +78,7 @@ class ImageCaptureHandler( if (currentBitmap == null) { continuation.resume( - CaptureOutcome.Failed(SaveResult.Error("Failed to convert image")) + SaveResult.Error("Failed to convert image") as Any ) return } @@ -87,23 +86,25 @@ class ImageCaptureHandler( currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera) val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams) + // Keep originalBitmap alive — both are recycled after saving val original = currentBitmap currentBitmap = null - continuation.resume(CaptureOutcome.Processed(original, processedBitmap)) + continuation.resume(ProcessedCapture(original, processedBitmap)) } catch (e: Exception) { Log.e(TAG, "Image processing failed", e) currentBitmap?.recycle() continuation.resume( - CaptureOutcome.Failed(SaveResult.Error("Failed to process image. Please try again.", e)) + SaveResult.Error("Capture failed: ${e.message}", e) as Any ) } } override fun onError(exception: ImageCaptureException) { - Log.e(TAG, "Image capture failed", exception) continuation.resume( - CaptureOutcome.Failed(SaveResult.Error("Failed to capture photo. Please try again.", exception)) + SaveResult.Error( + "Capture failed: ${exception.message}", exception + ) as Any ) } } @@ -111,29 +112,28 @@ class ImageCaptureHandler( } // Phase 2: save both original and processed to disk (suspend-safe) - return when (captureResult) { - is CaptureOutcome.Failed -> captureResult.result - is CaptureOutcome.Processed -> { - try { - val thumbnail = createThumbnail(captureResult.processed) - val result = photoSaver.saveBitmapPair( - original = captureResult.original, - processed = captureResult.processed, - orientation = ExifInterface.ORIENTATION_NORMAL, - location = location - ) - if (result is SaveResult.Success) { - result.copy(thumbnail = thumbnail) - } else { - thumbnail?.recycle() - result - } - } finally { - captureResult.original.recycle() - captureResult.processed.recycle() + 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 + ) + if (result is SaveResult.Success) { + result.copy(thumbnail = thumbnail) + } else { + thumbnail?.recycle() + result } + } finally { + captureResult.originalBitmap.recycle() + captureResult.processedBitmap.recycle() } } + + return captureResult as SaveResult } /** @@ -238,12 +238,9 @@ class ImageCaptureHandler( thumbnail?.recycle() result } - } catch (e: SecurityException) { - Log.e(TAG, "Permission denied while processing gallery image", e) - SaveResult.Error("Permission denied. Please grant access and try again.", e) } catch (e: Exception) { Log.e(TAG, "Gallery image processing failed", e) - SaveResult.Error("Failed to process image. Please try again.", e) + SaveResult.Error("Processing failed: ${e.message}", e) } finally { originalBitmap?.recycle() processedBitmap?.recycle() @@ -251,33 +248,13 @@ class ImageCaptureHandler( } /** - * Loads a bitmap from a content URI with dimension bounds checking - * to prevent OOM from extremely large images. + * Loads a bitmap from a content URI. */ private fun loadBitmapFromUri(uri: Uri): Bitmap? { return try { - // First pass: read dimensions without decoding pixels - val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } context.contentResolver.openInputStream(uri)?.use { stream -> - BitmapFactory.decodeStream(stream, null, options) + BitmapFactory.decodeStream(stream) } - - // Calculate sample size to stay within MAX_IMAGE_DIMENSION - val maxDim = maxOf(options.outWidth, options.outHeight) - val sampleSize = if (maxDim > MAX_IMAGE_DIMENSION) { - var sample = 1 - while (maxDim / sample > MAX_IMAGE_DIMENSION) sample *= 2 - sample - } else 1 - - // Second pass: decode with sample size - val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize } - context.contentResolver.openInputStream(uri)?.use { stream -> - BitmapFactory.decodeStream(stream, null, decodeOptions) - } - } catch (e: SecurityException) { - Log.e(TAG, "Permission denied loading bitmap from URI", e) - null } catch (e: Exception) { Log.e(TAG, "Failed to load bitmap from URI", e) null 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 083f6cd..4bbe9b5 100644 --- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -12,8 +12,8 @@ import android.util.Log import androidx.exifinterface.media.ExifInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter +import java.text.SimpleDateFormat +import java.util.Date import java.util.Locale @@ -23,6 +23,7 @@ import java.util.Locale sealed class SaveResult { data class Success( val uri: Uri, + val path: String, val originalUri: Uri? = null, val thumbnail: android.graphics.Bitmap? = null ) : SaveResult() @@ -38,7 +39,7 @@ class PhotoSaver(private val context: Context) { private const val TAG = "PhotoSaver" } - private val fileNameFormat = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss", Locale.US) + private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) /** * Saves a bitmap with the tilt-shift effect to the gallery. @@ -48,7 +49,7 @@ class PhotoSaver(private val context: Context) { orientation: Int, location: Location? ): SaveResult = withContext(Dispatchers.IO) { - val fileName = "TILTSHIFT_${fileNameFormat.format(LocalDateTime.now())}.jpg" + val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" saveSingleBitmap(fileName, bitmap, orientation, location) } @@ -63,7 +64,7 @@ class PhotoSaver(private val context: Context) { orientation: Int, location: Location? ): SaveResult = withContext(Dispatchers.IO) { - val timestamp = fileNameFormat.format(LocalDateTime.now()) + val timestamp = fileNameFormat.format(Date()) val processedFileName = "TILTSHIFT_${timestamp}.jpg" val originalFileName = "ORIGINAL_${timestamp}.jpg" @@ -111,13 +112,10 @@ class PhotoSaver(private val context: Context) { contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) contentResolver.update(uri, contentValues, null, null) - SaveResult.Success(uri) - } catch (e: SecurityException) { - Log.e(TAG, "Storage permission denied", e) - SaveResult.Error("Storage permission was revoked. Please grant it in Settings.", e) + val path = getPathFromUri(uri) + SaveResult.Success(uri, path) } catch (e: Exception) { - Log.e(TAG, "Failed to save photo", e) - SaveResult.Error("Failed to save photo. Please try again.", e) + SaveResult.Error("Failed to save photo: ${e.message}", e) } } @@ -131,8 +129,8 @@ class PhotoSaver(private val context: Context) { exif.setAttribute(ExifInterface.TAG_MAKE, "Android") exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL) - val exifDateFormat = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss", Locale.US) - val dateTime = exifDateFormat.format(LocalDateTime.now()) + val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US) + val dateTime = dateFormat.format(Date()) exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime) exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime) @@ -147,4 +145,14 @@ class PhotoSaver(private val context: Context) { } } + private fun getPathFromUri(uri: Uri): String { + val projection = arrayOf(MediaStore.Images.Media.DATA) + context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + return cursor.getString(columnIndex) ?: uri.toString() + } + } + return uri.toString() + } } 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 f9d851e..a01d11e 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -6,7 +6,6 @@ import android.graphics.SurfaceTexture import android.location.Location import android.net.Uri import android.opengl.GLSurfaceView -import android.util.Log import android.view.Surface import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -360,10 +359,9 @@ fun CameraScreen( // Cancel button IconButton( onClick = { - val oldBitmap = galleryBitmap + galleryBitmap?.recycle() galleryBitmap = null galleryImageUri = null - oldBitmap?.recycle() }, modifier = Modifier .size(56.dp) @@ -394,10 +392,9 @@ fun CameraScreen( when (result) { is SaveResult.Success -> { haptics.success() - val oldThumb = lastThumbnailBitmap + lastThumbnailBitmap?.recycle() lastThumbnailBitmap = result.thumbnail lastSavedUri = result.uri - oldThumb?.recycle() showSaveSuccess = true delay(1500) showSaveSuccess = false @@ -409,10 +406,9 @@ fun CameraScreen( showSaveError = null } } - val oldGalleryBitmap = galleryBitmap + galleryBitmap?.recycle() galleryBitmap = null galleryImageUri = null - oldGalleryBitmap?.recycle() isCapturing = false } } @@ -496,10 +492,9 @@ fun CameraScreen( when (result) { is SaveResult.Success -> { haptics.success() - val oldThumb = lastThumbnailBitmap + lastThumbnailBitmap?.recycle() lastThumbnailBitmap = result.thumbnail lastSavedUri = result.uri - oldThumb?.recycle() showSaveSuccess = true delay(1500) showSaveSuccess = false @@ -529,15 +524,11 @@ fun CameraScreen( thumbnail = lastThumbnailBitmap, onTap = { lastSavedUri?.let { uri -> - try { - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "image/jpeg") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity(intent) - } catch (e: android.content.ActivityNotFoundException) { - Log.w("CameraScreen", "No activity found to view image", e) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "image/jpeg") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } + context.startActivity(intent) } }, modifier = Modifier 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 0eb119a..801b275 100644 --- a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt +++ b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt @@ -2,45 +2,37 @@ package no.naiv.tiltshift.util import android.content.Context import android.os.VibrationEffect -import android.os.Vibrator import android.os.VibratorManager -import android.util.Log /** * Provides haptic feedback for user interactions. - * Gracefully degrades on devices without vibration hardware. */ class HapticFeedback(private val context: Context) { - companion object { - private const val TAG = "HapticFeedback" - } - - private val vibrator: Vibrator? by lazy { - val vibratorManager = - context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager - vibratorManager?.defaultVibrator + 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() { - vibrateOrLog(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) } /** * Click feedback for confirmations. */ fun click() { - vibrateOrLog(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) } /** * Heavy click for important actions (photo capture). */ fun heavyClick() { - vibrateOrLog(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) } /** @@ -49,7 +41,7 @@ class HapticFeedback(private val context: Context) { fun success() { val timings = longArrayOf(0, 30, 50, 30) val amplitudes = intArrayOf(0, 100, 0, 200) - vibrateOrLog(VibrationEffect.createWaveform(timings, amplitudes, -1)) + vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) } /** @@ -58,14 +50,6 @@ class HapticFeedback(private val context: Context) { fun error() { val timings = longArrayOf(0, 50, 30, 50, 30, 50) val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150) - vibrateOrLog(VibrationEffect.createWaveform(timings, amplitudes, -1)) - } - - private fun vibrateOrLog(effect: VibrationEffect) { - try { - vibrator?.vibrate(effect) - } catch (e: Exception) { - Log.w(TAG, "Haptic feedback failed", e) - } + vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) } } diff --git a/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt b/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt index f8d7b53..1065c45 100644 --- a/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt +++ b/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.pm.PackageManager import android.location.Location import android.os.Looper -import android.util.Log import androidx.core.content.ContextCompat import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback @@ -22,10 +21,6 @@ import kotlinx.coroutines.flow.callbackFlow */ class LocationProvider(private val context: Context) { - companion object { - private const val TAG = "LocationProvider" - } - private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) @@ -66,7 +61,6 @@ class LocationProvider(private val context: Context) { location?.let { trySend(it) } } } catch (e: SecurityException) { - Log.w(TAG, "Location permission revoked at runtime", e) trySend(null) } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 794ea4f..37ee1eb 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,4 @@ distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip -distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists