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:
k1ngsterr1
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,335 @@
package com.rosetta.messenger.repository
import android.content.Context
import android.util.Base64
import android.util.Log
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.database.AvatarCacheEntity
import com.rosetta.messenger.database.AvatarDao
import com.rosetta.messenger.database.AvatarDeliveryEntity
import com.rosetta.messenger.network.PacketAvatar
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import java.security.MessageDigest
/**
* Репозиторий для работы с аватарами
* Совместимо с desktop версией (AvatarProvider)
*
* Возможности:
* - Загрузка/сохранение аватаров
* - P2P доставка аватаров (PacketAvatar 0x0C)
* - Multi-layer кэширование (Memory + SQLite + Encrypted Files)
* - Трекинг доставки
* - Поддержка истории аватаров
*/
class AvatarRepository(
private val context: Context,
private val avatarDao: AvatarDao,
private val currentPublicKey: String,
private val currentPrivateKey: String,
private val protocolManager: ProtocolManager
) {
companion object {
private const val TAG = "AvatarRepository"
private const val MAX_AVATAR_HISTORY = 5 // Хранить последние N аватаров
}
// In-memory cache (как decodedAvatarsCache в desktop)
// publicKey -> Flow<List<AvatarInfo>>
private val memoryCache = mutableMapOf<String, MutableStateFlow<List<AvatarInfo>>>()
/**
* Получить аватары пользователя
* @param publicKey Публичный ключ пользователя
* @param allDecode true = вся история, false = только последний (для списков)
*/
fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow<List<AvatarInfo>> {
// Проверяем memory cache
if (memoryCache.containsKey(publicKey)) {
return memoryCache[publicKey]!!.asStateFlow()
}
// Создаем новый flow для этого пользователя
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
memoryCache[publicKey] = flow
// Подписываемся на изменения в БД
avatarDao.getAvatars(publicKey)
.onEach { entities ->
val avatars = if (allDecode) {
// Загружаем всю историю
entities.mapNotNull { entity ->
loadAndDecryptAvatar(entity)
}
} else {
// Загружаем только последний
entities.firstOrNull()?.let { entity ->
loadAndDecryptAvatar(entity)
}?.let { listOf(it) } ?: emptyList()
}
flow.value = avatars
}
.launchIn(kotlinx.coroutines.CoroutineScope(Dispatchers.IO))
return flow.asStateFlow()
}
/**
* Получить последний аватар пользователя (suspend версия)
*/
suspend fun getLatestAvatar(publicKey: String): AvatarInfo? {
val entity = avatarDao.getLatestAvatar(publicKey) ?: return null
return loadAndDecryptAvatar(entity)
}
/**
* Сохранить полученный аватар от другого пользователя
* @param fromPublicKey Публичный ключ отправителя
* @param base64Image Base64-encoded изображение
*/
suspend fun saveAvatar(fromPublicKey: String, base64Image: String) {
withContext(Dispatchers.IO) {
try {
// Сохраняем файл
val filePath = AvatarFileManager.saveAvatar(context, base64Image, fromPublicKey)
// Сохраняем в БД
val entity = AvatarCacheEntity(
publicKey = fromPublicKey,
avatar = filePath,
timestamp = System.currentTimeMillis()
)
avatarDao.insertAvatar(entity)
// Очищаем старые аватары (оставляем только последние N)
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY)
Log.d(TAG, "Saved avatar for $fromPublicKey")
} catch (e: Exception) {
Log.e(TAG, "Failed to save avatar", e)
}
}
}
/**
* Изменить свой аватар
* @param base64Image Base64-encoded изображение
*/
suspend fun changeMyAvatar(base64Image: String) {
withContext(Dispatchers.IO) {
try {
// Сохраняем файл
val filePath = AvatarFileManager.saveAvatar(context, base64Image, currentPublicKey)
// Сохраняем в БД
val entity = AvatarCacheEntity(
publicKey = currentPublicKey,
avatar = filePath,
timestamp = System.currentTimeMillis()
)
avatarDao.insertAvatar(entity)
// Очищаем трекинг доставки (новый аватар нужно доставить всем заново)
avatarDao.clearDeliveryForAccount(currentPublicKey)
// Очищаем старые аватары
avatarDao.deleteOldAvatars(currentPublicKey, MAX_AVATAR_HISTORY)
Log.d(TAG, "Changed my avatar")
} catch (e: Exception) {
Log.e(TAG, "Failed to change avatar", e)
}
}
}
/**
* Отправить свой аватар контакту через PacketAvatar
* @param toPublicKey Публичный ключ получателя
*/
suspend fun sendAvatarTo(toPublicKey: String) {
withContext(Dispatchers.IO) {
try {
// Проверяем, не доставлен ли уже
if (avatarDao.isAvatarDelivered(toPublicKey, currentPublicKey)) {
Log.d(TAG, "Avatar already delivered to $toPublicKey")
return@withContext
}
// Получаем свой аватар
val myAvatar = getLatestAvatar(currentPublicKey)
if (myAvatar == null) {
Log.d(TAG, "No avatar to send")
return@withContext
}
// Шифруем ChaCha20
val chachaResult = CryptoManager.chacha20Encrypt(myAvatar.base64Data)
// Объединяем key + nonce (32 + 24 = 56 байт)
val keyNonceHex = chachaResult.key + chachaResult.nonce
// Шифруем RSA (публичным ключом получателя)
val keyNonceBytes = keyNonceHex.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
val keyNonceBase64 = Base64.encodeToString(keyNonceBytes, Base64.NO_WRAP)
val encryptedKeyNonce = CryptoManager.encrypt(keyNonceBase64, toPublicKey)
// Создаем пакет
val packet = PacketAvatar().apply {
privateKey = CryptoManager.generatePrivateKeyHash(currentPrivateKey)
fromPublicKey = currentPublicKey
this.toPublicKey = toPublicKey
chachaKey = encryptedKeyNonce
blob = chachaResult.ciphertext
}
// Отправляем через протокол
protocolManager.sendPacket(packet)
// Отмечаем как доставленный
markAvatarDelivered(toPublicKey)
Log.d(TAG, "Sent avatar to $toPublicKey")
} catch (e: Exception) {
Log.e(TAG, "Failed to send avatar", e)
}
}
}
/**
* Отметить аватар как доставленный (без фактической отправки)
*/
suspend fun markAvatarDelivered(toPublicKey: String) {
withContext(Dispatchers.IO) {
try {
val entity = AvatarDeliveryEntity(
publicKey = toPublicKey,
account = currentPublicKey
)
avatarDao.markAvatarDelivered(entity)
} catch (e: Exception) {
Log.e(TAG, "Failed to mark avatar delivered", e)
}
}
}
/**
* Проверить доставлен ли аватар контакту
*/
suspend fun isAvatarDelivered(toPublicKey: String): Boolean {
return withContext(Dispatchers.IO) {
avatarDao.isAvatarDelivered(toPublicKey, currentPublicKey)
}
}
/**
* Обработать входящий PacketAvatar
*/
suspend fun handleIncomingAvatar(packet: PacketAvatar) {
withContext(Dispatchers.IO) {
try {
// Проверяем, что пакет адресован нам
if (packet.toPublicKey != currentPublicKey) {
Log.w(TAG, "Avatar packet not for us")
return@withContext
}
// Расшифровываем ChaCha key+nonce (RSA)
val keyNonceBase64 = CryptoManager.decrypt(packet.chachaKey, currentPrivateKey)
if (keyNonceBase64 == null) {
Log.e(TAG, "Failed to decrypt ChaCha key")
return@withContext
}
// Декодируем из Base64
val keyNonceBytes = Base64.decode(keyNonceBase64, Base64.NO_WRAP)
val keyNonceHex = keyNonceBytes.joinToString("") { "%02x".format(it) }
// Разделяем на key (32 байта = 64 hex) и nonce (24 байта = 48 hex)
if (keyNonceHex.length != 112) { // 64 + 48
Log.e(TAG, "Invalid key+nonce length: ${keyNonceHex.length}")
return@withContext
}
val keyHex = keyNonceHex.substring(0, 64)
val nonceHex = keyNonceHex.substring(64, 112)
// Расшифровываем blob (ChaCha20)
val base64Image = CryptoManager.chacha20Decrypt(packet.blob, nonceHex, keyHex)
if (base64Image == null) {
Log.e(TAG, "Failed to decrypt avatar blob")
return@withContext
}
// Сохраняем аватар
saveAvatar(packet.fromPublicKey, base64Image)
Log.d(TAG, "Received avatar from ${packet.fromPublicKey}")
} catch (e: Exception) {
Log.e(TAG, "Failed to handle incoming avatar", e)
}
}
}
/**
* Загрузить и расшифровать аватар из файла
*/
private suspend fun loadAndDecryptAvatar(entity: AvatarCacheEntity): AvatarInfo? {
return withContext(Dispatchers.IO) {
try {
val base64Image = AvatarFileManager.readAvatar(context, entity.avatar)
if (base64Image != null) {
AvatarInfo(
base64Data = base64Image,
timestamp = entity.timestamp
)
} else {
Log.w(TAG, "Failed to read avatar file: ${entity.avatar}")
null
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load avatar", e)
null
}
}
}
/**
* Очистить memory cache (для освобождения памяти)
*/
fun clearMemoryCache() {
memoryCache.clear()
}
/**
* Предзагрузить системные аватары (для ботов/системных аккаунтов)
* Аналогично desktop версии, которая использует hardcoded аватары
*/
suspend fun preloadSystemAvatars(systemAccounts: Map<String, String>) {
withContext(Dispatchers.IO) {
systemAccounts.forEach { (publicKey, base64Avatar) ->
// Сохраняем только в memory cache, не в БД
val flow = MutableStateFlow(listOf(
AvatarInfo(
base64Data = base64Avatar,
timestamp = 0
)
))
memoryCache[publicKey] = flow
}
}
}
}
/**
* Информация об аватаре
*/
data class AvatarInfo(
val base64Data: String, // Base64-encoded изображение
val timestamp: Long // Unix timestamp
)