feat: Implement avatar handling and display across chat and account screens

This commit is contained in:
2026-01-24 01:14:25 +05:00
parent 1367864008
commit 10c78e6231
7 changed files with 135 additions and 87 deletions

View File

@@ -583,6 +583,7 @@ fun MainScreen(
// TODO: Show new chat screen // TODO: Show new chat screen
}, },
onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser }, onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser },
avatarRepository = avatarRepository,
onLogout = onLogout onLogout = onLogout
) )
} }

View File

@@ -6,6 +6,7 @@ import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.database.* import com.rosetta.messenger.database.*
import com.rosetta.messenger.network.* import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.json.JSONArray import org.json.JSONArray
@@ -53,6 +54,7 @@ class MessageRepository private constructor(private val context: Context) {
private val database = RosettaDatabase.getDatabase(context) private val database = RosettaDatabase.getDatabase(context)
private val messageDao = database.messageDao() private val messageDao = database.messageDao()
private val dialogDao = database.dialogDao() private val dialogDao = database.dialogDao()
private val avatarDao = database.avatarDao()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -318,7 +320,10 @@ class MessageRepository private constructor(private val context: Context) {
privateKey privateKey
) )
// 🔒 Шифруем plainMessage с использованием приватного ключа // <EFBFBD> Обрабатываем AVATAR attachments - сохраняем аватар отправителя
processAvatarAttachments(packet.attachments, packet.fromPublicKey, packet.chachaKey, privateKey)
// <20>🔒 Шифруем plainMessage с использованием приватного ключа
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey) val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
// Создаем entity для кэша и возможной вставки // Создаем entity для кэша и возможной вставки
@@ -719,6 +724,61 @@ class MessageRepository private constructor(private val context: Context) {
return jsonArray.toString() return jsonArray.toString()
} }
/**
* 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш
* Как в desktop: при получении attachment с типом AVATAR - сохраняем в avatar_cache
*/
private suspend fun processAvatarAttachments(
attachments: List<MessageAttachment>,
fromPublicKey: String,
encryptedKey: String,
privateKey: String
) {
Log.d("MessageRepository", "📸 processAvatarAttachments: ${attachments.size} attachments from ${fromPublicKey.take(16)}")
for (attachment in attachments) {
Log.d("MessageRepository", "📸 Attachment type=${attachment.type} (AVATAR=${AttachmentType.AVATAR}), blob size=${attachment.blob.length}")
if (attachment.type == AttachmentType.AVATAR && attachment.blob.isNotEmpty()) {
try {
Log.d("MessageRepository", "📸 Found AVATAR attachment! Decrypting...")
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
Log.d("MessageRepository", "📸 Decrypted blob: ${decryptedBlob?.take(50) ?: "NULL"}")
if (decryptedBlob != null) {
// 2. Сохраняем аватар в кэш
val filePath = AvatarFileManager.saveAvatar(context, decryptedBlob, fromPublicKey)
Log.d("MessageRepository", "📸 Avatar saved to: $filePath")
val entity = AvatarCacheEntity(
publicKey = fromPublicKey,
avatar = filePath,
timestamp = System.currentTimeMillis()
)
avatarDao.insertAvatar(entity)
Log.d("MessageRepository", "📸 Avatar inserted to DB for ${fromPublicKey.take(16)}")
// 3. Очищаем старые аватары (оставляем последние 5)
avatarDao.deleteOldAvatars(fromPublicKey, 5)
Log.d("MessageRepository", "📸 ✅ Successfully saved avatar for $fromPublicKey")
} else {
Log.w("MessageRepository", "📸 ⚠️ Decryption returned null!")
}
} catch (e: Exception) {
Log.e("MessageRepository", "📸 ❌ Failed to process avatar attachment", e)
}
}
}
}
/** /**
* Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД * Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД
* Для MESSAGES типа: * Для MESSAGES типа:

View File

@@ -235,7 +235,8 @@ class PacketOnlineSubscribe : Packet() {
enum class AttachmentType(val value: Int) { enum class AttachmentType(val value: Int) {
IMAGE(0), // Изображение IMAGE(0), // Изображение
MESSAGES(1), // Reply (цитата сообщения) MESSAGES(1), // Reply (цитата сообщения)
FILE(2); // Файл FILE(2), // Файл
AVATAR(3); // Аватар пользователя
companion object { companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE

View File

@@ -16,11 +16,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
data class AccountInfo( data class AccountInfo(
@@ -258,6 +262,12 @@ private fun AccountListItem(
val avatarColor = getAccountColor(account.name) val avatarColor = getAccountColor(account.name)
val context = LocalContext.current
val avatarRepository = remember(account.publicKey) {
val database = RosettaDatabase.getDatabase(context)
AvatarRepository(context, database.avatarDao(), account.publicKey)
}
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -288,20 +298,12 @@ private fun AccountListItem(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar // Avatar
Box( AvatarImage(
modifier = Modifier publicKey = account.publicKey,
.size(48.dp) avatarRepository = avatarRepository,
.clip(CircleShape) size = 48.dp,
.background(avatarColor.copy(alpha = 0.2f)), isDarkTheme = isDarkTheme
contentAlignment = Alignment.Center )
) {
Text(
text = account.initials,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = avatarColor
)
}
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))

View File

@@ -41,8 +41,11 @@ import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarColor
import com.rosetta.messenger.ui.chats.getAvatarText import com.rosetta.messenger.ui.chats.getAvatarText
import com.rosetta.messenger.ui.chats.utils.getInitials import com.rosetta.messenger.ui.chats.utils.getInitials
@@ -387,22 +390,16 @@ fun UnlockScreen(
) { ) {
// Avatar // Avatar
if (selectedAccount != null) { if (selectedAccount != null) {
val avatarColors = val database = RosettaDatabase.getDatabase(context)
getAvatarColor(selectedAccount!!.publicKey, isDarkTheme) val avatarRepository = remember(selectedAccount!!.publicKey) {
Box( AvatarRepository(context, database.avatarDao(), selectedAccount!!.publicKey)
modifier =
Modifier.size(48.dp)
.clip(CircleShape)
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = getInitials(selectedAccount!!.name),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
} }
AvatarImage(
publicKey = selectedAccount!!.publicKey,
avatarRepository = avatarRepository,
size = 48.dp,
isDarkTheme = isDarkTheme
)
} }
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
@@ -572,25 +569,16 @@ fun UnlockScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar // Avatar
val avatarColors = val database = RosettaDatabase.getDatabase(context)
getAvatarColor(account.publicKey, isDarkTheme) val avatarRepository = remember(account.publicKey) {
Box( AvatarRepository(context, database.avatarDao(), account.publicKey)
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(
avatarColors
.backgroundColor
),
contentAlignment = Alignment.Center
) {
Text(
text = getInitials(account.name),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
} }
AvatarImage(
publicKey = account.publicKey,
avatarRepository = avatarRepository,
size = 40.dp,
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))

View File

@@ -37,6 +37,7 @@ import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -147,6 +148,7 @@ fun ChatsListScreen(
onNewChat: () -> Unit, onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
// Theme transition state // Theme transition state
@@ -398,7 +400,7 @@ fun ChatsListScreen(
) )
) { ) {
Column { Column {
// Avatar with border // Avatar - используем AvatarImage
Box( Box(
modifier = modifier =
Modifier.size(72.dp) Modifier.size(72.dp)
@@ -414,29 +416,15 @@ fun ChatsListScreen(
) )
.padding( .padding(
3.dp 3.dp
)
.clip(
CircleShape
)
.background(
avatarColors
.backgroundColor
), ),
contentAlignment = contentAlignment =
Alignment.Center Alignment.Center
) { ) {
Text( AvatarImage(
text = publicKey = accountPublicKey,
getAvatarText( avatarRepository = avatarRepository,
accountPublicKey size = 66.dp,
), isDarkTheme = isDarkTheme
fontSize = 26.sp,
fontWeight =
FontWeight
.Bold,
color =
avatarColors
.textColor
) )
} }
@@ -996,6 +984,8 @@ fun ChatsListScreen(
isBlocked, isBlocked,
isSavedMessages = isSavedMessages =
isSavedMessages, isSavedMessages,
avatarRepository =
avatarRepository,
onClick = { onClick = {
val user = val user =
chatsViewModel chatsViewModel
@@ -1469,6 +1459,7 @@ fun SwipeableDialogItem(
isTyping: Boolean = false, isTyping: Boolean = false,
isBlocked: Boolean = false, isBlocked: Boolean = false,
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onClick: () -> Unit, onClick: () -> Unit,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onBlock: () -> Unit = {}, onBlock: () -> Unit = {},
@@ -1615,6 +1606,7 @@ fun SwipeableDialogItem(
dialog = dialog, dialog = dialog,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isTyping = isTyping, isTyping = isTyping,
avatarRepository = avatarRepository,
onClick = onClick onClick = onClick
) )
@@ -1635,6 +1627,7 @@ fun DialogItemContent(
dialog: DialogUiModel, dialog: DialogUiModel,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isTyping: Boolean = false, isTyping: Boolean = false,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onClick: () -> Unit onClick: () -> Unit
) { ) {
// 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки
@@ -1721,31 +1714,28 @@ fun DialogItemContent(
// Avatar container with online indicator // Avatar container with online indicator
Box(modifier = Modifier.size(56.dp)) { Box(modifier = Modifier.size(56.dp)) {
// Avatar // Avatar
Box( if (dialog.isSavedMessages) {
modifier = Box(
Modifier.fillMaxSize() modifier =
.clip(CircleShape) Modifier.fillMaxSize()
.background( .clip(CircleShape)
if (dialog.isSavedMessages) PrimaryBlue .background(PrimaryBlue),
else avatarColors.backgroundColor contentAlignment = Alignment.Center
), ) {
contentAlignment = Alignment.Center
) {
if (dialog.isSavedMessages) {
Icon( Icon(
Icons.Default.Bookmark, Icons.Default.Bookmark,
contentDescription = null, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} else {
Text(
text = initials,
color = avatarColors.textColor,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp
)
} }
} else {
com.rosetta.messenger.ui.components.AvatarImage(
publicKey = dialog.opponentKey,
avatarRepository = avatarRepository,
size = 56.dp,
isDarkTheme = isDarkTheme
)
} }
// Online indicator - зелёный кружок с белой обводкой // Online indicator - зелёный кружок с белой обводкой

View File

@@ -62,9 +62,15 @@ fun AvatarImage(
// Состояние для bitmap // Состояние для bitmap
var bitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) } var bitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) }
// Логируем для отладки
LaunchedEffect(publicKey, avatars) {
android.util.Log.d("AvatarImage", "📸 publicKey=${publicKey.take(16)}... avatars=${avatars.size} repository=${avatarRepository != null}")
}
// Декодируем первый аватар // Декодируем первый аватар
LaunchedEffect(avatars) { LaunchedEffect(avatars) {
bitmap = if (avatars.isNotEmpty()) { bitmap = if (avatars.isNotEmpty()) {
android.util.Log.d("AvatarImage", "🔄 Decoding avatar for ${publicKey.take(16)}...")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(avatars.first().base64Data) AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
} }