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