- 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.
357 lines
13 KiB
Kotlin
357 lines
13 KiB
Kotlin
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)
|
||
}
|
||
}
|
||
}
|