diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 20a20e4..b61bc90 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -58,7 +58,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull @@ -214,8 +213,11 @@ class MainActivity : FragmentActivity() { else -> "main" }, 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" ) { screen -> @@ -603,14 +605,13 @@ fun MainScreen( // Состояние протокола для передачи в SearchScreen val protocolState by ProtocolManager.state.collectAsState() - // Перечитать username/name после получения own profile с сервера - // Аналог Desktop: useUserInformation автоматически обновляет UI при PacketSearch ответе - LaunchedEffect(protocolState) { - if (protocolState == ProtocolState.AUTHENTICATED && + // Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile() + val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() + LaunchedEffect(ownProfileUpdated) { + if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5" ) { - delay(2000) // Ждём fetchOwnProfile() → PacketSearch → AccountManager update val accountManager = AccountManager(context) val encryptedAccount = accountManager.getAccount(accountPublicKey) accountUsername = encryptedAccount?.username ?: "" diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 019432f..e81492c 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -139,9 +139,15 @@ class MessageRepository private constructor(private val context: Context) { /** Инициализация с текущим аккаунтом */ fun initialize(publicKey: String, privateKey: String) { val start = System.currentTimeMillis() - // 🔥 Очищаем кэш запрошенных user info при смене аккаунта + // 🔥 Очищаем кэши при смене аккаунта if (currentAccount != publicKey) { 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 diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 1ae050d..9a580b3 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -313,17 +313,29 @@ class Protocol( log(" Public key: ${publicKey.take(20)}...") log(" Private hash: ${privateHash.take(20)}...") 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 lastPublicKey = publicKey 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, не начинаем заново if (_state.value == ProtocolState.HANDSHAKING) { log("⚠️ HANDSHAKE IGNORED: Already handshaking") return } - + if (_state.value == ProtocolState.AUTHENTICATED) { log("⚠️ HANDSHAKE IGNORED: Already authenticated") return diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 0c8fba9..c8405f7 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -35,6 +35,10 @@ object ProtocolManager { // Typing status private val _typingUsers = MutableStateFlow>(emptySet()) val typingUsers: StateFlow> = _typingUsers.asStateFlow() + + // Сигнал обновления own profile (username/name загружены с сервера) + private val _ownProfileUpdated = MutableStateFlow(0L) + val ownProfileUpdated: StateFlow = _ownProfileUpdated.asStateFlow() // 🔍 Global user info cache (like Desktop's InformationProvider.cachedUsers) // publicKey → SearchUser (resolved via PacketSearch 0x03) @@ -200,6 +204,7 @@ object ProtocolManager { if (user.username.isNotBlank()) { accountManager.updateAccountUsername(user.publicKey, user.username) } + _ownProfileUpdated.value = System.currentTimeMillis() } } } diff --git a/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt b/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt index a78ac2d..5632e3a 100644 --- a/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt +++ b/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt @@ -154,15 +154,19 @@ class AuthStateManager( _state.update { it.copy( status = AuthStatus.Authenticated(decryptedAccount) )} - + 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 ProtocolManager.connect() - + // Give WebSocket time to connect before authenticating kotlinx.coroutines.delay(500) - + ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash) Result.success(decryptedAccount) @@ -199,13 +203,17 @@ class AuthStateManager( _state.update { it.copy( 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 ProtocolManager.connect() - + // Give WebSocket time to connect before authenticating kotlinx.coroutines.delay(500) - + ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash) Result.success(decryptedAccount) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt index c70dfc0..d9acf42 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/ImportSeedPhraseScreen.kt @@ -72,7 +72,8 @@ fun ImportSeedPhraseScreen( modifier = Modifier .fillMaxSize() .imePadding() - .padding(horizontal = 24.dp), + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { 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 AnimatedVisibility( visible = visible, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 778bc9d..d9dabfa 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -43,15 +43,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List) - // Сделан глобальным чтобы можно было очистить при удалении диалога + // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List) + // Ключ включает account для изоляции данных между аккаунтами private val dialogMessagesCache = ConcurrentHashMap>() + /** Формирует ключ кэша с привязкой к аккаунту */ + private fun cacheKey(account: String, dialogKey: String) = "$account|$dialogKey" + /** * 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE * сообщений для предотвращения OOM */ - private fun updateCacheWithLimit(dialogKey: String, messages: List) { + private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List) { val limitedMessages = if (messages.size > MAX_CACHE_SIZE) { // Оставляем только последние сообщения (по timestamp) @@ -61,12 +64,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } else { messages } - dialogMessagesCache[dialogKey] = limitedMessages + dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages } /** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */ fun clearDialogCache(dialogKey: String) { - dialogMessagesCache.remove(dialogKey) + // Очищаем для всех аккаунтов — при удалении диалога dialogKey не привязан к аккаунту + val keysToRemove = dialogMessagesCache.keys.filter { it.endsWith("|$dialogKey") } + keysToRemove.forEach { dialogMessagesCache.remove(it) } } /** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */ @@ -74,6 +79,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) } keysToRemove.forEach { dialogMessagesCache.remove(it) } } + + /** 🗑️ Полная очистка кэша — вызывается при переключении аккаунта */ + fun clearAllDialogCache() { + dialogMessagesCache.clear() + } } // Database @@ -322,11 +332,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // Обновляем кэш - val cachedMessages = dialogMessagesCache[dialogKey] + val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)] if (cachedMessages != null) { - updateCacheWithLimit(dialogKey, cachedMessages + newMessages) + updateCacheWithLimit(account, dialogKey, cachedMessages + newMessages) } else { - updateCacheWithLimit(dialogKey, _messages.value) + updateCacheWithLimit(account, dialogKey, _messages.value) } currentOffset += newMessages.size @@ -450,7 +460,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val account = myPublicKey ?: return val opponent = opponentKey ?: return 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) { + if (myPublicKey != publicKey) { + // Clear caches on account switch to prevent cross-account data leakage + // Безусловная очистка (даже если myPublicKey == null) — свежий ViewModel + // может получить стейтный кэш от предыдущего аккаунта + dialogMessagesCache.clear() + decryptionCache.clear() + } myPublicKey = publicKey myPrivateKey = privateKey } @@ -510,7 +527,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val cachedMessages = if (account != null) { val dialogKey = getDialogKey(account, publicKey) - dialogMessagesCache[dialogKey] + dialogMessagesCache[cacheKey(account, dialogKey)] } else null // Сбрасываем состояние @@ -602,7 +619,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch(Dispatchers.IO) { try { // 🔥 МГНОВЕННАЯ загрузка из кэша если есть! - val cachedMessages = dialogMessagesCache[dialogKey] + val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)] if (cachedMessages != null && cachedMessages.isNotEmpty()) { withContext(Dispatchers.Main.immediate) { @@ -687,7 +704,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // 🔥 Сохраняем в кэш для мгновенной повторной загрузки! - updateCacheWithLimit(dialogKey, messages.toList()) + updateCacheWithLimit(account, dialogKey, messages.toList()) // 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно // НО сохраняем оптимистичные сообщения (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 trulyNewMessages = newMessages.filter { it.id !in allCachedIds } if (trulyNewMessages.isNotEmpty()) { updateCacheWithLimit( + account, dialogKey, (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 trulyNewMessages = newMessages.filter { it.id !in allCachedIds } if (trulyNewMessages.isNotEmpty()) { updateCacheWithLimit( + account, dialogKey, (trulyNewMessages + existingCache).sortedBy { it.timestamp } ) @@ -3558,7 +3577,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Очищаем кэш val dialogKey = getDialogKey(account, opponent) - dialogMessagesCache.remove(dialogKey) + dialogMessagesCache.remove(cacheKey(account, dialogKey)) // Очищаем UI withContext(Dispatchers.Main) { _messages.value = emptyList() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 09afef6..e780913 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -49,11 +49,9 @@ import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.components.AnimatedDotsText import com.rosetta.messenger.ui.components.AppleEmojiText 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.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark -import com.rosetta.messenger.ui.settings.BackgroundBlurPresets import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.graphics.painter.Painter @@ -494,29 +492,15 @@ fun ChatsListScreen( ) val headerColor = avatarColors.backgroundColor - // Header: avatar blur или цвет шапки chat list + // Header: цвет шапки сайдбара Box(modifier = Modifier.fillMaxWidth()) { - if (backgroundBlurColorId == "avatar") { - // Avatar blur - BlurredAvatarBackground( - publicKey = accountPublicKey, - avatarRepository = avatarRepository, - 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 - ) - ) - } + Box( + modifier = Modifier + .matchParentSize() + .background( + if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue + ) + ) // Content поверх фона Column( @@ -1277,21 +1261,7 @@ fun ChatsListScreen( val requests = chatsState.requests val requestsCount = chatsState.requestsCount - // 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был - // показан - // Это предотвращает показ EmptyState при временных пустых - // обновлениях - var hasShownContent by rememberSaveable { - mutableStateOf(false) - } - if (chatsState.hasContent) { - hasShownContent = true - } - - // 🎯 Показываем Empty State только если контент НИКОГДА не - // показывался - val shouldShowEmptyState = - chatsState.isEmpty && !hasShownContent + val showSkeleton = isLoading // 🎬 Animated content transition between main list and // requests @@ -1427,12 +1397,9 @@ fun ChatsListScreen( } ) } // Close Box wrapper - } else if (isLoading) { - // 🚀 Shimmer skeleton пока данные грузятся + } else if (showSkeleton) { ChatsListSkeleton(isDarkTheme = isDarkTheme) - } else if (shouldShowEmptyState) { - // 🔥 Empty state - показываем только если - // контент НЕ был показан ранее + } else if (chatsState.isEmpty) { EmptyChatsState( isDarkTheme = isDarkTheme, modifier = Modifier.fillMaxSize() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index d2df47d..08ad54d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -14,6 +14,7 @@ import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* @@ -73,6 +74,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл private val subscribedOnlineKeys = mutableSetOf() + // Job для отмены подписок при смене аккаунта + private var accountSubscriptionsJob: Job? = null + // Список диалогов с расшифрованными сообщениями private val _dialogs = MutableStateFlow>(emptyList()) val dialogs: StateFlow> = _dialogs.asStateFlow() @@ -124,16 +128,32 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // 🔥 Очищаем кэш запрошенных user info при смене аккаунта requestedUserInfoKeys.clear() + subscribedOnlineKeys.clear() + + // 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта + // чтобы избежать показа сообщений с неправильным isOutgoing + ChatViewModel.clearAllDialogCache() + + // Отменяем старые подписки от предыдущего аккаунта + accountSubscriptionsJob?.cancel() currentAccount = publicKey currentPrivateKey = privateKey + // 🔥 Очищаем устаревшие данные от предыдущего аккаунта + _dialogs.value = emptyList() + _requests.value = emptyList() + _requestsCount.value = 0 + // 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) } + // Запускаем все подписки в одном родительском Job для отмены при смене аккаунта + accountSubscriptionsJob = viewModelScope.launch { + // Подписываемся на обычные диалоги @OptIn(FlowPreview::class) - viewModelScope.launch { + launch { dialogDao .getDialogsFlow(publicKey) .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO @@ -293,7 +313,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // 📬 Подписываемся на requests (запросы от новых пользователей) @OptIn(FlowPreview::class) - viewModelScope.launch { + launch { dialogDao .getRequestsFlow(publicKey) .flowOn(Dispatchers.IO) @@ -422,7 +442,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } // 📊 Подписываемся на количество requests - viewModelScope.launch { + launch { dialogDao .getRequestsCountFlow(publicKey) .flowOn(Dispatchers.IO) @@ -432,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // 🔥 Подписываемся на блоклист — Room автоматически переэмитит при // blockUser()/unblockUser() - viewModelScope.launch { + launch { database.blacklistDao() .getBlockedUsers(publicKey) .flowOn(Dispatchers.IO) @@ -440,6 +460,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio .distinctUntilChanged() .collect { blockedSet -> _blockedUsers.value = blockedSet } } + + } // end accountSubscriptionsJob } /** diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index cd34e02..beeab53 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -64,7 +64,11 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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.File import kotlin.math.min private const val TAG = "AttachmentComponents" @@ -1344,13 +1348,37 @@ fun FileAttachment( 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) { - downloadStatus = - if (isDownloadTag(preview)) { - DownloadStatus.NOT_DOWNLOADED - } else { - DownloadStatus.DOWNLOADED - } + downloadStatus = if (isDownloadTag(preview)) { + // Проверяем, был ли файл уже скачан ранее + if (savedFile.exists()) DownloadStatus.DOWNLOADED + else DownloadStatus.NOT_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 = { @@ -1364,8 +1392,6 @@ fun FileAttachment( downloadStatus = DownloadStatus.DECRYPTING - // КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop) - // Сначала расшифровываем его, получаем raw bytes val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) @@ -1377,7 +1403,16 @@ fun FileAttachment( downloadProgress = 0.9f 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 downloadStatus = DownloadStatus.DOWNLOADED } else { @@ -1398,8 +1433,14 @@ fun FileAttachment( .clickable( enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || + downloadStatus == DownloadStatus.DOWNLOADED || downloadStatus == DownloadStatus.ERROR - ) { download() } + ) { + when (downloadStatus) { + DownloadStatus.DOWNLOADED -> openFile() + else -> download() + } + } .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index 5e84377..8d16c43 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -42,12 +42,6 @@ fun BoxScope.BlurredAvatarBackground( overlayColors: List? = null, isDarkTheme: Boolean = true ) { - // В светлой теме с дефолтным фоном (avatar, без overlay) — синий как шапка chat list - if (!isDarkTheme && (overlayColors == null || overlayColors.isEmpty())) { - Box(modifier = Modifier.matchParentSize().background(Color(0xFF0D8CF4))) - return - } - // Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх // (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance) if (overlayColors != null && overlayColors.isNotEmpty()) {