Security-harden PhotoSaver

- Catch SecurityException separately for storage permission revocation
- Replace raw e.message with generic user-friendly error strings
- Replace thread-unsafe SimpleDateFormat with java.time.DateTimeFormatter
  to prevent filename collisions under concurrent saves on Dispatchers.IO
- Remove deprecated MediaStore.Images.Media.DATA column query and the
  path field from SaveResult.Success (unreliable on scoped storage)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 11:56:13 +01:00
commit cc133072fc

View file

@ -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()
}
}