feat: Integrate AvatarRepository into chat components for improved avatar handling and caching

This commit is contained in:
k1ngsterr1
2026-01-28 00:28:56 +05:00
parent f92bc8b0d5
commit 8702539d09
8 changed files with 142 additions and 73 deletions

View File

@@ -717,7 +717,8 @@ fun MainScreen(
// 📨 Forward: переход в выбранный чат с полными данными // 📨 Forward: переход в выбранный чат с полными данными
selectedUser = forwardUser selectedUser = forwardUser
}, },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository
) )
} }
} }
@@ -841,7 +842,8 @@ fun MainScreen(
onBack = { onBack = {
showOtherProfileScreen = false showOtherProfileScreen = false
selectedOtherUser = null selectedOtherUser = null
} },
avatarRepository = avatarRepository
) )
} }
} }

View File

@@ -30,6 +30,11 @@ class AvatarRepository(
private const val MAX_AVATAR_HISTORY = 5 // Хранить последние N аватаров private const val MAX_AVATAR_HISTORY = 5 // Хранить последние N аватаров
} }
// Repository scope для coroutines
private val repositoryScope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() + Dispatchers.IO
)
// In-memory cache (как decodedAvatarsCache в desktop) // In-memory cache (как decodedAvatarsCache в desktop)
// publicKey -> Flow<List<AvatarInfo>> // publicKey -> Flow<List<AvatarInfo>>
private val memoryCache = mutableMapOf<String, MutableStateFlow<List<AvatarInfo>>>() private val memoryCache = mutableMapOf<String, MutableStateFlow<List<AvatarInfo>>>()
@@ -49,9 +54,10 @@ class AvatarRepository(
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList()) val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
memoryCache[publicKey] = flow memoryCache[publicKey] = flow
// Подписываемся на изменения в БД // Подписываемся на изменения в БД с использованием repository scope
avatarDao.getAvatars(publicKey) avatarDao.getAvatars(publicKey)
.onEach { entities -> .onEach { entities ->
Log.d(TAG, "📥 DB update for $publicKey: ${entities.size} entities")
val avatars = if (allDecode) { val avatars = if (allDecode) {
// Загружаем всю историю // Загружаем всю историю
entities.mapNotNull { entity -> entities.mapNotNull { entity ->
@@ -64,8 +70,9 @@ class AvatarRepository(
}?.let { listOf(it) } ?: emptyList() }?.let { listOf(it) } ?: emptyList()
} }
flow.value = avatars flow.value = avatars
Log.d(TAG, "✅ Flow updated for $publicKey: ${avatars.size} avatars")
} }
.launchIn(kotlinx.coroutines.CoroutineScope(Dispatchers.IO)) .launchIn(repositoryScope)
return flow.asStateFlow() return flow.asStateFlow()
} }
@@ -90,6 +97,7 @@ class AvatarRepository(
try { try {
// Сохраняем файл // Сохраняем файл
val filePath = AvatarFileManager.saveAvatar(context, base64Image, fromPublicKey) val filePath = AvatarFileManager.saveAvatar(context, base64Image, fromPublicKey)
Log.d(TAG, "💾 Avatar file saved for $fromPublicKey: $filePath")
// Сохраняем в БД // Сохраняем в БД
val entity = AvatarCacheEntity( val entity = AvatarCacheEntity(
@@ -98,13 +106,26 @@ class AvatarRepository(
timestamp = System.currentTimeMillis() timestamp = System.currentTimeMillis()
) )
avatarDao.insertAvatar(entity) avatarDao.insertAvatar(entity)
Log.d(TAG, "💾 Avatar entity inserted to DB for $fromPublicKey")
// Очищаем старые аватары (оставляем только последние N) // Очищаем старые аватары (оставляем только последние N)
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY) avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY)
Log.d(TAG, "Saved avatar for $fromPublicKey") // 🔄 Обновляем memory cache если он существует
val cachedFlow = memoryCache[fromPublicKey]
if (cachedFlow != null) {
val avatarInfo = loadAndDecryptAvatar(entity)
if (avatarInfo != null) {
cachedFlow.value = listOf(avatarInfo)
Log.d(TAG, "✅ Memory cache updated for $fromPublicKey")
}
} else {
Log.d(TAG, " No memory cache for $fromPublicKey, will be loaded on next getAvatars()")
}
Log.d(TAG, "✅ Saved avatar for $fromPublicKey")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to save avatar", e) Log.e(TAG, "Failed to save avatar for $fromPublicKey", e)
} }
} }
} }

