diff --git a/.gitignore b/.gitignore index 0250fef..778ebeb 100644 --- a/.gitignore +++ b/.gitignore @@ -42,9 +42,8 @@ captures/ *.iws # Keystore files -# Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore +*.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 6961041..8cd08b7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ + val captureResult = suspendCancellableCoroutine { continuation -> imageCapture.takePicture( executor, object : ImageCapture.OnImageCapturedCallback() { @@ -78,7 +79,7 @@ class ImageCaptureHandler( if (currentBitmap == null) { continuation.resume( - SaveResult.Error("Failed to convert image") as Any + CaptureOutcome.Failed(SaveResult.Error("Failed to convert image")) ) return } @@ -86,25 +87,23 @@ 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(ProcessedCapture(original, processedBitmap)) + continuation.resume(CaptureOutcome.Processed(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 + CaptureOutcome.Failed(SaveResult.Error("Failed to process image. Please try again.", e)) ) } } override fun onError(exception: ImageCaptureException) { + Log.e(TAG, "Image capture failed", exception) continuation.resume( - SaveResult.Error( - "Capture failed: ${exception.message}", exception - ) as Any + CaptureOutcome.Failed(SaveResult.Error("Failed to capture photo. Please try again.", exception)) ) } } @@ -112,28 +111,29 @@ class ImageCaptureHandler( } // Phase 2: save both original and processed to disk (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 - ) - if (result is SaveResult.Success) { - result.copy(thumbnail = thumbnail) - } else { - thumbnail?.recycle() - result + 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() } - } finally { - captureResult.originalBitmap.recycle() - captureResult.processedBitmap.recycle() } } - - return captureResult as SaveResult } /** @@ -238,9 +238,12 @@ 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("Processing failed: ${e.message}", e) + SaveResult.Error("Failed to process image. Please try again.", e) } finally { originalBitmap?.recycle() processedBitmap?.recycle() @@ -248,13 +251,33 @@ class ImageCaptureHandler( } /** - * Loads a bitmap from a content URI. + * Loads a bitmap from a content URI with dimension bounds checking + * to prevent OOM from extremely large images. */ 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) + BitmapFactory.decodeStream(stream, null, options) } + + // 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 4bbe9b5..083f6cd 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.text.SimpleDateFormat -import java.util.Date +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter import java.util.Locale @@ -23,7 +23,6 @@ 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() @@ -39,7 +38,7 @@ class PhotoSaver(private val context: Context) { private const val TAG = "PhotoSaver" } - private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + private val fileNameFormat = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss", Locale.US) /** * Saves a bitmap with the tilt-shift effect to the gallery. @@ -49,7 +48,7 @@ class PhotoSaver(private val context: Context) { orientation: Int, location: Location? ): SaveResult = withContext(Dispatchers.IO) { - val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" + val fileName = "TILTSHIFT_${fileNameFormat.format(LocalDateTime.now())}.jpg" saveSingleBitmap(fileName, bitmap, orientation, location) } @@ -64,7 +63,7 @@ class PhotoSaver(private val context: Context) { orientation: Int, location: Location? ): SaveResult = withContext(Dispatchers.IO) { - val timestamp = fileNameFormat.format(Date()) + val timestamp = fileNameFormat.format(LocalDateTime.now()) val processedFileName = "TILTSHIFT_${timestamp}.jpg" val originalFileName = "ORIGINAL_${timestamp}.jpg" @@ -112,10 +111,13 @@ class PhotoSaver(private val context: Context) { contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) contentResolver.update(uri, contentValues, null, null) - val path = getPathFromUri(uri) - SaveResult.Success(uri, path) + 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) } catch (e: Exception) { - SaveResult.Error("Failed to save photo: ${e.message}", e) + Log.e(TAG, "Failed to save photo", e) + SaveResult.Error("Failed to save photo. Please try again.", e) } } @@ -129,8 +131,8 @@ class PhotoSaver(private val context: Context) { exif.setAttribute(ExifInterface.TAG_MAKE, "Android") exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL) - val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US) - val dateTime = dateFormat.format(Date()) + val exifDateFormat = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss", Locale.US) + val dateTime = exifDateFormat.format(LocalDateTime.now()) exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime) exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime) @@ -145,14 +147,4 @@ 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 a01d11e..f9d851e 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -6,6 +6,7 @@ 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 @@ -359,9 +360,10 @@ fun CameraScreen( // Cancel button IconButton( onClick = { - galleryBitmap?.recycle() + val oldBitmap = galleryBitmap galleryBitmap = null galleryImageUri = null + oldBitmap?.recycle() }, modifier = Modifier .size(56.dp) @@ -392,9 +394,10 @@ fun CameraScreen( when (result) { is SaveResult.Success -> { haptics.success() - lastThumbnailBitmap?.recycle() + val oldThumb = lastThumbnailBitmap lastThumbnailBitmap = result.thumbnail lastSavedUri = result.uri + oldThumb?.recycle() showSaveSuccess = true delay(1500) showSaveSuccess = false @@ -406,9 +409,10 @@ fun CameraScreen( showSaveError = null } } - galleryBitmap?.recycle() + val oldGalleryBitmap = galleryBitmap galleryBitmap = null galleryImageUri = null + oldGalleryBitmap?.recycle() isCapturing = false } } @@ -492,9 +496,10 @@ fun CameraScreen( when (result) { is SaveResult.Success -> { haptics.success() - lastThumbnailBitmap?.recycle() + val oldThumb = lastThumbnailBitmap lastThumbnailBitmap = result.thumbnail lastSavedUri = result.uri + oldThumb?.recycle() showSaveSuccess = true delay(1500) showSaveSuccess = false @@ -524,11 +529,15 @@ fun CameraScreen( thumbnail = lastThumbnailBitmap, onTap = { lastSavedUri?.let { uri -> - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "image/jpeg") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + 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) } - 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 801b275..0eb119a 100644 --- a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt +++ b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt @@ -2,37 +2,45 @@ 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) { - private val vibrator by lazy { - val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager - vibratorManager.defaultVibrator + 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 } /** * Light tick for UI feedback (button press, slider change). */ fun tick() { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) + vibrateOrLog(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) } /** * Click feedback for confirmations. */ fun click() { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) + vibrateOrLog(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) } /** * Heavy click for important actions (photo capture). */ fun heavyClick() { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) + vibrateOrLog(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) } /** @@ -41,7 +49,7 @@ class HapticFeedback(private val context: Context) { fun success() { val timings = longArrayOf(0, 30, 50, 30) val amplitudes = intArrayOf(0, 100, 0, 200) - vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) + vibrateOrLog(VibrationEffect.createWaveform(timings, amplitudes, -1)) } /** @@ -50,6 +58,14 @@ 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) - vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) + 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) + } } } 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 1065c45..f8d7b53 100644 --- a/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt +++ b/app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt @@ -5,6 +5,7 @@ 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 @@ -21,6 +22,10 @@ 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) @@ -61,6 +66,7 @@ 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 37ee1eb..794ea4f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,5 @@ distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists