feat: enhance avatar handling and file attachment functionality with improved UI interactions

This commit is contained in:
2026-02-15 11:54:09 +05:00
parent e301478d92
commit b543ef4d6f
11 changed files with 177 additions and 101 deletions

View File

@@ -58,7 +58,6 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -214,8 +213,11 @@ class MainActivity : FragmentActivity() {
else -> "main" else -> "main"
}, },
transitionSpec = { transitionSpec = {
fadeIn(animationSpec = tween(600)) togetherWith // Новый экран плавно появляется ПОВЕРХ старого.
fadeOut(animationSpec = tween(600)) // Старый остаётся видимым (alpha=1) пока новый не готов →
// нет белой вспышки от Surface.
fadeIn(animationSpec = tween(350)) togetherWith
fadeOut(animationSpec = tween(1, delayMillis = 350))
}, },
label = "screenTransition" label = "screenTransition"
) { screen -> ) { screen ->
@@ -603,14 +605,13 @@ fun MainScreen(
// Состояние протокола для передачи в SearchScreen // Состояние протокола для передачи в SearchScreen
val protocolState by ProtocolManager.state.collectAsState() val protocolState by ProtocolManager.state.collectAsState()
// Перечитать username/name после получения own profile с сервера // Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
// Аналог Desktop: useUserInformation автоматически обновляет UI при PacketSearch ответе val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
LaunchedEffect(protocolState) { LaunchedEffect(ownProfileUpdated) {
if (protocolState == ProtocolState.AUTHENTICATED && if (ownProfileUpdated > 0L &&
accountPublicKey.isNotBlank() && accountPublicKey.isNotBlank() &&
accountPublicKey != "04c266b98ae5" accountPublicKey != "04c266b98ae5"
) { ) {
delay(2000) // Ждём fetchOwnProfile() → PacketSearch → AccountManager update
val accountManager = AccountManager(context) val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountPublicKey) val encryptedAccount = accountManager.getAccount(accountPublicKey)
accountUsername = encryptedAccount?.username ?: "" accountUsername = encryptedAccount?.username ?: ""

View File

@@ -139,9 +139,15 @@ class MessageRepository private constructor(private val context: Context) {
/** Инициализация с текущим аккаунтом */ /** Инициализация с текущим аккаунтом */
fun initialize(publicKey: String, privateKey: String) { fun initialize(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта // 🔥 Очищаем кэши при смене аккаунта
if (currentAccount != publicKey) { if (currentAccount != publicKey) {
requestedUserInfoKeys.clear() requestedUserInfoKeys.clear()
// Clear message Flow cache — dialogKey is account-independent (sorted pair),
// so stale Flows from previous account would return wrong data
messageCache.clear()
// Clear processed messageIds — prevents blocking delivery of messages
// that were already processed under the previous account
clearProcessedCache()
} }
currentAccount = publicKey currentAccount = publicKey

View File

@@ -313,17 +313,29 @@ class Protocol(
log(" Public key: ${publicKey.take(20)}...") log(" Public key: ${publicKey.take(20)}...")
log(" Private hash: ${privateHash.take(20)}...") log(" Private hash: ${privateHash.take(20)}...")
log(" Current state: ${_state.value}") log(" Current state: ${_state.value}")
// Detect account switch: already authenticated but with different credentials
val switchingAccount = (_state.value == ProtocolState.AUTHENTICATED || _state.value == ProtocolState.HANDSHAKING) &&
lastPublicKey != null && lastPublicKey != publicKey
// Save credentials for reconnection // Save credentials for reconnection
lastPublicKey = publicKey lastPublicKey = publicKey
lastPrivateHash = privateHash lastPrivateHash = privateHash
// If switching accounts, force disconnect and reconnect with new credentials
if (switchingAccount) {
log("🔄 Account switch detected, forcing reconnect with new credentials")
disconnect()
connect() // Will auto-handshake with saved credentials (publicKey, privateHash) on connect
return
}
// КРИТИЧНО: если уже в handshake или authenticated, не начинаем заново // КРИТИЧНО: если уже в handshake или authenticated, не начинаем заново
if (_state.value == ProtocolState.HANDSHAKING) { if (_state.value == ProtocolState.HANDSHAKING) {
log("⚠️ HANDSHAKE IGNORED: Already handshaking") log("⚠️ HANDSHAKE IGNORED: Already handshaking")
return return
} }
if (_state.value == ProtocolState.AUTHENTICATED) { if (_state.value == ProtocolState.AUTHENTICATED) {
log("⚠️ HANDSHAKE IGNORED: Already authenticated") log("⚠️ HANDSHAKE IGNORED: Already authenticated")
return return

View File

@@ -35,6 +35,10 @@ object ProtocolManager {
// Typing status // Typing status
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet()) private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow() val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
// Сигнал обновления own profile (username/name загружены с сервера)
private val _ownProfileUpdated = MutableStateFlow(0L)
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
// 🔍 Global user info cache (like Desktop's InformationProvider.cachedUsers) // 🔍 Global user info cache (like Desktop's InformationProvider.cachedUsers)
// publicKey → SearchUser (resolved via PacketSearch 0x03) // publicKey → SearchUser (resolved via PacketSearch 0x03)
@@ -200,6 +204,7 @@ object ProtocolManager {
if (user.username.isNotBlank()) { if (user.username.isNotBlank()) {
accountManager.updateAccountUsername(user.publicKey, user.username) accountManager.updateAccountUsername(user.publicKey, user.username)
} }
_ownProfileUpdated.value = System.currentTimeMillis()
} }
} }
} }

View File

@@ -154,15 +154,19 @@ class AuthStateManager(
_state.update { it.copy( _state.update { it.copy(
status = AuthStatus.Authenticated(decryptedAccount) status = AuthStatus.Authenticated(decryptedAccount)
)} )}
loadAccounts() loadAccounts()
// Initialize MessageRepository BEFORE connecting/authenticating
// so incoming messages from server are stored under the correct account
ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey)
// Step 8: Connect and authenticate with protocol // Step 8: Connect and authenticate with protocol
ProtocolManager.connect() ProtocolManager.connect()
// Give WebSocket time to connect before authenticating // Give WebSocket time to connect before authenticating
kotlinx.coroutines.delay(500) kotlinx.coroutines.delay(500)
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash) ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
Result.success(decryptedAccount) Result.success(decryptedAccount)
@@ -199,13 +203,17 @@ class AuthStateManager(
_state.update { it.copy( _state.update { it.copy(
status = AuthStatus.Authenticated(decryptedAccount) status = AuthStatus.Authenticated(decryptedAccount)
)} )}
// Initialize MessageRepository BEFORE connecting/authenticating
// so incoming messages from server are stored under the correct account
ProtocolManager.initializeAccount(decryptedAccount.publicKey, decryptedAccount.privateKey)
// Connect and authenticate with protocol // Connect and authenticate with protocol
ProtocolManager.connect() ProtocolManager.connect()
// Give WebSocket time to connect before authenticating // Give WebSocket time to connect before authenticating
kotlinx.coroutines.delay(500) kotlinx.coroutines.delay(500)
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash) ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
Result.success(decryptedAccount) Result.success(decryptedAccount)

View File

@@ -72,7 +72,8 @@ fun ImportSeedPhraseScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -233,8 +234,8 @@ fun ImportSeedPhraseScreen(
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(24.dp))
// Import button // Import button
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,

View File

@@ -43,15 +43,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>) // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List<ChatMessage>)
// Сделан глобальным чтобы можно было очистить при удалении диалога // Ключ включает account для изоляции данных между аккаунтами
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>() private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
/** Формирует ключ кэша с привязкой к аккаунту */
private fun cacheKey(account: String, dialogKey: String) = "$account|$dialogKey"
/** /**
* 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE * 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE
* сообщений для предотвращения OOM * сообщений для предотвращения OOM
*/ */
private fun updateCacheWithLimit(dialogKey: String, messages: List<ChatMessage>) { private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List<ChatMessage>) {
val limitedMessages = val limitedMessages =
if (messages.size > MAX_CACHE_SIZE) { if (messages.size > MAX_CACHE_SIZE) {
// Оставляем только последние сообщения (по timestamp) // Оставляем только последние сообщения (по timestamp)
@@ -61,12 +64,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
messages messages
} }
dialogMessagesCache[dialogKey] = limitedMessages dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages
} }
/** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */ /** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */
fun clearDialogCache(dialogKey: String) { fun clearDialogCache(dialogKey: String) {
dialogMessagesCache.remove(dialogKey) // Очищаем для всех аккаунтов — при удалении диалога dialogKey не привязан к аккаунту
val keysToRemove = dialogMessagesCache.keys.filter { it.endsWith("|$dialogKey") }
keysToRemove.forEach { dialogMessagesCache.remove(it) }
} }
/** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */ /** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */
@@ -74,6 +79,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) } val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) }
keysToRemove.forEach { dialogMessagesCache.remove(it) } keysToRemove.forEach { dialogMessagesCache.remove(it) }
} }
/** 🗑️ Полная очистка кэша — вызывается при переключении аккаунта */
fun clearAllDialogCache() {
dialogMessagesCache.clear()
}
} }
// Database // Database
@@ -322,11 +332,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// Обновляем кэш // Обновляем кэш
val cachedMessages = dialogMessagesCache[dialogKey] val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
if (cachedMessages != null) { if (cachedMessages != null) {
updateCacheWithLimit(dialogKey, cachedMessages + newMessages) updateCacheWithLimit(account, dialogKey, cachedMessages + newMessages)
} else { } else {
updateCacheWithLimit(dialogKey, _messages.value) updateCacheWithLimit(account, dialogKey, _messages.value)
} }
currentOffset += newMessages.size currentOffset += newMessages.size
@@ -450,7 +460,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val account = myPublicKey ?: return val account = myPublicKey ?: return
val opponent = opponentKey ?: return val opponent = opponentKey ?: return
val dialogKey = getDialogKey(account, opponent) val dialogKey = getDialogKey(account, opponent)
updateCacheWithLimit(dialogKey, _messages.value) updateCacheWithLimit(account, dialogKey, _messages.value)
} }
/** Обновить статус сообщения в БД */ /** Обновить статус сообщения в БД */
@@ -480,6 +490,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** Установить ключи пользователя */ /** Установить ключи пользователя */
fun setUserKeys(publicKey: String, privateKey: String) { fun setUserKeys(publicKey: String, privateKey: String) {
if (myPublicKey != publicKey) {
// Clear caches on account switch to prevent cross-account data leakage
// Безусловная очистка (даже если myPublicKey == null) — свежий ViewModel
// может получить стейтный кэш от предыдущего аккаунта
dialogMessagesCache.clear()
decryptionCache.clear()
}
myPublicKey = publicKey myPublicKey = publicKey
myPrivateKey = privateKey myPrivateKey = privateKey
} }
@@ -510,7 +527,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val cachedMessages = val cachedMessages =
if (account != null) { if (account != null) {
val dialogKey = getDialogKey(account, publicKey) val dialogKey = getDialogKey(account, publicKey)
dialogMessagesCache[dialogKey] dialogMessagesCache[cacheKey(account, dialogKey)]
} else null } else null
// Сбрасываем состояние // Сбрасываем состояние
@@ -602,7 +619,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть! // 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
val cachedMessages = dialogMessagesCache[dialogKey] val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
if (cachedMessages != null && cachedMessages.isNotEmpty()) { if (cachedMessages != null && cachedMessages.isNotEmpty()) {
withContext(Dispatchers.Main.immediate) { withContext(Dispatchers.Main.immediate) {
@@ -687,7 +704,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки! // 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
updateCacheWithLimit(dialogKey, messages.toList()) updateCacheWithLimit(account, dialogKey, messages.toList())
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно // 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД // НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
@@ -787,11 +804,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые! // 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые!
// Объединяем существующий кэш с новыми сообщениями // Объединяем существующий кэш с новыми сообщениями
val existingCache = dialogMessagesCache[dialogKey] ?: emptyList() val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
val allCachedIds = existingCache.map { it.id }.toSet() val allCachedIds = existingCache.map { it.id }.toSet()
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds } val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
if (trulyNewMessages.isNotEmpty()) { if (trulyNewMessages.isNotEmpty()) {
updateCacheWithLimit( updateCacheWithLimit(
account,
dialogKey, dialogKey,
(existingCache + trulyNewMessages).sortedBy { it.timestamp } (existingCache + trulyNewMessages).sortedBy { it.timestamp }
) )
@@ -881,11 +899,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений! // 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений!
// Это предотвращает потерю сообщений при повторном открытии диалога // Это предотвращает потерю сообщений при повторном открытии диалога
val existingCache = dialogMessagesCache[dialogKey] ?: emptyList() val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
val allCachedIds = existingCache.map { it.id }.toSet() val allCachedIds = existingCache.map { it.id }.toSet()
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds } val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
if (trulyNewMessages.isNotEmpty()) { if (trulyNewMessages.isNotEmpty()) {
updateCacheWithLimit( updateCacheWithLimit(
account,
dialogKey, dialogKey,
(trulyNewMessages + existingCache).sortedBy { it.timestamp } (trulyNewMessages + existingCache).sortedBy { it.timestamp }
) )
@@ -3558,7 +3577,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Очищаем кэш // Очищаем кэш
val dialogKey = getDialogKey(account, opponent) val dialogKey = getDialogKey(account, opponent)
dialogMessagesCache.remove(dialogKey) dialogMessagesCache.remove(cacheKey(account, dialogKey))
// Очищаем UI // Очищаем UI
withContext(Dispatchers.Main) { _messages.value = emptyList() } withContext(Dispatchers.Main) { _messages.value = emptyList() }

View File

@@ -49,11 +49,9 @@ import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
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.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
@@ -494,29 +492,15 @@ fun ChatsListScreen(
) )
val headerColor = avatarColors.backgroundColor val headerColor = avatarColors.backgroundColor
// Header: avatar blur или цвет шапки chat list // Header: цвет шапки сайдбара
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
if (backgroundBlurColorId == "avatar") { Box(
// Avatar blur modifier = Modifier
BlurredAvatarBackground( .matchParentSize()
publicKey = accountPublicKey, .background(
avatarRepository = avatarRepository, if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
fallbackColor = if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue, )
blurRadius = 40f, )
alpha = 0.6f,
overlayColors = null,
isDarkTheme = isDarkTheme
)
} else {
// None или любой другой — стандартный цвет шапки
Box(
modifier = Modifier
.matchParentSize()
.background(
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
)
)
}
// Content поверх фона // Content поверх фона
Column( Column(
@@ -1277,21 +1261,7 @@ fun ChatsListScreen(
val requests = chatsState.requests val requests = chatsState.requests
val requestsCount = chatsState.requestsCount val requestsCount = chatsState.requestsCount
// 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был val showSkeleton = isLoading
// показан
// Это предотвращает показ EmptyState при временных пустых
// обновлениях
var hasShownContent by rememberSaveable {
mutableStateOf(false)
}
if (chatsState.hasContent) {
hasShownContent = true
}
// 🎯 Показываем Empty State только если контент НИКОГДА не
// показывался
val shouldShowEmptyState =
chatsState.isEmpty && !hasShownContent
// 🎬 Animated content transition between main list and // 🎬 Animated content transition between main list and
// requests // requests
@@ -1427,12 +1397,9 @@ fun ChatsListScreen(
} }
) )
} // Close Box wrapper } // Close Box wrapper
} else if (isLoading) { } else if (showSkeleton) {
// 🚀 Shimmer skeleton пока данные грузятся
ChatsListSkeleton(isDarkTheme = isDarkTheme) ChatsListSkeleton(isDarkTheme = isDarkTheme)
} else if (shouldShowEmptyState) { } else if (chatsState.isEmpty) {
// 🔥 Empty state - показываем только если
// контент НЕ был показан ранее
EmptyChatsState( EmptyChatsState(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()

View File

@@ -14,6 +14,7 @@ import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -73,6 +74,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл // 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
private val subscribedOnlineKeys = mutableSetOf<String>() private val subscribedOnlineKeys = mutableSetOf<String>()
// Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null
// Список диалогов с расшифрованными сообщениями // Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList()) private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow() val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
@@ -124,16 +128,32 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта // 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear() requestedUserInfoKeys.clear()
subscribedOnlineKeys.clear()
// 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
// чтобы избежать показа сообщений с неправильным isOutgoing
ChatViewModel.clearAllDialogCache()
// Отменяем старые подписки от предыдущего аккаунта
accountSubscriptionsJob?.cancel()
currentAccount = publicKey currentAccount = publicKey
currentPrivateKey = privateKey currentPrivateKey = privateKey
// 🔥 Очищаем устаревшие данные от предыдущего аккаунта
_dialogs.value = emptyList()
_requests.value = emptyList()
_requestsCount.value = 0
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий // 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) } viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
accountSubscriptionsJob = viewModelScope.launch {
// Подписываемся на обычные диалоги // Подписываемся на обычные диалоги
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
viewModelScope.launch { launch {
dialogDao dialogDao
.getDialogsFlow(publicKey) .getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
@@ -293,7 +313,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 📬 Подписываемся на requests (запросы от новых пользователей) // 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
viewModelScope.launch { launch {
dialogDao dialogDao
.getRequestsFlow(publicKey) .getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
@@ -422,7 +442,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
// 📊 Подписываемся на количество requests // 📊 Подписываемся на количество requests
viewModelScope.launch { launch {
dialogDao dialogDao
.getRequestsCountFlow(publicKey) .getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
@@ -432,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при // 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
// blockUser()/unblockUser() // blockUser()/unblockUser()
viewModelScope.launch { launch {
database.blacklistDao() database.blacklistDao()
.getBlockedUsers(publicKey) .getBlockedUsers(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
@@ -440,6 +460,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.distinctUntilChanged() .distinctUntilChanged()
.collect { blockedSet -> _blockedUsers.value = blockedSet } .collect { blockedSet -> _blockedUsers.value = blockedSet }
} }
} // end accountSubscriptionsJob
} }
/** /**

View File

@@ -64,7 +64,11 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
import android.content.Intent
import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File
import kotlin.math.min import kotlin.math.min
private const val TAG = "AttachmentComponents" private const val TAG = "AttachmentComponents"
@@ -1344,13 +1348,37 @@ fun FileAttachment(
label = "progress" label = "progress"
) )
// Путь к скачанному файлу (как Desktop: ~/Rosetta Downloads/filename)
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
LaunchedEffect(attachment.id) { LaunchedEffect(attachment.id) {
downloadStatus = downloadStatus = if (isDownloadTag(preview)) {
if (isDownloadTag(preview)) { // Проверяем, был ли файл уже скачан ранее
DownloadStatus.NOT_DOWNLOADED if (savedFile.exists()) DownloadStatus.DOWNLOADED
} else { else DownloadStatus.NOT_DOWNLOADED
DownloadStatus.DOWNLOADED } else {
} DownloadStatus.DOWNLOADED
}
}
// Открыть файл через системное приложение
val openFile: () -> Unit = {
try {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
savedFile
)
val ext = fileName.substringAfterLast('.', "").lowercase()
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
?: "application/octet-stream"
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (_: Exception) {}
} }
val download: () -> Unit = { val download: () -> Unit = {
@@ -1364,8 +1392,6 @@ fun FileAttachment(
downloadStatus = DownloadStatus.DECRYPTING downloadStatus = DownloadStatus.DECRYPTING
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce = val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
@@ -1377,7 +1403,16 @@ fun FileAttachment(
downloadProgress = 0.9f downloadProgress = 0.9f
if (decrypted != null) { if (decrypted != null) {
// TODO: Save to Downloads folder withContext(Dispatchers.IO) {
// Декодим base64 в байты (обработка data URL и plain base64)
val base64Data = if (decrypted.contains(",")) {
decrypted.substringAfter(",")
} else {
decrypted
}
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
savedFile.writeBytes(bytes)
}
downloadProgress = 1f downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED downloadStatus = DownloadStatus.DOWNLOADED
} else { } else {
@@ -1398,8 +1433,14 @@ fun FileAttachment(
.clickable( .clickable(
enabled = enabled =
downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR downloadStatus == DownloadStatus.ERROR
) { download() } ) {
when (downloadStatus) {
DownloadStatus.DOWNLOADED -> openFile()
else -> download()
}
}
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {

View File

@@ -42,12 +42,6 @@ fun BoxScope.BlurredAvatarBackground(
overlayColors: List<Color>? = null, overlayColors: List<Color>? = null,
isDarkTheme: Boolean = true isDarkTheme: Boolean = true
) { ) {
// В светлой теме с дефолтным фоном (avatar, без overlay) — синий как шапка chat list
if (!isDarkTheme && (overlayColors == null || overlayColors.isEmpty())) {
Box(modifier = Modifier.matchParentSize().background(Color(0xFF0D8CF4)))
return
}
// Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх // Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance) // (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
if (overlayColors != null && overlayColors.isNotEmpty()) { if (overlayColors != null && overlayColors.isNotEmpty()) {