View File

@@ -59,6 +59,8 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.chats.components.* import com.rosetta.messenger.ui.chats.components.*
@@ -97,7 +99,8 @@ fun ChatDetailScreen(
currentUserPublicKey: String, currentUserPublicKey: String,
currentUserPrivateKey: String, currentUserPrivateKey: String,
totalUnreadFromOthers: Int = 0, totalUnreadFromOthers: Int = 0,
isDarkTheme: Boolean isDarkTheme: Boolean,
avatarRepository: AvatarRepository? = null
) { ) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current val context = LocalContext.current
@@ -780,17 +783,6 @@ fun ChatDetailScreen(
Box( Box(
modifier = modifier =
Modifier.size(40.dp) Modifier.size(40.dp)
.clip(
CircleShape
)
.background(
if (isSavedMessages
)
PrimaryBlue
else
avatarColors
.backgroundColor
)
.clickable( .clickable(
indication = indication =
null, null,
@@ -809,41 +801,32 @@ fun ChatDetailScreen(
Alignment.Center Alignment.Center
) { ) {
if (isSavedMessages) { if (isSavedMessages) {
Icon( Box(
Icons.Default modifier = Modifier
.Bookmark, .fillMaxSize()
contentDescription = .clip(CircleShape)
null, .background(PrimaryBlue),
tint = contentAlignment = Alignment.Center
Color.White, ) {
modifier = Icon(
Modifier.size( Icons.Default
20.dp .Bookmark,
) contentDescription =
) null,
} else { tint =
Text( Color.White,
text = modifier =
if (user.title Modifier.size(
.isNotEmpty() 20.dp
)
getInitials(
user.title
) )
else )
user.publicKey }
.take( } else {
2 AvatarImage(
) publicKey = user.publicKey,
.uppercase(), avatarRepository = avatarRepository,
fontSize = size = 40.dp,
14.sp, isDarkTheme = isDarkTheme
fontWeight =
FontWeight
.Bold,
color =
avatarColors
.textColor
) )
} }
} }
@@ -1671,6 +1654,10 @@ fun ChatDetailScreen(
currentUserPrivateKey, currentUserPrivateKey,
senderPublicKey = senderPublicKey =
if (message.isOutgoing) currentUserPublicKey else user.publicKey, if (message.isOutgoing) currentUserPublicKey else user.publicKey,
currentUserPublicKey =
currentUserPublicKey,
avatarRepository =
avatarRepository,
onLongClick = { onLongClick = {
if (!isSelectionMode if (!isSelectionMode
) { ) {

View File

@@ -81,6 +81,7 @@ fun MessageAttachments(
timestamp: java.util.Date, timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ, messageStatus: MessageStatus = MessageStatus.READ,
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
currentUserPublicKey: String = "",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (attachments.isEmpty()) return if (attachments.isEmpty()) return
@@ -128,6 +129,7 @@ fun MessageAttachments(
privateKey = privateKey, privateKey = privateKey,
senderPublicKey = senderPublicKey, senderPublicKey = senderPublicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
currentUserPublicKey = currentUserPublicKey,
isOutgoing = isOutgoing, isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
@@ -1123,6 +1125,7 @@ fun AvatarAttachment(
privateKey: String, privateKey: String,
senderPublicKey: String, senderPublicKey: String,
avatarRepository: AvatarRepository?, avatarRepository: AvatarRepository?,
currentUserPublicKey: String = "",
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
timestamp: java.util.Date = java.util.Date(), timestamp: java.util.Date = java.util.Date(),
@@ -1263,8 +1266,28 @@ fun AvatarAttachment(
Log.d(TAG, "💾 Avatar saved to: $path") Log.d(TAG, "💾 Avatar saved to: $path")
} }
// Сохраняем аватар в репозиторий (для UI обновления) // Сохраняем аватар в репозиторий (для UI обновления)
Log.d(TAG, "💾 Saving avatar to repository for user ${senderPublicKey.take(16)}...") // Если это исходящее сообщение с аватаром, сохраняем для текущего пользователя
avatarRepository?.saveAvatar(senderPublicKey, decrypted) val targetPublicKey = if (isOutgoing && currentUserPublicKey.isNotEmpty()) {
Log.d(TAG, "💾 Saving avatar to repository for CURRENT user ${currentUserPublicKey.take(16)}...")
currentUserPublicKey
} else {
Log.d(TAG, "💾 Saving avatar to repository for SENDER ${senderPublicKey.take(16)}...")
senderPublicKey
}
// ВАЖНО: ждем завершения сохранения в репозиторий
if (avatarRepository != null) {
try {
Log.d(TAG, "📤 Calling avatarRepository.saveAvatar()...")
avatarRepository.saveAvatar(targetPublicKey, decrypted)
Log.d(TAG, "✅ Avatar saved to repository for ${targetPublicKey.take(16)}")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to save avatar to repository: ${e.message}", e)
}
} else {
Log.e(TAG, "❌ avatarRepository is NULL! Cannot save avatar for ${targetPublicKey.take(16)}")
}
downloadStatus = DownloadStatus.DOWNLOADED downloadStatus = DownloadStatus.DOWNLOADED
Log.d(TAG, "=====================================") Log.d(TAG, "=====================================")
Log.d(TAG, "✅ AVATAR DOWNLOAD COMPLETE") Log.d(TAG, "✅ AVATAR DOWNLOAD COMPLETE")

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.models.*
@@ -140,6 +141,8 @@ fun MessageBubble(
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
privateKey: String = "", privateKey: String = "",
senderPublicKey: String = "", senderPublicKey: String = "",
currentUserPublicKey: String = "",
avatarRepository: AvatarRepository? = null,
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onSwipeToReply: () -> Unit = {}, onSwipeToReply: () -> Unit = {},
@@ -380,7 +383,9 @@ fun MessageBubble(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
senderPublicKey = senderPublicKey, senderPublicKey = senderPublicKey,
timestamp = message.timestamp, timestamp = message.timestamp,
messageStatus = message.status messageStatus = message.status,
avatarRepository = avatarRepository,
currentUserPublicKey = currentUserPublicKey
) )
if (message.text.isNotEmpty()) { if (message.text.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View File

@@ -70,11 +70,14 @@ fun AvatarImage(
// Декодируем первый аватар // Декодируем первый аватар
LaunchedEffect(avatars) { LaunchedEffect(avatars) {
bitmap = if (avatars.isNotEmpty()) { bitmap = if (avatars.isNotEmpty()) {
android.util.Log.d("AvatarImage", "🔄 Decoding avatar for ${publicKey.take(16)}...") android.util.Log.d("AvatarImage", "🔄 Decoding avatar for ${publicKey.take(16)}... base64 length=${avatars.first().base64Data?.length ?: 0}")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(avatars.first().base64Data) val result = AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
android.util.Log.d("AvatarImage", "✅ Decoded bitmap for ${publicKey.take(16)}... result=${result != null} size=${result?.width}x${result?.height}")
result
} }
} else { } else {
android.util.Log.d("AvatarImage", "⚠️ No avatars for ${publicKey.take(16)}...")
null null
} }
} }
@@ -92,6 +95,11 @@ fun AvatarImage(
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Log what we're showing
LaunchedEffect(bitmap) {
android.util.Log.d("AvatarImage", "🖼️ Showing for ${publicKey.take(16)}... bitmap=${bitmap != null}")
}
if (bitmap != null) { if (bitmap != null) {
// Отображаем реальный аватар // Отображаем реальный аватар
Image( Image(

View File

@@ -44,6 +44,9 @@ import androidx.compose.ui.unit.IntOffset
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.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -60,7 +63,8 @@ private val AVATAR_SIZE_COLLAPSED_OTHER = 36.dp
fun OtherProfileScreen( fun OtherProfileScreen(
user: SearchUser, user: SearchUser,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onBack: () -> Unit onBack: () -> Unit,
avatarRepository: AvatarRepository? = null
) { ) {
var isBlocked by remember { mutableStateOf(false) } var isBlocked by remember { mutableStateOf(false) }
var showAvatarMenu by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) }
@@ -198,6 +202,7 @@ fun OtherProfileScreen(
onAvatarMenuChange = { showAvatarMenu = it }, onAvatarMenuChange = { showAvatarMenu = it },
isBlocked = isBlocked, isBlocked = isBlocked,
onBlockToggle = { isBlocked = !isBlocked }, onBlockToggle = { isBlocked = !isBlocked },
avatarRepository = avatarRepository,
onClearChat = { onClearChat = {
viewModel.clearChatHistory() viewModel.clearChatHistory()
// 🗑️ Удаляем диалог из списка после очистки истории // 🗑️ Удаляем диалог из списка после очистки истории
@@ -232,6 +237,7 @@ private fun CollapsingOtherProfileHeader(
onAvatarMenuChange: (Boolean) -> Unit, onAvatarMenuChange: (Boolean) -> Unit,
isBlocked: Boolean, isBlocked: Boolean,
onBlockToggle: () -> Unit, onBlockToggle: () -> Unit,
avatarRepository: AvatarRepository? = null,
onClearChat: () -> Unit onClearChat: () -> Unit
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
@@ -265,10 +271,18 @@ private fun CollapsingOtherProfileHeader(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(headerHeight) .height(headerHeight)
.drawBehind {
drawRect(avatarColors.backgroundColor)
}
) { ) {
// ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND
// ═══════════════════════════════════════════════════════════
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f
)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🔙 BACK BUTTON // 🔙 BACK BUTTON
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -346,18 +360,15 @@ private fun CollapsingOtherProfileHeader(
.clip(CircleShape) .clip(CircleShape)
.background(Color.White.copy(alpha = 0.15f)) .background(Color.White.copy(alpha = 0.15f))
.padding(2.dp) .padding(2.dp)
.clip(CircleShape) .clip(CircleShape),
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (avatarFontSize > 1.sp) { AvatarImage(
Text( publicKey = publicKey,
text = getInitials(name), avatarRepository = avatarRepository,
fontSize = avatarFontSize, size = avatarSize - 4.dp,
fontWeight = FontWeight.Bold, isDarkTheme = isDarkTheme
color = avatarColors.textColor )
)
}
} }
} }

View File

@@ -230,9 +230,21 @@ object AvatarFileManager {
*/ */
fun base64ToBitmap(base64: String): Bitmap? { fun base64ToBitmap(base64: String): Bitmap? {
return try { return try {
val imageBytes = Base64.decode(base64, Base64.NO_WRAP) // Check for data URI prefix
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) val actualBase64 = if (base64.contains(",")) {
android.util.Log.d("AvatarFileManager", "🔍 Removing data URI prefix, orig len=${base64.length}")
base64.substringAfter(",")
} else {
base64
}
android.util.Log.d("AvatarFileManager", "🔍 Decoding base64, len=${actualBase64.length}, prefix=${actualBase64.take(50)}")
val imageBytes = Base64.decode(actualBase64, Base64.NO_WRAP)
android.util.Log.d("AvatarFileManager", "🔍 Decoded bytes=${imageBytes.size}")
val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
android.util.Log.d("AvatarFileManager", "🔍 Bitmap result=${bitmap != null}, size=${bitmap?.width}x${bitmap?.height}")
bitmap
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("AvatarFileManager", "❌ base64ToBitmap error: ${e.message}")
null null
} }
} }