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:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user