234 lines
8.1 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|