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