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 androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat import java.time.LocalDateTime
import java.util.Date import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
@ -23,7 +23,6 @@ import java.util.Locale
sealed class SaveResult { sealed class SaveResult {
data class Success( data class Success(
val uri: Uri, val uri: Uri,
val path: String,
val originalUri: Uri? = null, val originalUri: Uri? = null,
val thumbnail: android.graphics.Bitmap? = null val thumbnail: android.graphics.Bitmap? = null
) : SaveResult() ) : SaveResult()
@ -39,7 +38,7 @@ class PhotoSaver(private val context: Context) {
private const val TAG = "PhotoSaver" 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. * Saves a bitmap with the tilt-shift effect to the gallery.
@ -49,7 +48,7 @@ class PhotoSaver(private val context: Context) {
orientation: Int, orientation: Int,
location: Location? location: Location?
): SaveResult = withContext(Dispatchers.IO) { ): SaveResult = withContext(Dispatchers.IO) {
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" val fileName = "TILTSHIFT_${fileNameFormat.format(LocalDateTime.now())}.jpg"
saveSingleBitmap(fileName, bitmap, orientation, location) saveSingleBitmap(fileName, bitmap, orientation, location)
} }
@ -64,7 +63,7 @@ class PhotoSaver(private val context: Context) {
orientation: Int, orientation: Int,
location: Location? location: Location?
): SaveResult = withContext(Dispatchers.IO) { ): SaveResult = withContext(Dispatchers.IO) {
val timestamp = fileNameFormat.format(Date()) val timestamp = fileNameFormat.format(LocalDateTime.now())
val processedFileName = "TILTSHIFT_${timestamp}.jpg" val processedFileName = "TILTSHIFT_${timestamp}.jpg"
val originalFileName = "ORIGINAL_${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) contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null) contentResolver.update(uri, contentValues, null, null)
val path = getPathFromUri(uri) SaveResult.Success(uri)
SaveResult.Success(uri, path) } 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) { } 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_MAKE, "Android")
exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL) exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL)
val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US) val exifDateFormat = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss", Locale.US)
val dateTime = dateFormat.format(Date()) val dateTime = exifDateFormat.format(LocalDateTime.now())
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime) exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, 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()
}
} }