Files
mobile-android/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt
k1ngsterr1 88e2084f8b Refactor image handling and decoding logic
- Introduced a maximum bitmap decode dimension to prevent excessive memory usage.
- Enhanced base64 to bitmap conversion by extracting payload and applying EXIF orientation.
- Improved error handling for image downloads and decoding processes.
- Simplified media picker and chat input components to manage keyboard visibility more effectively.
- Updated color selection grid to adaptively adjust based on available width.
- Added safety checks for notifications and call actions in profile screens.
- Optimized bitmap decoding in uriToBase64Image to handle large images more efficiently.
2026-02-20 02:45:00 +05:00

357 lines
13 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.rosetta.messenger.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import android.util.Base64
import androidx.exifinterface.media.ExifInterface
import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
private const val TAG = "MediaUtils"
/**
* Утилиты для работы с медиафайлами
* Конвертация изображений в Base64, генерация blurhash
*/
object MediaUtils {
// Максимальный размер изображения для отправки (сжимаем большие изображения)
private const val MAX_IMAGE_SIZE = 1920
private const val IMAGE_QUALITY = 85
// Максимальный размер файла в МБ
// Android ограничение: файл + base64 + шифрование = ~3x памяти
// 20 МБ файл = ~60 МБ RAM, безопасно для большинства устройств
const val MAX_FILE_SIZE_MB = 20
/**
* Конвертировать изображение из Uri в Base64 PNG
* Автоматически сжимает большие изображения и учитывает EXIF ориентацию
*/
suspend fun uriToBase64Image(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) {
try {
// Читаем EXIF ориентацию
val orientation = getExifOrientation(context, uri)
val boundsOptions =
BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream, null, boundsOptions)
} ?: return@withContext null
if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) {
return@withContext null
}
val decodeOptions =
BitmapFactory.Options().apply {
inSampleSize =
calculateInSampleSize(
boundsOptions.outWidth,
boundsOptions.outHeight,
MAX_IMAGE_SIZE * 2
)
inPreferredConfig = Bitmap.Config.ARGB_8888
}
var bitmap =
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream, null, decodeOptions)
} ?: return@withContext null
// Применяем EXIF ориентацию (поворот/отражение)
bitmap = applyExifOrientation(bitmap, orientation)
// Масштабируем если слишком большое
val scaledBitmap = scaleDownBitmap(bitmap, MAX_IMAGE_SIZE)
if (scaledBitmap != bitmap) {
bitmap.recycle()
}
// Конвертируем в PNG Base64
val outputStream = ByteArrayOutputStream()
scaledBitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream)
val bytes = outputStream.toByteArray()
val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
scaledBitmap.recycle()
base64
} catch (e: Exception) {
null
} catch (e: OutOfMemoryError) {
null
}
}
/**
* Читает EXIF ориентацию из изображения
*/
private fun getExifOrientation(context: Context, uri: Uri): Int {
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val exif = ExifInterface(inputStream)
exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
} ?: ExifInterface.ORIENTATION_NORMAL
} catch (e: Exception) {
ExifInterface.ORIENTATION_NORMAL
}
}
/**
* Применяет EXIF ориентацию к Bitmap (поворот/отражение)
*/
private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.preScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(270f)
matrix.preScale(-1f, 1f)
}
else -> return bitmap // ORIENTATION_NORMAL или неизвестный
}
return try {
val rotatedBitmap = Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)
if (rotatedBitmap != bitmap) {
bitmap.recycle()
}
rotatedBitmap
} catch (e: Exception) {
bitmap
}
}
/**
* Генерировать Blurhash для изображения
*/
suspend fun generateBlurhash(context: Context, uri: Uri): String = withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(uri)
?: return@withContext ""
// Декодируем в маленький размер для blurhash
val options = BitmapFactory.Options().apply {
inSampleSize = 8 // Уменьшаем в 8 раз для быстрого расчета
}
val bitmap = BitmapFactory.decodeStream(inputStream, null, options)
inputStream.close()
if (bitmap == null) {
return@withContext ""
}
// Генерируем blurhash
val blurhash = BlurHash.encode(bitmap, 4, 3)
bitmap.recycle()
blurhash ?: ""
} catch (e: Exception) {
""
}
}
/**
* Конвертировать файл из Uri в Base64
*/
suspend fun uriToBase64File(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(uri)
?: return@withContext null
val bytes = inputStream.readBytes()
inputStream.close()
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val base64 = "data:$mimeType;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
base64
} catch (e: Exception) {
null
}
}
/**
* Получить размер файла из Uri
*/
fun getFileSize(context: Context, uri: Uri): Long {
return try {
context.contentResolver.openInputStream(uri)?.use {
it.available().toLong()
} ?: 0L
} catch (e: Exception) {
0L
}
}
/**
* Получить имя файла из Uri
*/
fun getFileName(context: Context, uri: Uri): String {
var name = "file"
try {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && cursor.moveToFirst()) {
name = cursor.getString(nameIndex) ?: "file"
}
}
} catch (e: Exception) {
}
return name
}
/**
* Масштабировать bitmap до максимального размера
*/
private fun scaleDownBitmap(bitmap: Bitmap, maxSize: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
if (width <= maxSize && height <= maxSize) {
return bitmap
}
val ratio = width.toFloat() / height.toFloat()
val newWidth: Int
val newHeight: Int
if (width > height) {
newWidth = maxSize
newHeight = (maxSize / ratio).toInt()
} else {
newHeight = maxSize
newWidth = (maxSize * ratio).toInt()
}
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
private fun calculateInSampleSize(width: Int, height: Int, maxDimension: Int): Int {
var sample = 1
while ((width / (sample * 2)) >= maxDimension || (height / (sample * 2)) >= maxDimension) {
sample *= 2
}
return sample.coerceAtLeast(1)
}
/**
* Конвертировать Bitmap в Base64 PNG
*/
fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream)
val bytes = outputStream.toByteArray()
return "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
}
/**
* Генерировать Blurhash из Bitmap
*/
fun generateBlurhashFromBitmap(bitmap: Bitmap): String {
return try {
// Уменьшаем для быстрого расчета
val scaledBitmap = Bitmap.createScaledBitmap(bitmap, 32, 32, true)
val hash = BlurHash.encode(scaledBitmap, 4, 3)
if (scaledBitmap != bitmap) {
scaledBitmap.recycle()
}
hash ?: ""
} catch (e: Exception) {
""
}
}
/**
* Получить размеры изображения из Uri без полной загрузки в память
* Учитывает EXIF ориентацию для правильных width/height
*/
fun getImageDimensions(context: Context, uri: Uri): Pair<Int, Int> {
return try {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true // Не загружаем в память, только размеры
}
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream, null, options)
}
var width = options.outWidth
var height = options.outHeight
// Учитываем EXIF ориентацию - если поворот 90 или 270, меняем местами width/height
val orientation = getExifOrientation(context, uri)
if (orientation == ExifInterface.ORIENTATION_ROTATE_90 ||
orientation == ExifInterface.ORIENTATION_ROTATE_270 ||
orientation == ExifInterface.ORIENTATION_TRANSPOSE ||
orientation == ExifInterface.ORIENTATION_TRANSVERSE) {
val temp = width
width = height
height = temp
}
// Учитываем масштабирование (как в uriToBase64Image)
if (width > MAX_IMAGE_SIZE || height > MAX_IMAGE_SIZE) {
val ratio = width.toFloat() / height.toFloat()
if (width > height) {
width = MAX_IMAGE_SIZE
height = (MAX_IMAGE_SIZE / ratio).toInt()
} else {
height = MAX_IMAGE_SIZE
width = (MAX_IMAGE_SIZE * ratio).toInt()
}
}
Pair(width, height)
} catch (e: Exception) {
Pair(0, 0)
}
}
/**
* Получить размеры изображения из Base64 строки
*/
fun getImageDimensionsFromBase64(base64: String): Pair<Int, Int> {
return try {
// Убираем data URI prefix если есть
val base64Data = if (base64.contains(",")) {
base64.substringAfter(",")
} else {
base64
}
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
Pair(options.outWidth, options.outHeight)
} catch (e: Exception) {
Pair(0, 0)
}
}
}