Files
Ascently/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt

234 lines
8.1 KiB
Kotlin

package com.atridad.openclimb.utils
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import androidx.core.graphics.scale
object ImageUtils {
private const val IMAGES_DIR = "problem_images"
private const val MAX_IMAGE_SIZE = 1024
private const val IMAGE_QUALITY = 85
/**
* Creates the images directory if it doesn't exist
*/
private fun getImagesDirectory(context: Context): File {
val imagesDir = File(context.filesDir, IMAGES_DIR)
if (!imagesDir.exists()) {
imagesDir.mkdirs()
}
return imagesDir
}
/**
* Saves an image from URI to app's private storage with compression
* @param context Android context
* @param imageUri URI of the image to save
* @return The relative file path if successful, null otherwise
*/
fun saveImageFromUri(context: Context, imageUri: Uri): String? {
return try {
// Decode bitmap from a fresh stream to avoid mark/reset dependency
val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input ->
BitmapFactory.decodeStream(input)
} ?: return null
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
// Generate unique filename
val filename = "${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Clean up bitmaps
originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle()
}
compressedBitmap.recycle()
// Return relative path
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* Corrects image orientation based on EXIF data
*/
private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap {
return try {
val inputStream = context.contentResolver.openInputStream(imageUri)
inputStream?.use { input ->
val exif = android.media.ExifInterface(input)
val orientation = exif.getAttributeInt(
android.media.ExifInterface.TAG_ORIENTATION,
android.media.ExifInterface.ORIENTATION_NORMAL
)
val matrix = android.graphics.Matrix()
when (orientation) {
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
}
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
}
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
}
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
}
if (matrix.isIdentity) {
bitmap
} else {
android.graphics.Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)
}
} ?: bitmap
} catch (e: Exception) {
e.printStackTrace()
bitmap
}
}
/**
* Compresses and resizes an image bitmap
*/
@SuppressLint("UseKtx")
private fun compressImage(original: Bitmap): Bitmap {
val width = original.width
val height = original.height
// Calculate the scaling factor
val scaleFactor = if (width > height) {
if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f
} else {
if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f
}
return if (scaleFactor < 1f) {
val newWidth = (width * scaleFactor).toInt()
val newHeight = (height * scaleFactor).toInt()
original.scale(newWidth, newHeight)
} else {
original
}
}
/**
* Gets the full file path for an image
* @param context Android context
* @param relativePath The relative path returned by saveImageFromUri
* @return Full file path
*/
fun getImageFile(context: Context, relativePath: String): File {
return File(context.filesDir, relativePath)
}
/**
* Deletes an image file
* @param context Android context
* @param relativePath The relative path of the image to delete
* @return true if deleted successfully, false otherwise
*/
fun deleteImage(context: Context, relativePath: String): Boolean {
return try {
val file = getImageFile(context, relativePath)
file.delete()
} catch (e: Exception) {
e.printStackTrace()
false
}
}
/**
* Imports an image file from the import directory
* @param context Android context
* @param sourceFile The source image file to import
* @return The relative path in app storage, null if failed
*/
fun importImageFile(context: Context, sourceFile: File): String? {
return try {
if (!sourceFile.exists()) return null
// Generate new filename to avoid conflicts
val extension = sourceFile.extension.ifEmpty { "jpg" }
val filename = "${UUID.randomUUID()}.$extension"
val destFile = File(getImagesDirectory(context), filename)
sourceFile.copyTo(destFile, overwrite = true)
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* Gets all image files in the images directory
* @param context Android context
* @return List of relative paths for all images
*/
fun getAllImages(context: Context): List<String> {
return try {
val imagesDir = getImagesDirectory(context)
imagesDir.listFiles()?.mapNotNull { file ->
if (file.isFile && (file.extension == "jpg" || file.extension == "jpeg" || file.extension == "png")) {
"$IMAGES_DIR/${file.name}"
} else null
} ?: emptyList()
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
/**
* Cleans up orphaned images that are not referenced by any problems
* @param context Android context
* @param referencedPaths Set of image paths that are still being used
*/
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
try {
val allImages = getAllImages(context)
val orphanedImages = allImages.filter { it !in referencedPaths }
orphanedImages.forEach { path ->
deleteImage(context, path)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}