feat: Implement avatar management system with P2P delivery

- Added AvatarRepository for handling avatar storage, retrieval, and delivery.
- Created AvatarCacheEntity and AvatarDeliveryEntity for database storage.
- Introduced PacketAvatar for P2P avatar transfer between clients.
- Enhanced RosettaDatabase to include avatar-related tables and migration.
- Developed AvatarFileManager for file operations related to avatars.
- Implemented AvatarImage composable for displaying user avatars.
- Updated ProfileScreen to support avatar selection and updating.
- Added functionality for handling incoming avatar packets in ProtocolManager.
This commit is contained in:
2026-01-23 03:04:27 +05:00
parent 6fdad7a4c1
commit b08bea2c14
12 changed files with 1670 additions and 18 deletions

View File

@@ -0,0 +1,175 @@
package com.rosetta.messenger.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import com.rosetta.messenger.crypto.CryptoManager
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.MessageDigest
/**
* Менеджер для работы с файлами аватаров
* Совместимо с desktop версией:
* - Все файлы зашифрованы паролем "rosetta-a"
* - Формат пути: "a/md5hash" (без расширения)
* - MD5 генерируется из (base64Image + entityId)
*/
object AvatarFileManager {
private const val AVATAR_DIR = "avatars"
private const val AVATAR_PASSWORD = "rosetta-a"
private const val MAX_IMAGE_SIZE = 2048 // Максимальный размер изображения в пикселях
private const val JPEG_QUALITY = 85 // Качество JPEG сжатия
/**
* Сохранить аватар в файловую систему
* @param context Android context
* @param base64Image Base64-encoded изображение
* @param entityId ID сущности (publicKey или groupId)
* @return Путь к файлу (формат: "a/md5hash")
*/
fun saveAvatar(context: Context, base64Image: String, entityId: String): String {
// Генерируем путь как в desktop версии
val filePath = generateMd5Path(base64Image, entityId)
// Шифруем данные с паролем "rosetta-a"
val encrypted = CryptoManager.encryptWithPassword(base64Image, AVATAR_PASSWORD)
// Сохраняем в файловую систему
val dir = File(context.filesDir, AVATAR_DIR)
dir.mkdirs()
// Путь формата "a/md5hash" -> создаем подпапку "a"
val parts = filePath.split("/")
if (parts.size == 2) {
val subDir = File(dir, parts[0])
subDir.mkdirs()
val file = File(subDir, parts[1])
file.writeText(encrypted)
} else {
val file = File(dir, filePath)
file.writeText(encrypted)
}
return filePath
}
/**
* Прочитать и расшифровать аватар
* @param context Android context
* @param path Путь к файлу (формат: "a/md5hash")
* @return Base64-encoded изображение или null
*/
fun readAvatar(context: Context, path: String): String? {
return try {
val dir = File(context.filesDir, AVATAR_DIR)
val file = File(dir, path)
if (!file.exists()) return null
val encrypted = file.readText()
CryptoManager.decryptWithPassword(encrypted, AVATAR_PASSWORD)
} catch (e: Exception) {
null
}
}
/**
* Удалить файл аватара
* @param context Android context
* @param path Путь к файлу
*/
fun deleteAvatar(context: Context, path: String) {
try {
val dir = File(context.filesDir, AVATAR_DIR)
val file = File(dir, path)
file.delete()
} catch (e: Exception) {
// Ignore errors
}
}
/**
* Генерировать MD5 путь для аватара (совместимо с desktop)
* Desktop код:
* ```js
* const hash = md5(`${data}${entity}`);
* return `a/${hash}`;
* ```
*/
fun generateMd5Path(data: String, entity: String): String {
val md5 = MessageDigest.getInstance("MD5")
.digest("$data$entity".toByteArray())
.joinToString("") { "%02x".format(it) }
return "a/$md5"
}
/**
* Конвертировать URI изображения в Base64 PNG
* Это соответствует desktop функции imagePrepareForNetworkTransfer()
* которая конвертирует все изображения в PNG для кросс-платформенной совместимости
*
* @param context Android context
* @param imageData Данные изображения (может быть JPEG, PNG, etc.)
* @return Base64-encoded PNG изображение
*/
fun imagePrepareForNetworkTransfer(context: Context, imageData: ByteArray): String {
// Декодируем изображение
var bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size)
// Ресайзим если слишком большое
if (bitmap.width > MAX_IMAGE_SIZE || bitmap.height > MAX_IMAGE_SIZE) {
val scale = MAX_IMAGE_SIZE.toFloat() / maxOf(bitmap.width, bitmap.height)
val newWidth = (bitmap.width * scale).toInt()
val newHeight = (bitmap.height * scale).toInt()
bitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
// Конвертируем в PNG (для кросс-платформенной совместимости)
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
val pngBytes = outputStream.toByteArray()
// Конвертируем в Base64
return Base64.encodeToString(pngBytes, Base64.NO_WRAP)
}
/**
* Конвертировать Base64 в Bitmap для отображения
*/
fun base64ToBitmap(base64: String): Bitmap? {
return try {
val imageBytes = Base64.decode(base64, Base64.NO_WRAP)
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
} catch (e: Exception) {
null
}
}
/**
* Получить размер файла в байтах
*/
fun getFileSize(context: Context, path: String): Long {
return try {
val dir = File(context.filesDir, AVATAR_DIR)
val file = File(dir, path)
if (file.exists()) file.length() else 0
} catch (e: Exception) {
0
}
}
/**
* Очистить все аватары (для отладки)
*/
fun clearAllAvatars(context: Context) {
try {
val dir = File(context.filesDir, AVATAR_DIR)
dir.deleteRecursively()
} catch (e: Exception) {
// Ignore errors
}
}
}