From 162747ea352507bb6f0fd5299c4af8debc54cc2e Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 8 Feb 2026 06:18:20 +0500 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- .../com/rosetta/messenger/MainActivity.kt | 574 +-- .../messenger/ui/chats/ChatDetailScreen.kt | 1282 +++---- .../messenger/ui/chats/ChatViewModel.kt | 3093 +++++++++-------- 3 files changed, 2640 insertions(+), 2309 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index eb974e9..64acce1 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -4,7 +4,6 @@ import android.Manifest import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -21,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import com.google.firebase.FirebaseApp import com.google.firebase.messaging.FirebaseMessaging @@ -29,12 +29,12 @@ import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.database.RosettaDatabase -import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.network.PacketPushNotification import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.PushNotificationAction import com.rosetta.messenger.network.SearchUser +import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.chats.ChatDetailScreen @@ -42,6 +42,7 @@ import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.SwipeBackContainer +import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.settings.BackupScreen import com.rosetta.messenger.ui.settings.BiometricEnableScreen @@ -50,7 +51,6 @@ import com.rosetta.messenger.ui.settings.ProfileScreen import com.rosetta.messenger.ui.settings.SafetyScreen import com.rosetta.messenger.ui.settings.ThemeScreen import com.rosetta.messenger.ui.settings.UpdatesScreen -import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import java.text.SimpleDateFormat @@ -58,7 +58,6 @@ import java.util.Date import java.util.Locale import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import androidx.fragment.app.FragmentActivity class MainActivity : FragmentActivity() { private lateinit var preferencesManager: PreferencesManager @@ -134,12 +133,13 @@ class MainActivity : FragmentActivity() { val scope = rememberCoroutineScope() val themeMode by preferencesManager.themeMode.collectAsState(initial = "dark") val systemInDarkTheme = isSystemInDarkTheme() - val isDarkTheme = when (themeMode) { - "light" -> false - "dark" -> true - "auto" -> systemInDarkTheme - else -> true - } + val isDarkTheme = + when (themeMode) { + "light" -> false + "dark" -> true + "auto" -> systemInDarkTheme + else -> true + } val isLoggedIn by accountManager.isLoggedIn.collectAsState(initial = null) var showSplash by remember { mutableStateOf(true) } var showOnboarding by remember { mutableStateOf(true) } @@ -315,9 +315,7 @@ class MainActivity : FragmentActivity() { } }, onThemeModeChange = { mode -> - scope.launch { - preferencesManager.setThemeMode(mode) - } + scope.launch { preferencesManager.setThemeMode(mode) } }, onLogout = { // Set currentAccount to null immediately to prevent UI @@ -343,10 +341,13 @@ class MainActivity : FragmentActivity() { .filter { it.isNotEmpty() } .let { words -> when { - words.isEmpty() -> "??" + words.isEmpty() -> + "??" words.size == 1 -> words[0] - .take(2) + .take( + 2 + ) .uppercase() else -> "${words[0].first()}${words[1].first()}".uppercase() @@ -475,6 +476,27 @@ class MainActivity : FragmentActivity() { } } +/** + * Navigation sealed class — replaces ~15 boolean flags with a type-safe navigation stack. ChatsList + * is always the base layer and is not part of the stack. Each SwipeBackContainer reads a + * derivedStateOf, so pushing/popping an unrelated screen won't trigger recomposition of other + * screens. + */ +sealed class Screen { + data object Profile : Screen() + data object Search : Screen() + data class ChatDetail(val user: SearchUser) : Screen() + data class OtherProfile(val user: SearchUser) : Screen() + data object Updates : Screen() + data object Theme : Screen() + data object Safety : Screen() + data object Backup : Screen() + data object Logs : Screen() + data object CrashLogs : Screen() + data object Biometric : Screen() + data object Appearance : Screen() +} + @Composable fun MainScreen( account: DecryptedAccount? = null, @@ -495,12 +517,12 @@ fun MainScreen( val accountPublicKey = account?.publicKey ?: "04c266b98ae5" val accountPrivateKey = account?.privateKey ?: "" val privateKeyHash = account?.privateKeyHash ?: "" - + // Username state - загружается из EncryptedAccount // Following desktop version pattern: username is stored locally and loaded on app start var accountUsername by remember { mutableStateOf("") } var reloadTrigger by remember { mutableIntStateOf(0) } - + // Load username AND name from AccountManager (persisted in DataStore) val context = LocalContext.current LaunchedEffect(accountPublicKey, reloadTrigger) { @@ -514,11 +536,14 @@ fun MainScreen( // Состояние протокола для передачи в SearchScreen val protocolState by ProtocolManager.state.collectAsState() - + // Перечитать username/name после получения own profile с сервера // Аналог Desktop: useUserInformation автоматически обновляет UI при PacketSearch ответе LaunchedEffect(protocolState) { - if (protocolState == ProtocolState.AUTHENTICATED && accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") { + if (protocolState == ProtocolState.AUTHENTICATED && + accountPublicKey.isNotBlank() && + accountPublicKey != "04c266b98ae5" + ) { delay(2000) // Ждём fetchOwnProfile() → PacketSearch → AccountManager update val accountManager = AccountManager(context) val encryptedAccount = accountManager.getAccount(accountPublicKey) @@ -527,46 +552,92 @@ fun MainScreen( } } - // Навигация между экранами - var selectedUser by remember { mutableStateOf(null) } - var showSearchScreen by remember { mutableStateOf(false) } - var showProfileScreen by remember { mutableStateOf(false) } - var showOtherProfileScreen by remember { mutableStateOf(false) } - var selectedOtherUser by remember { mutableStateOf(null) } - - // Дополнительные экраны настроек - var showUpdatesScreen by remember { mutableStateOf(false) } - var showThemeScreen by remember { mutableStateOf(false) } - var showSafetyScreen by remember { mutableStateOf(false) } - var showBackupScreen by remember { mutableStateOf(false) } - var showLogsScreen by remember { mutableStateOf(false) } - var showCrashLogsScreen by remember { mutableStateOf(false) } - var showBiometricScreen by remember { mutableStateOf(false) } - var showAppearanceScreen by remember { mutableStateOf(false) } + // ═══════════════════════════════════════════════════════════ + // Navigation stack — sealed class instead of ~15 boolean flags. + // ChatsList is always the base layer (not in stack). + // Each derivedStateOf only recomposes its SwipeBackContainer + // when that specific screen appears/disappears — not on every + // navigation change. This eliminates the massive recomposition + // that happened when ANY boolean flag changed. + // ═══════════════════════════════════════════════════════════ + var navStack by remember { mutableStateOf>(emptyList()) } + + // Derived visibility — only triggers recomposition when THIS screen changes + val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } } + val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } } + val chatDetailScreen by remember { + derivedStateOf { navStack.filterIsInstance().lastOrNull() } + } + val selectedUser = chatDetailScreen?.user + val otherProfileScreen by remember { + derivedStateOf { navStack.filterIsInstance().lastOrNull() } + } + val selectedOtherUser = otherProfileScreen?.user + val isUpdatesVisible by remember { derivedStateOf { navStack.any { it is Screen.Updates } } } + val isThemeVisible by remember { derivedStateOf { navStack.any { it is Screen.Theme } } } + val isSafetyVisible by remember { derivedStateOf { navStack.any { it is Screen.Safety } } } + val isBackupVisible by remember { derivedStateOf { navStack.any { it is Screen.Backup } } } + val isLogsVisible by remember { derivedStateOf { navStack.any { it is Screen.Logs } } } + val isCrashLogsVisible by remember { + derivedStateOf { navStack.any { it is Screen.CrashLogs } } + } + val isBiometricVisible by remember { + derivedStateOf { navStack.any { it is Screen.Biometric } } + } + val isAppearanceVisible by remember { + derivedStateOf { navStack.any { it is Screen.Appearance } } + } + + // Navigation helpers + fun pushScreen(screen: Screen) { + navStack = navStack + screen + } + fun popScreen() { + navStack = navStack.dropLast(1) + } + fun popProfileAndChildren() { + navStack = + navStack.filterNot { + it is Screen.Profile || + it is Screen.Theme || + it is Screen.Safety || + it is Screen.Backup || + it is Screen.Logs || + it is Screen.CrashLogs || + it is Screen.Biometric || + it is Screen.Appearance + } + } + fun popChatAndChildren() { + navStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile } + } // ProfileViewModel для логов - val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel() + val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = + androidx.lifecycle.viewmodel.compose.viewModel() val profileState by profileViewModel.state.collectAsState() - + // Appearance: background blur color preference val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } - val backgroundBlurColorId by prefsManager.backgroundBlurColorId.collectAsState(initial = "avatar") + val backgroundBlurColorId by + prefsManager.backgroundBlurColorId.collectAsState(initial = "avatar") val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet()) - + // AvatarRepository для работы с аватарами - val avatarRepository = remember(accountPublicKey) { - if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") { - val database = RosettaDatabase.getDatabase(context) - AvatarRepository( - context = context, - avatarDao = database.avatarDao(), - currentPublicKey = accountPublicKey - ) - } else { - null - } - } - + val avatarRepository = + remember(accountPublicKey) { + if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") { + val database = RosettaDatabase.getDatabase(context) + AvatarRepository( + context = context, + avatarDao = database.avatarDao(), + currentPublicKey = accountPublicKey + ) + } else { + null + } + } + // Coroutine scope for profile updates val mainScreenScope = rememberCoroutineScope() @@ -582,9 +653,7 @@ fun MainScreen( accountPrivateKey = accountPrivateKey, privateKeyHash = privateKeyHash, onToggleTheme = onToggleTheme, - onProfileClick = { - showProfileScreen = true - }, + onProfileClick = { pushScreen(Screen.Profile) }, onNewGroupClick = { // TODO: Navigate to new group }, @@ -596,43 +665,46 @@ fun MainScreen( }, onSavedMessagesClick = { // Открываем чат с самим собой (Saved Messages) - selectedUser = - SearchUser( - title = "Saved Messages", - username = "", - publicKey = accountPublicKey, - verified = 0, - online = 1 + pushScreen( + Screen.ChatDetail( + SearchUser( + title = "Saved Messages", + username = "", + publicKey = accountPublicKey, + verified = 0, + online = 1 + ) ) + ) }, - onSettingsClick = { showProfileScreen = true }, + onSettingsClick = { pushScreen(Screen.Profile) }, onInviteFriendsClick = { // TODO: Share invite link }, - onSearchClick = { showSearchScreen = true }, + onSearchClick = { pushScreen(Screen.Search) }, onNewChat = { // TODO: Show new chat screen }, - onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser }, + onUserSelect = { selectedChatUser -> + pushScreen(Screen.ChatDetail(selectedChatUser)) + }, backgroundBlurColorId = backgroundBlurColorId, pinnedChats = pinnedChats, onTogglePin = { opponentKey -> - mainScreenScope.launch { - prefsManager.togglePinChat(opponentKey) - } + mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } }, avatarRepository = avatarRepository, onLogout = onLogout - ) + ) // ═══════════════════════════════════════════════════════════ // Profile Screen — MUST be before sub-screens so it stays // visible beneath them during swipe-back animation // ═══════════════════════════════════════════════════════════ SwipeBackContainer( - isVisible = showProfileScreen, - onBack = { showProfileScreen = false }, - isDarkTheme = isDarkTheme + isVisible = isProfileVisible, + onBack = { popProfileAndChildren() }, + isDarkTheme = isDarkTheme ) { // Экран профиля ProfileScreen( @@ -641,33 +713,19 @@ fun MainScreen( accountUsername = accountUsername, accountPublicKey = accountPublicKey, accountPrivateKeyHash = privateKeyHash, - onBack = { showProfileScreen = false }, + onBack = { popProfileAndChildren() }, onSaveProfile = { name, username -> accountName = name accountUsername = username - mainScreenScope.launch { - onAccountInfoUpdated() - } + mainScreenScope.launch { onAccountInfoUpdated() } }, onLogout = onLogout, - onNavigateToTheme = { - showThemeScreen = true - }, - onNavigateToAppearance = { - showAppearanceScreen = true - }, - onNavigateToSafety = { - showSafetyScreen = true - }, - onNavigateToLogs = { - showLogsScreen = true - }, - onNavigateToCrashLogs = { - showCrashLogsScreen = true - }, - onNavigateToBiometric = { - showBiometricScreen = true - }, + onNavigateToTheme = { pushScreen(Screen.Theme) }, + onNavigateToAppearance = { pushScreen(Screen.Appearance) }, + onNavigateToSafety = { pushScreen(Screen.Safety) }, + onNavigateToLogs = { pushScreen(Screen.Logs) }, + onNavigateToCrashLogs = { pushScreen(Screen.CrashLogs) }, + onNavigateToBiometric = { pushScreen(Screen.Biometric) }, viewModel = profileViewModel, avatarRepository = avatarRepository, dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), @@ -677,132 +735,109 @@ fun MainScreen( // Other screens with swipe back SwipeBackContainer( - isVisible = showBackupScreen, - onBack = { - showBackupScreen = false - showSafetyScreen = true - }, - isDarkTheme = isDarkTheme + isVisible = isBackupVisible, + onBack = { navStack = navStack.filterNot { it is Screen.Backup } + Screen.Safety }, + isDarkTheme = isDarkTheme ) { BackupScreen( - isDarkTheme = isDarkTheme, - onBack = { - showBackupScreen = false - showSafetyScreen = true - }, - onVerifyPassword = { password -> - // Verify password by trying to decrypt the private key - try { - val publicKey = account?.publicKey ?: return@BackupScreen null - val accountManager = AccountManager(context) - val encryptedAccount = accountManager.getAccount(publicKey) + isDarkTheme = isDarkTheme, + onBack = { + navStack = navStack.filterNot { it is Screen.Backup } + Screen.Safety + }, + onVerifyPassword = { password -> + // Verify password by trying to decrypt the private key + try { + val publicKey = account?.publicKey ?: return@BackupScreen null + val accountManager = AccountManager(context) + val encryptedAccount = accountManager.getAccount(publicKey) - if (encryptedAccount != null) { - // Try to decrypt private key with password - val decryptedPrivateKey = com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword( - encryptedAccount.encryptedPrivateKey, - password - ) + if (encryptedAccount != null) { + // Try to decrypt private key with password + val decryptedPrivateKey = + com.rosetta.messenger.crypto.CryptoManager + .decryptWithPassword( + encryptedAccount.encryptedPrivateKey, + password + ) - if (decryptedPrivateKey != null) { - // Password is correct, decrypt seed phrase - com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword( - encryptedAccount.encryptedSeedPhrase, - password - ) + if (decryptedPrivateKey != null) { + // Password is correct, decrypt seed phrase + com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword( + encryptedAccount.encryptedSeedPhrase, + password + ) + } else { + null + } } else { null } - } else { + } catch (e: Exception) { null } - } catch (e: Exception) { - null } - } ) } SwipeBackContainer( - isVisible = showSafetyScreen, - onBack = { - showSafetyScreen = false - showProfileScreen = true - }, - isDarkTheme = isDarkTheme + isVisible = isSafetyVisible, + onBack = { navStack = navStack.filterNot { it is Screen.Safety } }, + isDarkTheme = isDarkTheme ) { SafetyScreen( - isDarkTheme = isDarkTheme, - accountPublicKey = accountPublicKey, - accountPrivateKey = accountPrivateKey, - onBack = { - showSafetyScreen = false - showProfileScreen = true - }, - onBackupClick = { - showSafetyScreen = false - showBackupScreen = true - }, - onDeleteAccount = { - // TODO: Implement account deletion - } + isDarkTheme = isDarkTheme, + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + onBack = { navStack = navStack.filterNot { it is Screen.Safety } }, + onBackupClick = { + navStack = navStack.filterNot { it is Screen.Safety } + Screen.Backup + }, + onDeleteAccount = { + // TODO: Implement account deletion + } ) } SwipeBackContainer( - isVisible = showThemeScreen, - onBack = { - showThemeScreen = false - showProfileScreen = true - }, - isDarkTheme = isDarkTheme + isVisible = isThemeVisible, + onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, + isDarkTheme = isDarkTheme ) { ThemeScreen( - isDarkTheme = isDarkTheme, - currentThemeMode = themeMode, - onBack = { - showThemeScreen = false - showProfileScreen = true - }, - onThemeModeChange = onThemeModeChange + isDarkTheme = isDarkTheme, + currentThemeMode = themeMode, + onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, + onThemeModeChange = onThemeModeChange ) } SwipeBackContainer( - isVisible = showAppearanceScreen, - onBack = { - showAppearanceScreen = false - showProfileScreen = true - }, - isDarkTheme = isDarkTheme + isVisible = isAppearanceVisible, + onBack = { navStack = navStack.filterNot { it is Screen.Appearance } }, + isDarkTheme = isDarkTheme ) { com.rosetta.messenger.ui.settings.AppearanceScreen( - isDarkTheme = isDarkTheme, - currentBlurColorId = backgroundBlurColorId, - onBack = { - showAppearanceScreen = false - showProfileScreen = true - }, - onBlurColorChange = { newId -> - mainScreenScope.launch { - prefsManager.setBackgroundBlurColorId(newId) - } - }, - onToggleTheme = onToggleTheme, - accountPublicKey = accountPublicKey, - accountName = accountName, - avatarRepository = avatarRepository + isDarkTheme = isDarkTheme, + currentBlurColorId = backgroundBlurColorId, + onBack = { navStack = navStack.filterNot { it is Screen.Appearance } }, + onBlurColorChange = { newId -> + mainScreenScope.launch { prefsManager.setBackgroundBlurColorId(newId) } + }, + onToggleTheme = onToggleTheme, + accountPublicKey = accountPublicKey, + accountName = accountName, + avatarRepository = avatarRepository ) } SwipeBackContainer( - isVisible = showUpdatesScreen, - onBack = { showUpdatesScreen = false }, - isDarkTheme = isDarkTheme + isVisible = isUpdatesVisible, + onBack = { navStack = navStack.filterNot { it is Screen.Updates } }, + isDarkTheme = isDarkTheme ) { UpdatesScreen( - isDarkTheme = isDarkTheme, - onBack = { showUpdatesScreen = false } + isDarkTheme = isDarkTheme, + onBack = { navStack = navStack.filterNot { it is Screen.Updates } } ) } @@ -810,40 +845,40 @@ fun MainScreen( var isImageViewerOpen by remember { mutableStateOf(false) } SwipeBackContainer( - isVisible = selectedUser != null, - onBack = { selectedUser = null }, - isDarkTheme = isDarkTheme, - swipeEnabled = !isImageViewerOpen + isVisible = selectedUser != null, + onBack = { popChatAndChildren() }, + isDarkTheme = isDarkTheme, + swipeEnabled = !isImageViewerOpen ) { - if (selectedUser != null) { + selectedUser?.let { currentChatUser -> // Экран чата ChatDetailScreen( - user = selectedUser!!, + user = currentChatUser, currentUserPublicKey = accountPublicKey, currentUserPrivateKey = accountPrivateKey, - onBack = { selectedUser = null }, + onBack = { popChatAndChildren() }, onUserProfileClick = { user -> // Открываем профиль другого пользователя - selectedOtherUser = user - showOtherProfileScreen = true + pushScreen(Screen.OtherProfile(user)) }, onNavigateToChat = { forwardUser -> // 📨 Forward: переход в выбранный чат с полными данными - selectedUser = forwardUser + navStack = + navStack.filterNot { + it is Screen.ChatDetail || it is Screen.OtherProfile + } + Screen.ChatDetail(forwardUser) }, isDarkTheme = isDarkTheme, avatarRepository = avatarRepository, - onImageViewerChanged = { isOpen -> - isImageViewerOpen = isOpen - } + onImageViewerChanged = { isOpen -> isImageViewerOpen = isOpen } ) } } SwipeBackContainer( - isVisible = showSearchScreen, - onBack = { showSearchScreen = false }, - isDarkTheme = isDarkTheme + isVisible = isSearchVisible, + onBack = { navStack = navStack.filterNot { it is Screen.Search } }, + isDarkTheme = isDarkTheme ) { // Экран поиска SearchScreen( @@ -851,114 +886,95 @@ fun MainScreen( currentUserPublicKey = accountPublicKey, isDarkTheme = isDarkTheme, protocolState = protocolState, - onBackClick = { showSearchScreen = false }, + onBackClick = { navStack = navStack.filterNot { it is Screen.Search } }, onUserSelect = { selectedSearchUser -> - showSearchScreen = false - selectedUser = selectedSearchUser + navStack = + navStack.filterNot { it is Screen.Search } + + Screen.ChatDetail(selectedSearchUser) } ) } SwipeBackContainer( - isVisible = showLogsScreen, - onBack = { - showLogsScreen = false - showProfileScreen = true - }, - isDarkTheme = isDarkTheme + isVisible = isLogsVisible, + onBack = { navStack = navStack.filterNot { it is Screen.Logs } }, + isDarkTheme = isDarkTheme ) { com.rosetta.messenger.ui.settings.ProfileLogsScreen( - isDarkTheme = isDarkTheme, - logs = profileState.logs, - onBack = { - showLogsScreen = false - showProfileScreen = true - }, - onClearLogs = { - profileViewModel.clearLogs() - } + isDarkTheme = isDarkTheme, + logs = profileState.logs, + onBack = { navStack = navStack.filterNot { it is Screen.Logs } }, + onClearLogs = { profileViewModel.clearLogs() } ) } SwipeBackContainer( - isVisible = showCrashLogsScreen, - onBack = { - showCrashLogsScreen = false - showProfileScreen = true - }, - isDarkTheme = isDarkTheme + isVisible = isCrashLogsVisible, + onBack = { navStack = navStack.filterNot { it is Screen.CrashLogs } }, + isDarkTheme = isDarkTheme ) { CrashLogsScreen( - onBackClick = { - showCrashLogsScreen = false - showProfileScreen = true - } + onBackClick = { navStack = navStack.filterNot { it is Screen.CrashLogs } } ) } SwipeBackContainer( - isVisible = showOtherProfileScreen, - onBack = { - showOtherProfileScreen = false - selectedOtherUser = null - }, - isDarkTheme = isDarkTheme + isVisible = selectedOtherUser != null, + onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, + isDarkTheme = isDarkTheme ) { - if (selectedOtherUser != null) { + selectedOtherUser?.let { currentOtherUser -> OtherProfileScreen( - user = selectedOtherUser!!, - isDarkTheme = isDarkTheme, - onBack = { - showOtherProfileScreen = false - selectedOtherUser = null - }, - avatarRepository = avatarRepository + user = currentOtherUser, + isDarkTheme = isDarkTheme, + onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, + avatarRepository = avatarRepository ) } } // Biometric Enable Screen SwipeBackContainer( - isVisible = showBiometricScreen, - onBack = { - showBiometricScreen = false - showProfileScreen = true - }, - isDarkTheme = isDarkTheme + isVisible = isBiometricVisible, + onBack = { navStack = navStack.filterNot { it is Screen.Biometric } }, + isDarkTheme = isDarkTheme ) { - val biometricManager = remember { com.rosetta.messenger.biometric.BiometricAuthManager(context) } - val biometricPrefs = remember { com.rosetta.messenger.biometric.BiometricPreferences(context) } + val biometricManager = remember { + com.rosetta.messenger.biometric.BiometricAuthManager(context) + } + val biometricPrefs = remember { + com.rosetta.messenger.biometric.BiometricPreferences(context) + } val activity = context as? FragmentActivity BiometricEnableScreen( - isDarkTheme = isDarkTheme, - onBack = { - showBiometricScreen = false - showProfileScreen = true - }, - onEnable = { password, onSuccess, onError -> - if (activity == null) { - onError("Activity not available") - return@BiometricEnableScreen - } - - biometricManager.encryptPassword( - activity = activity, - password = password, - onSuccess = { encryptedPassword -> - mainScreenScope.launch { - biometricPrefs.saveEncryptedPassword(accountPublicKey, encryptedPassword) - biometricPrefs.enableBiometric() - onSuccess() - } - }, - onError = { error -> onError(error) }, - onCancel = { - showBiometricScreen = false - showProfileScreen = true + isDarkTheme = isDarkTheme, + onBack = { navStack = navStack.filterNot { it is Screen.Biometric } }, + onEnable = { password, onSuccess, onError -> + if (activity == null) { + onError("Activity not available") + return@BiometricEnableScreen } - ) - } + + biometricManager.encryptPassword( + activity = activity, + password = password, + onSuccess = { encryptedPassword -> + mainScreenScope.launch { + biometricPrefs.saveEncryptedPassword( + accountPublicKey, + encryptedPassword + ) + biometricPrefs.enableBiometric() + onSuccess() + } + }, + onError = { error -> onError(error) }, + onCancel = { + navStack = navStack.filterNot { it is Screen.Biometric } + } + ) + } ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index e32f84a..df8b8c3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -1,13 +1,15 @@ package com.rosetta.messenger.ui.chats -import android.content.ClipboardManager +import android.app.Activity import android.content.Context +import android.net.Uri import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween @@ -16,7 +18,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -28,6 +29,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -35,11 +37,10 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -51,58 +52,47 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel +import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition import com.rosetta.messenger.R +import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository -import com.rosetta.messenger.ui.components.AvatarImage +import com.rosetta.messenger.ui.chats.components.* +import com.rosetta.messenger.ui.chats.components.ImageEditorScreen +import com.rosetta.messenger.ui.chats.components.InAppCameraScreen +import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen +import com.rosetta.messenger.ui.chats.input.* import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.utils.* -import com.rosetta.messenger.ui.chats.components.* -import com.rosetta.messenger.ui.chats.input.* +import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.VerifiedBadge -import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import com.rosetta.messenger.data.ForwardManager -import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet import com.rosetta.messenger.utils.MediaUtils -import com.rosetta.messenger.ui.chats.components.ImageEditorScreen -import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen -import com.rosetta.messenger.ui.chats.components.ImageWithCaption -import com.rosetta.messenger.ui.chats.components.InAppCameraScreen -import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator -import androidx.compose.runtime.collectAsState -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import android.net.Uri -import android.provider.MediaStore -import androidx.core.content.FileProvider -import java.io.File -import kotlinx.coroutines.delay -import android.app.Activity -import androidx.core.view.WindowCompat +import java.text.SimpleDateFormat +import java.util.Locale import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @OptIn( - ExperimentalMaterial3Api::class, - androidx.compose.foundation.ExperimentalFoundationApi::class, - androidx.compose.ui.ExperimentalComposeUiApi::class, - FlowPreview::class + ExperimentalMaterial3Api::class, + androidx.compose.foundation.ExperimentalFoundationApi::class, + androidx.compose.ui.ExperimentalComposeUiApi::class, + FlowPreview::class ) @Composable fun ChatDetailScreen( @@ -194,84 +184,92 @@ fun ChatDetailScreen( // 📨 Forward: показывать ли выбор чата var showForwardPicker by remember { mutableStateOf(false) } - + // 📸 Image Viewer state with Telegram-style shared element animation var showImageViewer by remember { mutableStateOf(false) } var imageViewerInitialIndex by remember { mutableStateOf(0) } var imageViewerSourceBounds by remember { mutableStateOf(null) } - + // 🎨 Управление статус баром DisposableEffect(isDarkTheme, showImageViewer) { - val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } + val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } - if (showImageViewer) { - // 📸 При просмотре фото - чёрный статус бар - window?.statusBarColor = android.graphics.Color.BLACK - insetsController?.isAppearanceLightStatusBars = false - } else { - // Обычный режим - прозрачный статус бар, иконки по теме - window?.statusBarColor = android.graphics.Color.TRANSPARENT - insetsController?.isAppearanceLightStatusBars = !isDarkTheme - } + if (showImageViewer) { + // 📸 При просмотре фото - чёрный статус бар + window?.statusBarColor = android.graphics.Color.BLACK + insetsController?.isAppearanceLightStatusBars = false + } else { + // Обычный режим - прозрачный статус бар, иконки по теме + window?.statusBarColor = android.graphics.Color.TRANSPARENT + insetsController?.isAppearanceLightStatusBars = !isDarkTheme + } - onDispose { - // Восстанавливаем прозрачный статус бар при выходе - window?.statusBarColor = android.graphics.Color.TRANSPARENT - } - } - - // 📷 Camera: URI для сохранения фото - var cameraImageUri by remember { mutableStateOf(null) } - - // 📷 Состояние для flow камеры: фото → редактор с caption → отправка - var pendingCameraPhotoUri by remember { mutableStateOf(null) } // Фото для редактирования - - // 📷 Показать встроенную камеру (без системного превью) - var showInAppCamera by remember { mutableStateOf(false) } - - // 🖼 Состояние для multi-image editor (галерея) - var pendingGalleryImages by remember { mutableStateOf>(emptyList()) } - - // �📷 Camera launcher - val cameraLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.TakePicture() - ) { success -> - if (success && cameraImageUri != null) { - // Очищаем фокус чтобы клавиатура не появилась - keyboardController?.hide() - focusManager.clearFocus() - // Открываем редактор вместо прямой отправки - pendingCameraPhotoUri = cameraImageUri + onDispose { + // Восстанавливаем прозрачный статус бар при выходе + window?.statusBarColor = android.graphics.Color.TRANSPARENT } } - + + // 📷 Camera: URI для сохранения фото + var cameraImageUri by remember { mutableStateOf(null) } + + // 📷 Состояние для flow камеры: фото → редактор с caption → отправка + var pendingCameraPhotoUri by remember { + mutableStateOf(null) + } // Фото для редактирования + + // 📷 Показать встроенную камеру (без системного превью) + var showInAppCamera by remember { mutableStateOf(false) } + + // 🖼 Состояние для multi-image editor (галерея) + var pendingGalleryImages by remember { mutableStateOf>(emptyList()) } + + // �📷 Camera launcher + val cameraLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + if (success && cameraImageUri != null) { + // Очищаем фокус чтобы клавиатура не появилась + keyboardController?.hide() + focusManager.clearFocus() + // Открываем редактор вместо прямой отправки + pendingCameraPhotoUri = cameraImageUri + } + } + // 📄 File picker launcher - val filePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri -> - if (uri != null) { - scope.launch { - val fileName = MediaUtils.getFileName(context, uri) - val fileSize = MediaUtils.getFileSize(context, uri) - - // Проверяем размер файла - if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) { - android.widget.Toast.makeText( - context, - "Файл слишком большой (макс. ${MediaUtils.MAX_FILE_SIZE_MB} МБ)", - android.widget.Toast.LENGTH_LONG - ).show() - return@launch - } - - val base64 = MediaUtils.uriToBase64File(context, uri) - if (base64 != null) { - viewModel.sendFileMessage(base64, fileName, fileSize) + val filePickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + scope.launch { + val fileName = MediaUtils.getFileName(context, uri) + val fileSize = MediaUtils.getFileSize(context, uri) + + // Проверяем размер файла + if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) { + android.widget.Toast.makeText( + context, + "Файл слишком большой (макс. ${MediaUtils.MAX_FILE_SIZE_MB} МБ)", + android.widget.Toast.LENGTH_LONG + ) + .show() + return@launch + } + + val base64 = MediaUtils.uriToBase64File(context, uri) + if (base64 != null) { + viewModel.sendFileMessage( + base64, + fileName, + fileSize + ) + } } } } - } - // 📨 Forward: список диалогов для выбора (загружаем из базы) val chatsListViewModel: ChatsListViewModel = viewModel() @@ -295,14 +293,13 @@ fun ChatDetailScreen( val debugLogs by ProtocolManager.debugLogs.collectAsState() // Включаем UI логи только когда открыт bottom sheet - LaunchedEffect(showDebugLogs) { - ProtocolManager.enableUILogs(showDebugLogs) - } + LaunchedEffect(showDebugLogs) { ProtocolManager.enableUILogs(showDebugLogs) } // Наблюдаем за статусом блокировки в реальном времени через Flow - val isBlocked by database.blacklistDao() - .observeUserBlocked(user.publicKey, currentUserPublicKey) - .collectAsState(initial = false) + val isBlocked by + database.blacklistDao() + .observeUserBlocked(user.publicKey, currentUserPublicKey) + .collectAsState(initial = false) // Подключаем к ViewModel val messages by viewModel.messages.collectAsState() @@ -312,7 +309,6 @@ fun ChatDetailScreen( val isOnline by viewModel.opponentOnline.collectAsState() val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона - // �🔥 Reply/Forward state val replyMessages by viewModel.replyMessages.collectAsState() val hasReply = replyMessages.isNotEmpty() @@ -328,17 +324,21 @@ fun ChatDetailScreen( snapshotFlow { val layoutInfo = listState.layoutInfo val totalItems = layoutInfo.totalItemsCount - val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val lastVisibleItemIndex = + layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 Pair(lastVisibleItemIndex, totalItems) } - .distinctUntilChanged() - .debounce(100) // 🔥 Debounce чтобы не триггерить на каждый фрейм скролла - .collect { (lastVisible, total) -> - // Загружаем когда осталось 5 элементов до конца и не идёт загрузка - if (total > 0 && lastVisible >= total - 5 && !viewModel.isLoadingMore.value) { - viewModel.loadMoreMessages() + .distinctUntilChanged() + .debounce(100) // 🔥 Debounce чтобы не триггерить на каждый фрейм скролла + .collect { (lastVisible, total) -> + // Загружаем когда осталось 5 элементов до конца и не идёт загрузка + if (total > 0 && + lastVisible >= total - 5 && + !viewModel.isLoadingMore.value + ) { + viewModel.loadMoreMessages() + } } - } } // 🔥 Display reply messages - получаем полную информацию о сообщениях для reply @@ -351,53 +351,18 @@ fun ChatDetailScreen( text = chatMsg.text, timestamp = chatMsg.timestamp.time, isOutgoing = chatMsg.isOutgoing, - publicKey = if (chatMsg.isOutgoing) currentUserPublicKey else user.publicKey, + publicKey = + if (chatMsg.isOutgoing) currentUserPublicKey + else user.publicKey, attachments = chatMsg.attachments ) } } } - // 🔥 Добавляем информацию о датах к сообщениям - // В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху) - val messagesWithDates = - remember(messages) { - val result = - mutableListOf< - Pair>() // message, showDateHeader - var lastDateString = "" - - // 🔥 КРИТИЧНО: Дедупликация по ID перед сортировкой! - val uniqueMessages = messages.distinctBy { it.id } - if (uniqueMessages.size != messages.size) { - } - - // Сортируем по времени (новые -> старые) для reversed layout - val sortedMessages = uniqueMessages.sortedByDescending { it.timestamp.time } - - for (i in sortedMessages.indices) { - val message = sortedMessages[i] - val dateString = - SimpleDateFormat("yyyyMMdd", Locale.getDefault()) - .format(message.timestamp) - - // Показываем дату если это последнее сообщение за день - // (следующее сообщение - другой день или нет следующего) - val nextMessage = sortedMessages.getOrNull(i + 1) - val nextDateString = - nextMessage?.let { - SimpleDateFormat("yyyyMMdd", Locale.getDefault()) - .format(it.timestamp) - } - val showDate = - nextDateString == null || nextDateString != dateString - - result.add(message to showDate) - lastDateString = dateString - } - - result - } + // 🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default + // (dedup + sort + date headers off the main thread) + val messagesWithDates by viewModel.messagesWithDates.collectAsState() // 🔥 Функция для скролла к сообщению с подсветкой val scrollToMessage: (String) -> Unit = { messageId -> @@ -492,7 +457,10 @@ fun ChatDetailScreen( // 🔥 Скроллим только если изменился ID самого нового сообщения // При пагинации добавляются старые сообщения - ID нового не меняется LaunchedEffect(newestMessageId) { - if (newestMessageId != null && lastNewestMessageId != null && newestMessageId != lastNewestMessageId) { + if (newestMessageId != null && + lastNewestMessageId != null && + newestMessageId != lastNewestMessageId + ) { // Новое сообщение пришло - скроллим вниз delay(50) // Debounce - ждём стабилизации listState.animateScrollToItem(0) @@ -509,9 +477,7 @@ fun ChatDetailScreen( ) // 🚀 Весь контент (swipe-back обрабатывается в SwipeBackContainer) - Box( - modifier = Modifier.fillMaxSize() - ) { + Box(modifier = Modifier.fillMaxSize()) { // Telegram-style solid header background (без blur) val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) @@ -529,7 +495,8 @@ fun ChatDetailScreen( else Color.White } else headerBackground ) - // 🎨 statusBarsPadding ПОСЛЕ background = хедер начинается под статус баром + // 🎨 statusBarsPadding ПОСЛЕ background = + // хедер начинается под статус баром .statusBarsPadding() ) { // Контент хедера с Crossfade для плавной смены - ускоренная @@ -745,7 +712,13 @@ fun ChatDetailScreen( contentDescription = "Back", tint = - if (isDarkTheme) Color.White else Color(0xFF007AFF), + if (isDarkTheme + ) + Color.White + else + Color( + 0xFF007AFF + ), modifier = Modifier.size( 32.dp @@ -824,29 +797,52 @@ fun ChatDetailScreen( modifier = Modifier.size(40.dp) .then( - if (!isSavedMessages) { - Modifier.clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - // Мгновенное закрытие клавиатуры через нативный API - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() - onUserProfileClick(user) - } - } else Modifier + if (!isSavedMessages + ) { + Modifier + .clickable( + indication = + null, + interactionSource = + remember { + MutableInteractionSource() + } + ) { + // Мгновенное закрытие клавиатуры через нативный API + val imm = + context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as + InputMethodManager + imm.hideSoftInputFromWindow( + view.windowToken, + 0 + ) + focusManager + .clearFocus() + onUserProfileClick( + user + ) + } + } else + Modifier ), contentAlignment = Alignment.Center ) { if (isSavedMessages) { Box( - modifier = Modifier - .fillMaxSize() - .clip(CircleShape) - .background(PrimaryBlue), - contentAlignment = Alignment.Center + modifier = + Modifier.fillMaxSize() + .clip( + CircleShape + ) + .background( + PrimaryBlue + ), + contentAlignment = + Alignment + .Center ) { Icon( Icons.Default @@ -863,11 +859,19 @@ fun ChatDetailScreen( } } else { AvatarImage( - publicKey = user.publicKey, - avatarRepository = avatarRepository, - size = 40.dp, - isDarkTheme = isDarkTheme, - displayName = user.title.ifEmpty { user.username } // 🔥 Для инициалов + publicKey = + user.publicKey, + avatarRepository = + avatarRepository, + size = + 40.dp, + isDarkTheme = + isDarkTheme, + displayName = + user.title + .ifEmpty { + user.username + } // 🔥 Для инициалов ) } } @@ -884,18 +888,35 @@ fun ChatDetailScreen( modifier = Modifier.weight(1f) .then( - if (!isSavedMessages) { - Modifier.clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - // Мгновенное закрытие клавиатуры через нативный API - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() - onUserProfileClick(user) - } - } else Modifier + if (!isSavedMessages + ) { + Modifier + .clickable( + indication = + null, + interactionSource = + remember { + MutableInteractionSource() + } + ) { + // Мгновенное закрытие клавиатуры через нативный API + val imm = + context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as + InputMethodManager + imm.hideSoftInputFromWindow( + view.windowToken, + 0 + ) + focusManager + .clearFocus() + onUserProfileClick( + user + ) + } + } else + Modifier ) ) { Row( @@ -934,7 +955,8 @@ fun ChatDetailScreen( user.verified, size = 16, - isDarkTheme = isDarkTheme + isDarkTheme = + isDarkTheme ) } } @@ -980,7 +1002,13 @@ fun ChatDetailScreen( contentDescription = "Call", tint = - if (isDarkTheme) Color.White else Color(0xFF007AFF) + if (isDarkTheme + ) + Color.White + else + Color( + 0xFF007AFF + ) ) } } @@ -1013,7 +1041,13 @@ fun ChatDetailScreen( contentDescription = "More", tint = - if (isDarkTheme) Color.White else Color(0xFF007AFF), + if (isDarkTheme + ) + Color.White + else + Color( + 0xFF007AFF + ), modifier = Modifier.size( 26.dp @@ -1164,10 +1198,14 @@ fun ChatDetailScreen( modifier = Modifier.fillMaxWidth() .padding( - start = 12.dp, - end = 12.dp, - top = 8.dp, - bottom = 16.dp + start = + 12.dp, + end = + 12.dp, + top = + 8.dp, + bottom = + 16.dp ) .navigationBarsPadding() .graphicsLayer { @@ -1447,13 +1485,23 @@ fun ChatDetailScreen( onReplyClick = scrollToMessage, onAttachClick = { - // Telegram-style: галерея открывается ПОВЕРХ клавиатуры - // НЕ скрываем клавиатуру! - showMediaPicker = true + // Telegram-style: + // галерея + // открывается + // ПОВЕРХ клавиатуры + // НЕ скрываем + // клавиатуру! + showMediaPicker = + true }, - myPublicKey = viewModel.myPublicKey ?: "", - opponentPublicKey = user.publicKey, - myPrivateKey = currentUserPrivateKey + myPublicKey = + viewModel + .myPublicKey + ?: "", + opponentPublicKey = + user.publicKey, + myPrivateKey = + currentUserPrivateKey ) } } @@ -1463,7 +1511,8 @@ fun ChatDetailScreen( ) { paddingValues -> // 🔥 Box wrapper для overlay (MediaPicker над клавиатурой) Box(modifier = Modifier.fillMaxSize()) { - // 🔥 Column структура - список сжимается когда клавиатура открывается + // 🔥 Column структура - список сжимается когда клавиатура + // открывается Column( modifier = Modifier.fillMaxSize() @@ -1478,286 +1527,298 @@ fun ChatDetailScreen( isLoading -> { MessageSkeletonList( isDarkTheme = isDarkTheme, - modifier = Modifier.fillMaxSize() + modifier = + Modifier.fillMaxSize() ) } // Пустое состояние (нет сообщений) messages.isEmpty() -> { - Column( - modifier = - Modifier.fillMaxSize() - .padding(32.dp), - horizontalAlignment = - Alignment - .CenterHorizontally, - verticalArrangement = - Arrangement.Center - ) { - if (isSavedMessages) { - val composition by - rememberLottieComposition( - LottieCompositionSpec - .RawRes( - R.raw.saved - ) - ) - val progress by - animateLottieCompositionAsState( + Column( + modifier = + Modifier.fillMaxSize() + .padding( + 32.dp + ), + horizontalAlignment = + Alignment + .CenterHorizontally, + verticalArrangement = + Arrangement.Center + ) { + if (isSavedMessages) { + val composition by + rememberLottieComposition( + LottieCompositionSpec + .RawRes( + R.raw.saved + ) + ) + val progress by + animateLottieCompositionAsState( + composition = + composition, + iterations = + LottieConstants + .IterateForever + ) + LottieAnimation( composition = composition, - iterations = - LottieConstants - .IterateForever + progress = { + progress + }, + modifier = + Modifier.size( + 120.dp + ) ) - LottieAnimation( - composition = - composition, - progress = { - progress - }, + } else { + val composition by + rememberLottieComposition( + LottieCompositionSpec + .RawRes( + R.raw.speech + ) + ) + val progress by + animateLottieCompositionAsState( + composition = + composition, + iterations = + LottieConstants + .IterateForever + ) + LottieAnimation( + composition = + composition, + progress = { + progress + }, + modifier = + Modifier.size( + 120.dp + ) + ) + } + Spacer( modifier = - Modifier.size( - 120.dp + Modifier.height( + 16.dp ) ) - } else { - val composition by - rememberLottieComposition( - LottieCompositionSpec - .RawRes( - R.raw.speech - ) - ) - val progress by - animateLottieCompositionAsState( - composition = - composition, - iterations = - LottieConstants - .IterateForever - ) - LottieAnimation( - composition = - composition, - progress = { - progress - }, - modifier = - Modifier.size( - 120.dp + Text( + text = + if (isSavedMessages ) + "Save messages here for quick access" + else + "No messages yet", + fontSize = 16.sp, + color = + secondaryTextColor, + fontWeight = + FontWeight + .Medium + ) + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + Text( + text = + if (isSavedMessages + ) + "Forward messages here or send notes to yourself" + else + "Send a message to start the conversation", + fontSize = 14.sp, + color = + secondaryTextColor + .copy( + alpha = + 0.7f + ) ) } - Spacer( - modifier = - Modifier.height( - 16.dp - ) - ) - Text( - text = - if (isSavedMessages) - "Save messages here for quick access" - else - "No messages yet", - fontSize = 16.sp, - color = secondaryTextColor, - fontWeight = - FontWeight.Medium - ) - Spacer( - modifier = - Modifier.height( - 8.dp - ) - ) - Text( - text = - if (isSavedMessages) - "Forward messages here or send notes to yourself" - else - "Send a message to start the conversation", - fontSize = 14.sp, - color = - secondaryTextColor - .copy( - alpha = - 0.7f - ) - ) } - } - // Есть сообщения - else -> - LazyColumn( - state = listState, - modifier = - Modifier.fillMaxSize() - .nestedScroll( - remember { - object : - NestedScrollConnection { - override fun onPreScroll( - available: - Offset, - source: - NestedScrollSource - ): Offset { - // Отслеживаем ручную - // прокрутку - // пользователем - if (source == + // Есть сообщения + else -> + LazyColumn( + state = listState, + modifier = + Modifier.fillMaxSize() + .nestedScroll( + remember { + object : + NestedScrollConnection { + override fun onPreScroll( + available: + Offset, + source: NestedScrollSource - .Drag - ) { - wasManualScroll = - true + ): Offset { + // Отслеживаем ручную + // прокрутку + // пользователем + if (source == + NestedScrollSource + .Drag + ) { + wasManualScroll = + true + } + return Offset.Zero } - return Offset.Zero } } - } - ), - contentPadding = - PaddingValues( - start = 0.dp, - end = 0.dp, - top = 8.dp, - bottom = - if (isSelectionMode - ) - 100.dp - else 16.dp - ), - reverseLayout = true - ) { - itemsIndexed( - messagesWithDates, - key = { _, item -> - item.first.id - } - ) { index, (message, showDate) -> - // Определяем, показывать ли - // хвостик (последнее - // сообщение в группе) - val nextMessage = - messagesWithDates - .getOrNull( - index + - 1 - ) - ?.first - val showTail = - nextMessage == - null || - nextMessage - .isOutgoing != - message.isOutgoing || - (message.timestamp - .time - - nextMessage - .timestamp - .time) > - 60_000 - - // Определяем начало новой - // группы (для отступов) - val prevMessage = - messagesWithDates - .getOrNull( - index - - 1 - ) - ?.first - val isGroupStart = - prevMessage != null && - (prevMessage - .isOutgoing != - message.isOutgoing || - (prevMessage - .timestamp - .time - - message.timestamp - .time) > - 60_000) - - Column { - if (showDate) { - DateHeader( - dateText = - getDateText( - message.timestamp - .time - ), - secondaryTextColor = - secondaryTextColor - ) - } - val selectionKey = - message.id - MessageBubble( - message = - message, - isDarkTheme = - isDarkTheme, - showTail = - showTail, - isGroupStart = - isGroupStart, - isSelected = - selectedMessages - .contains( - selectionKey - ), - isHighlighted = - highlightedMessageId == - message.id, - isSavedMessages = - isSavedMessages, - privateKey = - currentUserPrivateKey, - senderPublicKey = - if (message.isOutgoing) currentUserPublicKey else user.publicKey, - currentUserPublicKey = - currentUserPublicKey, - avatarRepository = - avatarRepository, - onLongClick = { - // 📳 Haptic feedback при долгом нажатии - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - - if (!isSelectionMode - ) { - val imm = - context.getSystemService( - Context.INPUT_METHOD_SERVICE - ) as - InputMethodManager - imm.hideSoftInputFromWindow( - view.windowToken, - 0 - ) - focusManager - .clearFocus() - showEmojiPicker = - false - } - selectedMessages = - if (selectedMessages - .contains( - selectionKey - ) - ) { - selectedMessages - - selectionKey - } else { - selectedMessages + - selectionKey - } - }, - onClick = { + ), + contentPadding = + PaddingValues( + start = + 0.dp, + end = 0.dp, + top = 8.dp, + bottom = if (isSelectionMode - ) { + ) + 100.dp + else + 16.dp + ), + reverseLayout = true + ) { + itemsIndexed( + messagesWithDates, + key = { _, item -> + item.first + .id + } + ) { + index, + (message, showDate) + -> + // Определяем, + // показывать ли + // хвостик + // (последнее + // сообщение в + // группе) + val nextMessage = + messagesWithDates + .getOrNull( + index + + 1 + ) + ?.first + val showTail = + nextMessage == + null || + nextMessage + .isOutgoing != + message.isOutgoing || + (message.timestamp + .time - + nextMessage + .timestamp + .time) > + 60_000 + + // Определяем начало + // новой + // группы (для + // отступов) + val prevMessage = + messagesWithDates + .getOrNull( + index - + 1 + ) + ?.first + val isGroupStart = + prevMessage != + null && + (prevMessage + .isOutgoing != + message.isOutgoing || + (prevMessage + .timestamp + .time - + message.timestamp + .time) > + 60_000) + + Column { + if (showDate + ) { + DateHeader( + dateText = + getDateText( + message.timestamp + .time + ), + secondaryTextColor = + secondaryTextColor + ) + } + val selectionKey = + message.id + MessageBubble( + message = + message, + isDarkTheme = + isDarkTheme, + showTail = + showTail, + isGroupStart = + isGroupStart, + isSelected = + selectedMessages + .contains( + selectionKey + ), + isHighlighted = + highlightedMessageId == + message.id, + isSavedMessages = + isSavedMessages, + privateKey = + currentUserPrivateKey, + senderPublicKey = + if (message.isOutgoing + ) + currentUserPublicKey + else + user.publicKey, + currentUserPublicKey = + currentUserPublicKey, + avatarRepository = + avatarRepository, + onLongClick = { + // 📳 Haptic feedback при долгом нажатии + hapticFeedback + .performHapticFeedback( + HapticFeedbackType + .LongPress + ) + + if (!isSelectionMode + ) { + val imm = + context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as + InputMethodManager + imm.hideSoftInputFromWindow( + view.windowToken, + 0 + ) + focusManager + .clearFocus() + showEmojiPicker = + false + } selectedMessages = if (selectedMessages .contains( @@ -1770,117 +1831,173 @@ fun ChatDetailScreen( selectedMessages + selectionKey } - } - }, - onSwipeToReply = { - // Не разрешаем reply на сообщения с аватаркой - val hasAvatar = message.attachments.any { it.type == AttachmentType.AVATAR } - if (!hasAvatar) { - viewModel - .setReplyMessages( - listOf( - message + }, + onClick = { + if (isSelectionMode + ) { + selectedMessages = + if (selectedMessages + .contains( + selectionKey + ) + ) { + selectedMessages - + selectionKey + } else { + selectedMessages + + selectionKey + } + } + }, + onSwipeToReply = { + // Не разрешаем reply на сообщения с аватаркой + val hasAvatar = + message.attachments + .any { + it.type == + AttachmentType + .AVATAR + } + if (!hasAvatar + ) { + viewModel + .setReplyMessages( + listOf( + message + ) ) - ) - } - }, - onReplyClick = { - messageId - -> - scrollToMessage( + } + }, + onReplyClick = { messageId - ) - }, - onRetry = { - viewModel - .retryMessage( - message + -> + scrollToMessage( + messageId ) - }, - onDelete = { - viewModel - .deleteMessage( - message.id + }, + onRetry = { + viewModel + .retryMessage( + message + ) + }, + onDelete = { + viewModel + .deleteMessage( + message.id + ) + }, + onImageClick = { + attachmentId, + bounds + -> + // 📸 Открыть просмотрщик фото с shared element animation + val allImages = + extractImagesFromMessages( + messages, + currentUserPublicKey, + user.publicKey, + user.title + .ifEmpty { + "User" + } + ) + imageViewerInitialIndex = + findImageIndex( + allImages, + attachmentId + ) + imageViewerSourceBounds = + bounds + showImageViewer = + true + onImageViewerChanged( + true ) - }, - onImageClick = { attachmentId, bounds -> - // 📸 Открыть просмотрщик фото с shared element animation - val allImages = extractImagesFromMessages( - messages, - currentUserPublicKey, - user.publicKey, - user.title.ifEmpty { "User" } - ) - imageViewerInitialIndex = findImageIndex(allImages, attachmentId) - imageViewerSourceBounds = bounds - showImageViewer = true - onImageViewerChanged(true) - } - ) + } + ) + } } } - } + } } } - } - } // Конец Column внутри Scaffold content - - // 📎 Media Picker INLINE OVERLAY (Telegram-style gallery над клавиатурой) - // Теперь это НЕ Dialog, а обычный composable внутри того же layout! - MediaPickerBottomSheet( - isVisible = showMediaPicker, - onDismiss = { showMediaPicker = false }, - isDarkTheme = isDarkTheme, - currentUserPublicKey = currentUserPublicKey, - onMediaSelected = { selectedMedia -> - // 📸 Открываем edit screen для выбранных изображений - val imageUris = selectedMedia - .filter { !it.isVideo } - .map { it.uri } + } // Конец Column внутри Scaffold content - if (imageUris.isNotEmpty()) { - pendingGalleryImages = imageUris - } - }, - onMediaSelectedWithCaption = { mediaItem, caption -> - // 📸 Отправляем фото с caption напрямую - showMediaPicker = false - scope.launch { - val base64 = MediaUtils.uriToBase64Image(context, mediaItem.uri) - val blurhash = MediaUtils.generateBlurhash(context, mediaItem.uri) - val (width, height) = MediaUtils.getImageDimensions(context, mediaItem.uri) - if (base64 != null) { - viewModel.sendImageMessage(base64, blurhash, caption, width, height) + // 📎 Media Picker INLINE OVERLAY (Telegram-style gallery над клавиатурой) + // Теперь это НЕ Dialog, а обычный composable внутри того же layout! + MediaPickerBottomSheet( + isVisible = showMediaPicker, + onDismiss = { showMediaPicker = false }, + isDarkTheme = isDarkTheme, + currentUserPublicKey = currentUserPublicKey, + onMediaSelected = { selectedMedia -> + // 📸 Открываем edit screen для выбранных изображений + val imageUris = + selectedMedia.filter { !it.isVideo }.map { it.uri } + + if (imageUris.isNotEmpty()) { + pendingGalleryImages = imageUris } - } - }, - onOpenCamera = { - // 📷 Открываем встроенную камеру (без системного превью!) - keyboardController?.hide() - focusManager.clearFocus() - showInAppCamera = true - }, - onOpenFilePicker = { - // 📄 Открываем файловый пикер - filePickerLauncher.launch("*/*") - }, - onAvatarClick = { - // 👤 Отправляем свой аватар (как в desktop) - viewModel.sendAvatarMessage() - }, - recipientName = user.title - ) + }, + onMediaSelectedWithCaption = { mediaItem, caption -> + // 📸 Отправляем фото с caption напрямую + showMediaPicker = false + scope.launch { + val base64 = + MediaUtils.uriToBase64Image( + context, + mediaItem.uri + ) + val blurhash = + MediaUtils.generateBlurhash( + context, + mediaItem.uri + ) + val (width, height) = + MediaUtils.getImageDimensions( + context, + mediaItem.uri + ) + if (base64 != null) { + viewModel.sendImageMessage( + base64, + blurhash, + caption, + width, + height + ) + } + } + }, + onOpenCamera = { + // 📷 Открываем встроенную камеру (без системного превью!) + keyboardController?.hide() + focusManager.clearFocus() + showInAppCamera = true + }, + onOpenFilePicker = { + // 📄 Открываем файловый пикер + filePickerLauncher.launch("*/*") + }, + onAvatarClick = { + // 👤 Отправляем свой аватар (как в desktop) + viewModel.sendAvatarMessage() + }, + recipientName = user.title + ) } // Закрытие Box wrapper для Scaffold content } // Закрытие Box // 📸 Image Viewer Overlay with Telegram-style shared element animation if (showImageViewer) { - val allImages = extractImagesFromMessages( - messages, - currentUserPublicKey, - user.publicKey, - user.title.ifEmpty { "User" } - ) + val allImages = + extractImagesFromMessages( + messages, + currentUserPublicKey, + user.publicKey, + user.title.ifEmpty { "User" } + ) ImageViewerScreen( images = allImages, initialIndex = imageViewerInitialIndex, @@ -1894,7 +2011,8 @@ fun ChatDetailScreen( // Сразу сбрасываем status bar при начале закрытия (до анимации) window?.statusBarColor = android.graphics.Color.TRANSPARENT window?.let { w -> - WindowCompat.getInsetsController(w, view)?.isAppearanceLightStatusBars = !isDarkTheme + WindowCompat.getInsetsController(w, view) + ?.isAppearanceLightStatusBars = !isDarkTheme } }, isDarkTheme = isDarkTheme, @@ -2106,18 +2224,19 @@ fun ChatDetailScreen( onChatSelected = { selectedDialog -> showForwardPicker = false ForwardManager.selectChat(selectedDialog.opponentKey) - val searchUser = SearchUser( - title = selectedDialog.opponentTitle, - username = selectedDialog.opponentUsername, - publicKey = selectedDialog.opponentKey, - verified = selectedDialog.verified, - online = selectedDialog.isOnline - ) + val searchUser = + SearchUser( + title = selectedDialog.opponentTitle, + username = selectedDialog.opponentUsername, + publicKey = selectedDialog.opponentKey, + verified = selectedDialog.verified, + online = selectedDialog.isOnline + ) onNavigateToChat(searchUser) } ) } - + // 📷 In-App Camera (без системного превью!) if (showInAppCamera) { InAppCameraScreen( @@ -2129,14 +2248,12 @@ fun ChatDetailScreen( } ) } - + // 📷 Image Editor для фото с камеры (с caption как в Telegram) pendingCameraPhotoUri?.let { uri -> ImageEditorScreen( imageUri = uri, - onDismiss = { - pendingCameraPhotoUri = null - }, + onDismiss = { pendingCameraPhotoUri = null }, onSave = { editedUri -> // 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ viewModel.sendImageFromUri(editedUri, "") @@ -2151,18 +2268,19 @@ fun ChatDetailScreen( skipEnterAnimation = true // Из камеры — мгновенно, без fade ) } - + // 📸 Multi-Image Editor для фото из галереи (со свайпом как в Telegram) if (pendingGalleryImages.isNotEmpty()) { MultiImageEditorScreen( imageUris = pendingGalleryImages, - onDismiss = { - pendingGalleryImages = emptyList() - }, + onDismiss = { pendingGalleryImages = emptyList() }, onSendAll = { imagesWithCaptions -> // 🚀 Мгновенный optimistic UI для каждого фото for (imageWithCaption in imagesWithCaptions) { - viewModel.sendImageFromUri(imageWithCaption.uri, imageWithCaption.caption) + viewModel.sendImageFromUri( + imageWithCaption.uri, + imageWithCaption.caption + ) } }, isDarkTheme = isDarkTheme, 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 99a42f0..3fd297d 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 @@ -9,24 +9,22 @@ import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.data.ForwardManager -import com.rosetta.messenger.database.DialogEntity import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.* import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.MessageThrottleManager +import java.util.Date +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.json.JSONArray import org.json.JSONObject -import java.util.UUID -import java.util.Date -import java.util.concurrent.ConcurrentHashMap /** - * ViewModel для экрана чата - оптимизированная версия - * 🚀 Особенности: + * ViewModel для экрана чата - оптимизированная версия 🚀 Особенности: * - Dispatchers.IO для всех тяжёлых операций * - Пагинация сообщений * - Chunked decryption (расшифровка пачками) @@ -34,137 +32,167 @@ import java.util.concurrent.ConcurrentHashMap * - Flow для реактивных обновлений без блокировки UI */ class ChatViewModel(application: Application) : AndroidViewModel(application) { - + companion object { private const val TAG = "ChatViewModel" private const val PAGE_SIZE = 30 - private const val DECRYPT_CHUNK_SIZE = 5 // Расшифровываем по 5 сообщений за раз - private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM + private const val DECRYPT_CHUNK_SIZE = 5 // Расшифровываем по 5 сообщений за раз + private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List) // Сделан глобальным чтобы можно было очистить при удалении диалога private val dialogMessagesCache = ConcurrentHashMap>() /** - * 🔥 Обновить кэш с ограничением размера - * Сохраняет только последние MAX_CACHE_SIZE сообщений для предотвращения OOM + * 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE + * сообщений для предотвращения OOM */ private fun updateCacheWithLimit(dialogKey: String, messages: List) { - val limitedMessages = if (messages.size > MAX_CACHE_SIZE) { - // Оставляем только последние сообщения (по timestamp) - messages.sortedByDescending { it.timestamp }.take(MAX_CACHE_SIZE).sortedBy { it.timestamp } - } else { - messages - } + val limitedMessages = + if (messages.size > MAX_CACHE_SIZE) { + // Оставляем только последние сообщения (по timestamp) + messages.sortedByDescending { it.timestamp }.take(MAX_CACHE_SIZE).sortedBy { + it.timestamp + } + } else { + messages + } dialogMessagesCache[dialogKey] = limitedMessages } - - /** - * 🗑️ Очистить кэш сообщений для диалога - * Вызывается при удалении диалога - */ + + /** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */ fun clearDialogCache(dialogKey: String) { dialogMessagesCache.remove(dialogKey) } - - /** - * 🗑️ Очистить кэш по publicKey собеседника - * Удаляет все ключи содержащие этот publicKey - */ + + /** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */ fun clearCacheForOpponent(opponentKey: String) { val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) } - keysToRemove.forEach { - dialogMessagesCache.remove(it) - } + keysToRemove.forEach { dialogMessagesCache.remove(it) } } } - + // Database private val database = RosettaDatabase.getDatabase(application) private val dialogDao = database.dialogDao() private val messageDao = database.messageDao() - + // MessageRepository для подписки на события новых сообщений - private val messageRepository = com.rosetta.messenger.data.MessageRepository.getInstance(application) - + private val messageRepository = + com.rosetta.messenger.data.MessageRepository.getInstance(application) + // 🔥 Кэш расшифрованных сообщений (messageId -> plainText) private val decryptionCache = ConcurrentHashMap() - + // Информация о собеседнике private var opponentTitle: String = "" private var opponentUsername: String = "" - + // Текущий диалог private var opponentKey: String? = null - var myPublicKey: String? = null // 🔥 Публичный доступ для OtherProfileScreen + var myPublicKey: String? = null // 🔥 Публичный доступ для OtherProfileScreen private set private var myPrivateKey: String? = null - + // UI State private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() - + + /** + * Pre-computed messages with date headers — runs dedup + sort on Dispatchers.Default. Replaces + * the old `remember(messages) { ... }` block in ChatDetailScreen that ran O(n log n) sorting + + * SimpleDateFormat allocation in a loop on the main thread. + */ + private val _dateFmt = + java.text.SimpleDateFormat( + "yyyyMMdd", + java.util.Locale.US + ) // single instance, thread-safe via Default dispatcher + @OptIn(FlowPreview::class) + val messagesWithDates: StateFlow>> = + _messages + .debounce(16) // coalesce rapid updates (1 frame) + .mapLatest { rawMessages -> + withContext(Dispatchers.Default) { + val unique = rawMessages.distinctBy { it.id } + val sorted = unique.sortedByDescending { it.timestamp.time } + val result = ArrayList>(sorted.size) + var prevDateStr: String? = null + for (i in sorted.indices) { + val msg = sorted[i] + val dateStr = _dateFmt.format(msg.timestamp) + val nextMsg = sorted.getOrNull(i + 1) + val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) } + val showDate = nextDateStr == null || nextDateStr != dateStr + result.add(msg to showDate) + prevDateStr = dateStr + } + result as List> + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() - + private val _isLoadingMore = MutableStateFlow(false) val isLoadingMore: StateFlow = _isLoadingMore.asStateFlow() - + private val _opponentTyping = MutableStateFlow(false) val opponentTyping: StateFlow = _opponentTyping.asStateFlow() private var typingTimeoutJob: kotlinx.coroutines.Job? = null - + // 🟢 Онлайн статус собеседника private val _opponentOnline = MutableStateFlow(false) val opponentOnline: StateFlow = _opponentOnline.asStateFlow() - + private val _opponentLastSeen = MutableStateFlow(0L) val opponentLastSeen: StateFlow = _opponentLastSeen.asStateFlow() - + // Input state private val _inputText = MutableStateFlow("") val inputText: StateFlow = _inputText.asStateFlow() - + // 🔥 Reply/Forward state (как в React Native) data class ReplyMessage( - val messageId: String, - val text: String, - val timestamp: Long, - val isOutgoing: Boolean, - val publicKey: String = "", // publicKey отправителя цитируемого сообщения - val attachments: List = emptyList() // Для показа превью + val messageId: String, + val text: String, + val timestamp: Long, + val isOutgoing: Boolean, + val publicKey: String = "", // publicKey отправителя цитируемого сообщения + val attachments: List = emptyList() // Для показа превью ) private val _replyMessages = MutableStateFlow>(emptyList()) val replyMessages: StateFlow> = _replyMessages.asStateFlow() - + private val _isForwardMode = MutableStateFlow(false) val isForwardMode: StateFlow = _isForwardMode.asStateFlow() - + // Пагинация private var currentOffset = 0 private var hasMoreMessages = true private var isLoadingMessages = false - + // Защита от двойной отправки private var isSending = false // 🔥 Throttling перенесён в глобальный MessageThrottleManager // Job для отмены загрузки при смене диалога private var loadingJob: Job? = null - + // 🔥 Throttling для typing индикатора private var lastTypingSentTime = 0L - private val TYPING_THROTTLE_MS = 2000L // Отправляем не чаще чем раз в 2 сек - + private val TYPING_THROTTLE_MS = 2000L // Отправляем не чаще чем раз в 2 сек + // Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного private var lastReadMessageTimestamp = 0L // Флаг что read receipt уже отправлен для текущего диалога private var readReceiptSentForCurrentDialog = false - + // 🔥 Флаг что диалог АКТИВЕН (пользователь внутри чата, а не на главной) // Как currentDialogPublicKeyView в архиве private var isDialogActive = false - + // 🟢 Флаг что уже подписаны на онлайн статус собеседника private var subscribedToOnlineStatus = false @@ -191,17 +219,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { setupNewMessageListener() setupDeliveryStatusListener() } - + // 🔥 Debounce для защиты от спама входящих сообщений private var pendingMessageUpdates = mutableSetOf() private var messageUpdateJob: kotlinx.coroutines.Job? = null - private val MESSAGE_BATCH_DELAY_MS = 100L // Собираем сообщения за 100ms и обрабатываем пачкой + private val MESSAGE_BATCH_DELAY_MS = 100L // Собираем сообщения за 100ms и обрабатываем пачкой /** - * 🔔 Подписка на события новых сообщений от MessageRepository - * Обновляет UI в реальном времени когда приходит новое сообщение - * 🚀 ОПТИМИЗАЦИЯ: Добавляем сообщение инкрементально, а не перезагружаем весь список - * 🔥 ЗАЩИТА ОТ СПАМА: Используем debounce + batching чтобы не перегружать UI при спаме + * 🔔 Подписка на события новых сообщений от MessageRepository Обновляет UI в реальном времени + * когда приходит новое сообщение 🚀 ОПТИМИЗАЦИЯ: Добавляем сообщение инкрементально, а не + * перезагружаем весь список 🔥 ЗАЩИТА ОТ СПАМА: Используем debounce + batching чтобы не + * перегружать UI при спаме */ private fun setupNewMessageListener() { viewModelScope.launch { @@ -219,18 +247,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Отменяем предыдущий job и создаём новый с задержкой messageUpdateJob?.cancel() - messageUpdateJob = viewModelScope.launch { - kotlinx.coroutines.delay(MESSAGE_BATCH_DELAY_MS) - processPendingMessages(account) - } + messageUpdateJob = + viewModelScope.launch { + kotlinx.coroutines.delay(MESSAGE_BATCH_DELAY_MS) + processPendingMessages(account) + } } } } } /** - * 🔥 Обработка накопленных событий о новых сообщениях - * Загружает все новые сообщения одним запросом вместо многих + * 🔥 Обработка накопленных событий о новых сообщениях Загружает все новые сообщения одним + * запросом вместо многих */ private suspend fun processPendingMessages(account: String) { val dialogsToProcess: Set @@ -245,8 +274,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } /** - * 🔥 Загрузка ВСЕХ новых сообщений за раз (batch) - * Вместо загрузки по одному - загружаем все что появились с момента последнего обновления + * 🔥 Загрузка ВСЕХ новых сообщений за раз (batch) Вместо загрузки по одному - загружаем все что + * появились с момента последнего обновления */ private suspend fun addLatestMessagesFromDb(account: String, dialogKey: String) { kotlinx.coroutines.withContext(Dispatchers.IO) { @@ -255,11 +284,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val isSavedMessages = (opponent == account) // 🔥 Получаем последние N сообщений из БД (увеличено до 50 чтобы поймать весь спам) - val latestEntities = if (isSavedMessages) { - messageDao.getMessagesForSavedDialog(account, limit = 50, offset = 0) - } else { - messageDao.getMessages(account, dialogKey, limit = 50, offset = 0) - } + val latestEntities = + if (isSavedMessages) { + messageDao.getMessagesForSavedDialog(account, limit = 50, offset = 0) + } else { + messageDao.getMessages(account, dialogKey, limit = 50, offset = 0) + } if (latestEntities.isEmpty()) return@withContext @@ -276,13 +306,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { kotlinx.coroutines.withContext(Dispatchers.Main.immediate) { val currentList = _messages.value val newList = (currentList + newMessages).sortedBy { it.timestamp } - + // 🔍 DEBUG: Проверка на дублирующиеся ID val allIds = newList.map { it.id } val duplicates = allIds.groupBy { it }.filter { it.value.size > 1 }.keys - if (duplicates.isNotEmpty()) { - } - + if (duplicates.isNotEmpty()) {} + _messages.value = newList } @@ -318,8 +347,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } /** - * 🔔 Подписка на события изменения статуса доставки - * Обновляет UI когда сообщение доставлено или прочитано + * 🔔 Подписка на события изменения статуса доставки Обновляет UI когда сообщение доставлено или + * прочитано */ private fun setupDeliveryStatusListener() { viewModelScope.launch { @@ -345,15 +374,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } - /** - * Помечает все исходящие сообщения как прочитанные - */ + /** Помечает все исходящие сообщения как прочитанные */ private fun markAllOutgoingAsRead() { - _messages.value = _messages.value.map { msg -> - if (msg.isOutgoing && msg.status != MessageStatus.READ) { - msg.copy(status = MessageStatus.READ) - } else msg - } + _messages.value = + _messages.value.map { msg -> + if (msg.isOutgoing && msg.status != MessageStatus.READ) { + msg.copy(status = MessageStatus.READ) + } else msg + } updateCacheFromCurrentMessages() } @@ -367,114 +395,110 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🟢 Онлайн статус - нужен здесь для UI текущего чата ProtocolManager.waitPacket(0x05, onlinePacketHandler) } - - // ✅ handleIncomingMessage удалён - обработка входящих сообщений теперь ТОЛЬКО в MessageRepository + + // ✅ handleIncomingMessage удалён - обработка входящих сообщений теперь ТОЛЬКО в + // MessageRepository // Это предотвращает дублирование сообщений - + /** - * 🔥 Безопасное добавление сообщения - предотвращает дубликаты - * Возвращает true если сообщение было добавлено + * 🔥 Безопасное добавление сообщения - предотвращает дубликаты Возвращает true если сообщение + * было добавлено */ private fun addMessageSafely(message: ChatMessage): Boolean { val currentMessages = _messages.value val currentIds = currentMessages.map { it.id }.toSet() -if (message.id in currentIds) { + if (message.id in currentIds) { return false } _messages.value = currentMessages + message return true } - + private fun updateMessageStatus(messageId: String, status: MessageStatus) { - _messages.value = _messages.value.map { msg -> - if (msg.id == messageId) msg.copy(status = status) else msg - } - - // 🔥 Также обновляем кэш! - updateCacheFromCurrentMessages() - } - - /** - * 🔄 Очистить localUri в attachments сообщения (после успешной отправки) - */ - private fun updateMessageAttachments(messageId: String, localUri: String?) { - _messages.value = _messages.value.map { msg -> - if (msg.id == messageId) { - val updatedAttachments = msg.attachments.map { att -> - att.copy(localUri = localUri ?: "") + _messages.value = + _messages.value.map { msg -> + if (msg.id == messageId) msg.copy(status = status) else msg } - msg.copy(attachments = updatedAttachments) - } else msg - } - + // 🔥 Также обновляем кэш! updateCacheFromCurrentMessages() } - - /** - * 🔥 Обновить кэш из текущих сообщений (для синхронизации после изменений) - */ + + /** 🔄 Очистить localUri в attachments сообщения (после успешной отправки) */ + private fun updateMessageAttachments(messageId: String, localUri: String?) { + _messages.value = + _messages.value.map { msg -> + if (msg.id == messageId) { + val updatedAttachments = + msg.attachments.map { att -> att.copy(localUri = localUri ?: "") } + msg.copy(attachments = updatedAttachments) + } else msg + } + + // 🔥 Также обновляем кэш! + updateCacheFromCurrentMessages() + } + + /** 🔥 Обновить кэш из текущих сообщений (для синхронизации после изменений) */ private fun updateCacheFromCurrentMessages() { val account = myPublicKey ?: return val opponent = opponentKey ?: return val dialogKey = getDialogKey(account, opponent) updateCacheWithLimit(dialogKey, _messages.value) } - - /** - * Обновить статус сообщения в БД - */ + + /** Обновить статус сообщения в БД */ private suspend fun updateMessageStatusInDb(messageId: String, delivered: Int) { val account = myPublicKey ?: return try { messageDao.updateDeliveryStatus(account, messageId, delivered) - } catch (e: Exception) { - } + } catch (e: Exception) {} } - - /** - * 🔄 Обновить статус и attachments в БД (для очистки localUri после отправки) - */ - private suspend fun updateMessageStatusAndAttachmentsInDb(messageId: String, delivered: Int, attachmentsJson: String) { + + /** 🔄 Обновить статус и attachments в БД (для очистки localUri после отправки) */ + private suspend fun updateMessageStatusAndAttachmentsInDb( + messageId: String, + delivered: Int, + attachmentsJson: String + ) { val account = myPublicKey ?: return try { - messageDao.updateDeliveryStatusAndAttachments(account, messageId, delivered, attachmentsJson) - } catch (e: Exception) { - } + messageDao.updateDeliveryStatusAndAttachments( + account, + messageId, + delivered, + attachmentsJson + ) + } catch (e: Exception) {} } - - /** - * Установить ключи пользователя - */ + + /** Установить ключи пользователя */ fun setUserKeys(publicKey: String, privateKey: String) { myPublicKey = publicKey myPrivateKey = privateKey } - - /** - * Открыть диалог - */ + + /** Открыть диалог */ fun openDialog(publicKey: String, title: String = "", username: String = "") { - + // 🔥 ВСЕГДА перезагружаем данные - не кешируем, т.к. диалог мог быть удалён // if (opponentKey == publicKey) { // return // } - + // Отменяем предыдущую загрузку loadingJob?.cancel() - + opponentKey = publicKey opponentTitle = title opponentUsername = username - + // 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния! // Это важно для правильного отображения forward сообщений сразу val forwardMessages = ForwardManager.getForwardMessagesForChat(publicKey) val hasForward = forwardMessages.isNotEmpty() - if (hasForward) { - } - + if (hasForward) {} + // Сбрасываем состояние _messages.value = emptyList() _opponentOnline.value = false @@ -485,22 +509,22 @@ if (message.id in currentIds) { isLoadingMessages = false lastReadMessageTimestamp = 0L readReceiptSentForCurrentDialog = false - subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога - isDialogActive = true // 🔥 Диалог активен! - - + subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога + isDialogActive = true // 🔥 Диалог активен! + // 📨 Применяем Forward сообщения СРАЗУ после сброса if (hasForward) { // Конвертируем ForwardMessage в ReplyMessage - _replyMessages.value = forwardMessages.map { fm -> - ReplyMessage( - messageId = fm.messageId, - text = fm.text, - timestamp = fm.timestamp, - isOutgoing = fm.isOutgoing, - publicKey = fm.senderPublicKey - ) - } + _replyMessages.value = + forwardMessages.map { fm -> + ReplyMessage( + messageId = fm.messageId, + text = fm.text, + timestamp = fm.timestamp, + isOutgoing = fm.isOutgoing, + publicKey = fm.senderPublicKey + ) + } _isForwardMode.value = true // Очищаем ForwardManager после применения ForwardManager.clear() @@ -509,212 +533,230 @@ if (message.id in currentIds) { _replyMessages.value = emptyList() _isForwardMode.value = false } - + // Подписываемся на онлайн статус subscribeToOnlineStatus() - + // 🔥 ОПТИМИЗАЦИЯ: Загружаем сообщения ПОСЛЕ задержки для плавной анимации // 250ms - это время анимации перехода в чат loadMessagesFromDatabase(delayMs = 250L) } - + /** - * 🔥 Закрыть диалог (вызывается когда пользователь выходит из чата) - * Как setCurrentDialogPublicKeyView("") в архиве + * 🔥 Закрыть диалог (вызывается когда пользователь выходит из чата) Как + * setCurrentDialogPublicKeyView("") в архиве */ fun closeDialog() { isDialogActive = false typingTimeoutJob?.cancel() } - + /** - * 🔥 Установить состояние активности диалога (вызывается при ON_RESUME/ON_PAUSE) - * Предотвращает отметку сообщений как прочитанных когда приложение в фоне + * 🔥 Установить состояние активности диалога (вызывается при ON_RESUME/ON_PAUSE) Предотвращает + * отметку сообщений как прочитанных когда приложение в фоне */ fun setDialogActive(active: Boolean) { isDialogActive = active } - + /** - * 🚀 СУПЕР-оптимизированная загрузка сообщений - * 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка - * 📁 SAVED MESSAGES: Использует специальные методы для saved messages чтобы избежать дублирования + * 🚀 СУПЕР-оптимизированная загрузка сообщений 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка + * для анимации + чанковая расшифровка 📁 SAVED MESSAGES: Использует специальные методы для + * saved messages чтобы избежать дублирования */ private fun loadMessagesFromDatabase(delayMs: Long = 0L) { val account = myPublicKey ?: return val opponent = opponentKey ?: return val dialogKey = getDialogKey(account, opponent) - - + // 📁 Проверяем является ли это Saved Messages val isSavedMessages = (opponent == account) - + if (isLoadingMessages) { return } isLoadingMessages = true - - loadingJob = viewModelScope.launch(Dispatchers.IO) { - try { - // 🔥 МГНОВЕННАЯ загрузка из кэша если есть! - val cachedMessages = dialogMessagesCache[dialogKey] - - if (cachedMessages != null && cachedMessages.isNotEmpty()) { - withContext(Dispatchers.Main.immediate) { - _messages.value = cachedMessages - _isLoading.value = false - } - - // Фоновое обновление из БД (новые сообщения) - delay(100) // Небольшая задержка чтобы UI успел отрисоваться - refreshMessagesFromDb(account, opponent, dialogKey, cachedMessages, isSavedMessages) - isLoadingMessages = false - return@launch - } - - // 🔥 Нет кэша - проверяем есть ли вообще сообщения в БД - // Если диалог пустой - не показываем скелетон! - val totalCount = if (isSavedMessages) { - messageDao.getMessageCountForSavedDialog(account) - } else { - messageDao.getMessageCount(account, dialogKey) - } - - - if (totalCount == 0) { - // Пустой диалог - сразу показываем пустое состояние без скелетона - withContext(Dispatchers.Main.immediate) { - _messages.value = emptyList() - _isLoading.value = false - } - isLoadingMessages = false - return@launch - } - - // 🔥 Есть сообщения - показываем скелетон и загружаем с задержкой для анимации - if (delayMs > 0) { - withContext(Dispatchers.Main.immediate) { - _isLoading.value = true // Показываем скелетон - } - delay(delayMs) - } - - - // 🔥 Получаем первую страницу - используем специальный метод для saved messages - val entities = if (isSavedMessages) { - messageDao.getMessagesForSavedDialog(account, limit = PAGE_SIZE, offset = 0) - } else { - messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) - } - - - hasMoreMessages = entities.size >= PAGE_SIZE - currentOffset = entities.size - - // 🔥 ЧАНКОВАЯ расшифровка - по DECRYPT_CHUNK_SIZE сообщений с yield между ними - // Это предотвращает блокировку UI thread - val messages = ArrayList(entities.size) - val reversedEntities = entities.asReversed() - for ((index, entity) in reversedEntities.withIndex()) { - val chatMsg = entityToChatMessage(entity) - messages.add(chatMsg) - - // Каждые DECRYPT_CHUNK_SIZE сообщений даём UI thread "подышать" - if ((index + 1) % DECRYPT_CHUNK_SIZE == 0) { - yield() // Позволяем другим корутинам выполниться - } - } - - // 🔥 Сохраняем в кэш для мгновенной повторной загрузки! - updateCacheWithLimit(dialogKey, messages.toList()) + loadingJob = + viewModelScope.launch(Dispatchers.IO) { + try { + // 🔥 МГНОВЕННАЯ загрузка из кэша если есть! + val cachedMessages = dialogMessagesCache[dialogKey] - // 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно - // НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД - withContext(Dispatchers.Main.immediate) { - val dbIds = messages.map { it.id }.toSet() - val currentMsgs = _messages.value -val optimisticMessages = currentMsgs.filter { msg -> - msg.status == MessageStatus.SENDING && msg.id !in dbIds - } -val newList = messages + optimisticMessages - - // 🔍 Финальная дедупликация по ID (на всякий случай) - val deduplicatedList = newList.distinctBy { it.id } - - if (deduplicatedList.size != newList.size) { - } - - _messages.value = deduplicatedList - _isLoading.value = false - } - - // 🔥 Фоновые операции БЕЗ блокировки UI - launch(Dispatchers.IO) { - // 👁️ Отмечаем как прочитанные ТОЛЬКО если диалог активен - if (isDialogActive) { - messageDao.markDialogAsRead(account, dialogKey) - } - // 🔥 Пересчитываем счетчики из messages - используем специальный метод для saved messages - if (isSavedMessages) { - dialogDao.updateSavedMessagesDialogFromMessages(account) - } else { - dialogDao.updateDialogFromMessages(account, opponent) - } - - // Отправляем read receipt собеседнику (НЕ для saved messages!) - if (!isSavedMessages && messages.isNotEmpty()) { - val lastIncoming = messages.lastOrNull { !it.isOutgoing } - if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) { - sendReadReceiptToOpponent() + if (cachedMessages != null && cachedMessages.isNotEmpty()) { + withContext(Dispatchers.Main.immediate) { + _messages.value = cachedMessages + _isLoading.value = false + } + + // Фоновое обновление из БД (новые сообщения) + delay(100) // Небольшая задержка чтобы UI успел отрисоваться + refreshMessagesFromDb( + account, + opponent, + dialogKey, + cachedMessages, + isSavedMessages + ) + isLoadingMessages = false + return@launch } + + // 🔥 Нет кэша - проверяем есть ли вообще сообщения в БД + // Если диалог пустой - не показываем скелетон! + val totalCount = + if (isSavedMessages) { + messageDao.getMessageCountForSavedDialog(account) + } else { + messageDao.getMessageCount(account, dialogKey) + } + + if (totalCount == 0) { + // Пустой диалог - сразу показываем пустое состояние без скелетона + withContext(Dispatchers.Main.immediate) { + _messages.value = emptyList() + _isLoading.value = false + } + isLoadingMessages = false + return@launch + } + + // 🔥 Есть сообщения - показываем скелетон и загружаем с задержкой для + // анимации + if (delayMs > 0) { + withContext(Dispatchers.Main.immediate) { + _isLoading.value = true // Показываем скелетон + } + delay(delayMs) + } + + // 🔥 Получаем первую страницу - используем специальный метод для saved + // messages + val entities = + if (isSavedMessages) { + messageDao.getMessagesForSavedDialog( + account, + limit = PAGE_SIZE, + offset = 0 + ) + } else { + messageDao.getMessages( + account, + dialogKey, + limit = PAGE_SIZE, + offset = 0 + ) + } + + hasMoreMessages = entities.size >= PAGE_SIZE + currentOffset = entities.size + + // 🔥 ЧАНКОВАЯ расшифровка - по DECRYPT_CHUNK_SIZE сообщений с yield между + // ними + // Это предотвращает блокировку UI thread + val messages = ArrayList(entities.size) + val reversedEntities = entities.asReversed() + for ((index, entity) in reversedEntities.withIndex()) { + val chatMsg = entityToChatMessage(entity) + messages.add(chatMsg) + + // Каждые DECRYPT_CHUNK_SIZE сообщений даём UI thread "подышать" + if ((index + 1) % DECRYPT_CHUNK_SIZE == 0) { + yield() // Позволяем другим корутинам выполниться + } + } + + // 🔥 Сохраняем в кэш для мгновенной повторной загрузки! + updateCacheWithLimit(dialogKey, messages.toList()) + + // 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно + // НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД + withContext(Dispatchers.Main.immediate) { + val dbIds = messages.map { it.id }.toSet() + val currentMsgs = _messages.value + val optimisticMessages = + currentMsgs.filter { msg -> + msg.status == MessageStatus.SENDING && msg.id !in dbIds + } + val newList = messages + optimisticMessages + + // 🔍 Финальная дедупликация по ID (на всякий случай) + val deduplicatedList = newList.distinctBy { it.id } + + if (deduplicatedList.size != newList.size) {} + + _messages.value = deduplicatedList + _isLoading.value = false + } + + // 🔥 Фоновые операции БЕЗ блокировки UI + launch(Dispatchers.IO) { + // 👁️ Отмечаем как прочитанные ТОЛЬКО если диалог активен + if (isDialogActive) { + messageDao.markDialogAsRead(account, dialogKey) + } + // 🔥 Пересчитываем счетчики из messages - используем специальный метод + // для saved messages + if (isSavedMessages) { + dialogDao.updateSavedMessagesDialogFromMessages(account) + } else { + dialogDao.updateDialogFromMessages(account, opponent) + } + + // Отправляем read receipt собеседнику (НЕ для saved messages!) + if (!isSavedMessages && messages.isNotEmpty()) { + val lastIncoming = messages.lastOrNull { !it.isOutgoing } + if (lastIncoming != null && + lastIncoming.timestamp.time > + lastReadMessageTimestamp + ) { + sendReadReceiptToOpponent() + } + } + } + + isLoadingMessages = false + } catch (e: Exception) { + withContext(Dispatchers.Main.immediate) { _isLoading.value = false } + isLoadingMessages = false } } - - isLoadingMessages = false - - } catch (e: Exception) { - withContext(Dispatchers.Main.immediate) { - _isLoading.value = false - } - isLoadingMessages = false - } - } } - + /** - * 🔥 Фоновое обновление сообщений из БД (проверка новых) - * Вызывается когда кэш уже отображён, но нужно проверить есть ли новые сообщения - * 🔥 ВАЖНО: НЕ заменяем все сообщения - только добавляем новые, сохраняя существующие! - * 📁 SAVED MESSAGES: Использует специальные методы для saved messages + * 🔥 Фоновое обновление сообщений из БД (проверка новых) Вызывается когда кэш уже отображён, но + * нужно проверить есть ли новые сообщения 🔥 ВАЖНО: НЕ заменяем все сообщения - только + * добавляем новые, сохраняя существующие! 📁 SAVED MESSAGES: Использует специальные методы для + * saved messages */ private suspend fun refreshMessagesFromDb( - account: String, - opponent: String, - dialogKey: String, - cachedMessages: List, - isSavedMessages: Boolean + account: String, + opponent: String, + dialogKey: String, + cachedMessages: List, + isSavedMessages: Boolean ) { try { - val entities = if (isSavedMessages) { - messageDao.getMessagesForSavedDialog(account, limit = PAGE_SIZE, offset = 0) - } else { - messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) - } - + val entities = + if (isSavedMessages) { + messageDao.getMessagesForSavedDialog(account, limit = PAGE_SIZE, offset = 0) + } else { + messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) + } + // 🔥 Берём ТЕКУЩЕЕ состояние UI (может уже содержать новые сообщения) val currentMessages = _messages.value val existingIds = currentMessages.map { it.id }.toSet() - + // 🔥 Находим только НОВЫЕ сообщения (которых нет в текущем UI) val newEntities = entities.filter { it.messageId !in existingIds } - + if (newEntities.isNotEmpty()) { val newMessages = ArrayList(newEntities.size) for (entity in newEntities) { newMessages.add(entityToChatMessage(entity)) } - + // 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем! // Сортируем по timestamp чтобы новые были в конце val updatedMessages = (currentMessages + newMessages).sortedBy { it.timestamp } @@ -725,14 +767,15 @@ val newList = messages + optimisticMessages val allCachedIds = existingCache.map { it.id }.toSet() val trulyNewMessages = newMessages.filter { it.id !in allCachedIds } if (trulyNewMessages.isNotEmpty()) { - updateCacheWithLimit(dialogKey, (existingCache + trulyNewMessages).sortedBy { it.timestamp }) + updateCacheWithLimit( + dialogKey, + (existingCache + trulyNewMessages).sortedBy { it.timestamp } + ) } - withContext(Dispatchers.Main.immediate) { - _messages.value = updatedMessages - } + withContext(Dispatchers.Main.immediate) { _messages.value = updatedMessages } } - + hasMoreMessages = entities.size >= PAGE_SIZE // 🔥 ИСПРАВЛЕНИЕ: НЕ сбрасываем offset если уже загружено больше сообщений! // Это предотвращает потерю прогресса пагинации при refresh @@ -744,54 +787,61 @@ val newList = messages + optimisticMessages if (isDialogActive) { messageDao.markDialogAsRead(account, dialogKey) } - // 🔥 Пересчитываем счетчики из messages - используем специальный метод для saved messages + // 🔥 Пересчитываем счетчики из messages - используем специальный метод для saved + // messages if (isSavedMessages) { dialogDao.updateSavedMessagesDialogFromMessages(account) } else { dialogDao.updateDialogFromMessages(account, opponent) } - - } catch (e: Exception) { - } + } catch (e: Exception) {} } - - // ✅ updateDialogCache удалён - кэш обновляется через loadMessagesFromDatabase после сохранения в БД - + + // ✅ updateDialogCache удалён - кэш обновляется через loadMessagesFromDatabase после сохранения + // в БД + /** - * 🚀 Загрузка следующей страницы (для бесконечной прокрутки) - * 📁 SAVED MESSAGES: Использует специальные методы для saved messages + * 🚀 Загрузка следующей страницы (для бесконечной прокрутки) 📁 SAVED MESSAGES: Использует + * специальные методы для saved messages */ fun loadMoreMessages() { val account = myPublicKey ?: return val opponent = opponentKey ?: return - + // 📁 Проверяем является ли это Saved Messages val isSavedMessages = (opponent == account) - + if (!hasMoreMessages || isLoadingMessages) return isLoadingMessages = true - + viewModelScope.launch(Dispatchers.IO) { try { - withContext(Dispatchers.Main) { - _isLoadingMore.value = true - } - + withContext(Dispatchers.Main) { _isLoadingMore.value = true } + val dialogKey = getDialogKey(account, opponent) - - val entities = if (isSavedMessages) { - messageDao.getMessagesForSavedDialog(account, limit = PAGE_SIZE, offset = currentOffset) - } else { - messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = currentOffset) - } - + + val entities = + if (isSavedMessages) { + messageDao.getMessagesForSavedDialog( + account, + limit = PAGE_SIZE, + offset = currentOffset + ) + } else { + messageDao.getMessages( + account, + dialogKey, + limit = PAGE_SIZE, + offset = currentOffset + ) + } + hasMoreMessages = entities.size >= PAGE_SIZE currentOffset += entities.size - + if (entities.isNotEmpty()) { - val newMessages = entities.map { entity -> - entityToChatMessage(entity) - }.asReversed() + val newMessages = + entities.map { entity -> entityToChatMessage(entity) }.asReversed() // 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений! // Это предотвращает потерю сообщений при повторном открытии диалога @@ -799,7 +849,10 @@ val newList = messages + optimisticMessages val allCachedIds = existingCache.map { it.id }.toSet() val trulyNewMessages = newMessages.filter { it.id !in allCachedIds } if (trulyNewMessages.isNotEmpty()) { - updateCacheWithLimit(dialogKey, (trulyNewMessages + existingCache).sortedBy { it.timestamp }) + updateCacheWithLimit( + dialogKey, + (trulyNewMessages + existingCache).sortedBy { it.timestamp } + ) } // Добавляем в начало списка (старые сообщения) @@ -808,293 +861,320 @@ val newList = messages + optimisticMessages } } - withContext(Dispatchers.Main) { - _isLoadingMore.value = false - } + withContext(Dispatchers.Main) { _isLoadingMore.value = false } isLoadingMessages = false - } catch (e: Exception) { - withContext(Dispatchers.Main) { - _isLoadingMore.value = false - } + withContext(Dispatchers.Main) { _isLoadingMore.value = false } isLoadingMessages = false } } } - + /** - * 🔥 Конвертация Entity -> ChatMessage с расшифровкой из content + chachaKey - * Как в архиве: расшифровываем при каждой загрузке + * 🔥 Конвертация Entity -> ChatMessage с расшифровкой из content + chachaKey Как в архиве: + * расшифровываем при каждой загрузке */ private suspend fun entityToChatMessage(entity: MessageEntity): ChatMessage { - + // Расшифровываем сообщение из content + chachaKey - var displayText = try { - val privateKey = myPrivateKey - if (privateKey != null && entity.content.isNotEmpty() && entity.chachaKey.isNotEmpty()) { - // Расшифровываем как в архиве: content + chachaKey + privateKey - val decrypted = MessageCrypto.decryptIncoming( - ciphertext = entity.content, - encryptedKey = entity.chachaKey, - myPrivateKey = privateKey - ) - decrypted - } else { - // Fallback на расшифровку plainMessage с приватным ключом - if (privateKey != null && entity.plainMessage.isNotEmpty()) { - try { - CryptoManager.decryptWithPassword(entity.plainMessage, privateKey) ?: entity.plainMessage - } catch (e: Exception) { + var displayText = + try { + val privateKey = myPrivateKey + if (privateKey != null && + entity.content.isNotEmpty() && + entity.chachaKey.isNotEmpty() + ) { + // Расшифровываем как в архиве: content + chachaKey + privateKey + val decrypted = + MessageCrypto.decryptIncoming( + ciphertext = entity.content, + encryptedKey = entity.chachaKey, + myPrivateKey = privateKey + ) + decrypted + } else { + // Fallback на расшифровку plainMessage с приватным ключом + if (privateKey != null && entity.plainMessage.isNotEmpty()) { + try { + CryptoManager.decryptWithPassword(entity.plainMessage, privateKey) + ?: entity.plainMessage + } catch (e: Exception) { + entity.plainMessage + } + } else { + entity.plainMessage + } + } + } catch (e: Exception) { + // Пробуем расшифровать plainMessage + val privateKey = myPrivateKey + if (privateKey != null && entity.plainMessage.isNotEmpty()) { + try { + CryptoManager.decryptWithPassword(entity.plainMessage, privateKey) + ?: entity.plainMessage + } catch (e2: Exception) { + entity.plainMessage + } + } else { entity.plainMessage } - } else { - entity.plainMessage } - } - } catch (e: Exception) { - // Пробуем расшифровать plainMessage - val privateKey = myPrivateKey - if (privateKey != null && entity.plainMessage.isNotEmpty()) { - try { - CryptoManager.decryptWithPassword(entity.plainMessage, privateKey) ?: entity.plainMessage - } catch (e2: Exception) { - entity.plainMessage - } - } else { - entity.plainMessage - } - } - - + // Парсим attachments для поиска MESSAGES (цитата) // 🔥 ВАЖНО: Передаем content и chachaKey для расшифровки reply blob если нужно - var replyData = parseReplyFromAttachments( - attachmentsJson = entity.attachments, - isFromMe = entity.fromMe == 1, - content = entity.content, - chachaKey = entity.chachaKey - ) - + var replyData = + parseReplyFromAttachments( + attachmentsJson = entity.attachments, + isFromMe = entity.fromMe == 1, + content = entity.content, + chachaKey = entity.chachaKey + ) + // Если не нашли reply в attachments, пробуем распарсить из текста if (replyData == null) { val parseResult = parseReplyFromText(displayText) if (parseResult != null) { replyData = parseResult.first displayText = parseResult.second - } else { - } - } else { - } - + } else {} + } else {} + // Парсим все attachments (IMAGE, FILE, AVATAR) val parsedAttachments = parseAllAttachments(entity.attachments) - + return ChatMessage( - id = entity.messageId, - text = displayText, - isOutgoing = entity.fromMe == 1, - timestamp = Date(entity.timestamp), - status = when (entity.delivered) { - 0 -> MessageStatus.SENDING - 1 -> MessageStatus.DELIVERED - 2 -> MessageStatus.SENT - 3 -> MessageStatus.READ - else -> MessageStatus.SENT - }, - replyData = replyData, - attachments = parsedAttachments, - chachaKey = entity.chachaKey + id = entity.messageId, + text = displayText, + isOutgoing = entity.fromMe == 1, + timestamp = Date(entity.timestamp), + status = + when (entity.delivered) { + 0 -> MessageStatus.SENDING + 1 -> MessageStatus.DELIVERED + 2 -> MessageStatus.SENT + 3 -> MessageStatus.READ + else -> MessageStatus.SENT + }, + replyData = replyData, + attachments = parsedAttachments, + chachaKey = entity.chachaKey ) } - + /** - * Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) - * 💾 Для IMAGE - загружает blob из файловой системы если пустой в БД + * Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) 💾 Для + * IMAGE - загружает blob из файловой системы если пустой в БД */ private fun parseAllAttachments(attachmentsJson: String): List { if (attachmentsJson.isEmpty() || attachmentsJson == "[]") { return emptyList() } - + return try { val attachments = JSONArray(attachmentsJson) val result = mutableListOf() val publicKey = myPublicKey ?: "" val privateKey = myPrivateKey ?: "" - + for (i in 0 until attachments.length()) { val attachment = attachments.getJSONObject(i) val type = attachment.optInt("type", 0) - + // Пропускаем MESSAGES (1) - это reply, обрабатывается отдельно if (type == 1) continue - + var blob = attachment.optString("blob", "") val attachmentId = attachment.optString("id", "") val attachmentType = AttachmentType.fromInt(type) - + // 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой - if ((attachmentType == AttachmentType.IMAGE || attachmentType == AttachmentType.AVATAR) - && blob.isEmpty() && attachmentId.isNotEmpty()) { - val fileBlob = AttachmentFileManager.readAttachment( - context = getApplication(), - attachmentId = attachmentId, - publicKey = publicKey, - privateKey = privateKey - ) + if ((attachmentType == AttachmentType.IMAGE || + attachmentType == AttachmentType.AVATAR) && + blob.isEmpty() && + attachmentId.isNotEmpty() + ) { + val fileBlob = + AttachmentFileManager.readAttachment( + context = getApplication(), + attachmentId = attachmentId, + publicKey = publicKey, + privateKey = privateKey + ) if (fileBlob != null) { blob = fileBlob } } - + result.add( - MessageAttachment( - id = attachmentId, - blob = blob, - type = attachmentType, - preview = attachment.optString("preview", ""), - width = attachment.optInt("width", 0), - height = attachment.optInt("height", 0), - localUri = attachment.optString("localUri", "") // 🔥 Поддержка localUri из БД - ) + MessageAttachment( + id = attachmentId, + blob = blob, + type = attachmentType, + preview = attachment.optString("preview", ""), + width = attachment.optInt("width", 0), + height = attachment.optInt("height", 0), + localUri = + attachment.optString( + "localUri", + "" + ) // 🔥 Поддержка localUri из БД + ) ) } - + result } catch (e: Exception) { emptyList() } } - + /** - * Парсинг reply из текста сообщения (fallback формат) - * Формат: "🇵 Reply: "текст цитаты"\n\nосновной текст" - * Возвращает Pair(ReplyData, очищенный текст) или null + * Парсинг reply из текста сообщения (fallback формат) Формат: "🇵 Reply: "текст + * цитаты"\n\nосновной текст" Возвращает Pair(ReplyData, очищенный текст) или null */ private fun parseReplyFromText(text: String): Pair? { // Паттерн: начинается с эмодзи флага + "Reply:" + текст в кавычках + перенос строки - val replyPattern = Regex("^[🇵🔁↩️]\\s*Reply:\\s*\"(.+?)\"\\s*\\n+(.*)$", RegexOption.DOT_MATCHES_ALL) + val replyPattern = + Regex("^[🇵🔁↩️]\\s*Reply:\\s*\"(.+?)\"\\s*\\n+(.*)$", RegexOption.DOT_MATCHES_ALL) val match = replyPattern.find(text) ?: return null - + val replyText = match.groupValues[1] val mainText = match.groupValues[2].trim() - + return Pair( - ReplyData( - messageId = "", - senderName = opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }, - text = replyText, - isFromMe = false, // Мы не знаем кто автор из fallback формата - senderPublicKey = opponentKey ?: "", - recipientPrivateKey = myPrivateKey ?: "" - ), - mainText + ReplyData( + messageId = "", + senderName = opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }, + text = replyText, + isFromMe = false, // Мы не знаем кто автор из fallback формата + senderPublicKey = opponentKey ?: "", + recipientPrivateKey = myPrivateKey ?: "" + ), + mainText ) } - + /** - * Парсинг MESSAGES attachment для извлечения данных цитаты - * Формат: [{"message_id": "...", "publicKey": "...", "message": "..."}] - * 🔥 ВАЖНО: Если blob зашифрован (формат "iv:ciphertext"), расшифровываем его + * Парсинг MESSAGES attachment для извлечения данных цитаты Формат: [{"message_id": "...", + * "publicKey": "...", "message": "..."}] 🔥 ВАЖНО: Если blob зашифрован (формат + * "iv:ciphertext"), расшифровываем его */ private suspend fun parseReplyFromAttachments( - attachmentsJson: String, - isFromMe: Boolean, - content: String, - chachaKey: String + attachmentsJson: String, + isFromMe: Boolean, + content: String, + chachaKey: String ): ReplyData? { - + if (attachmentsJson.isEmpty() || attachmentsJson == "[]") { return null } - + return try { val attachments = JSONArray(attachmentsJson) - + for (i in 0 until attachments.length()) { val attachment = attachments.getJSONObject(i) val type = attachment.optInt("type", 0) - + // MESSAGES = 1 (цитата) if (type == 1) { - + // Данные могут быть в blob или preview var dataJson = attachment.optString("blob", "") - + if (dataJson.isEmpty()) { dataJson = attachment.optString("preview", "") } - - + if (dataJson.isEmpty()) { continue } - - // 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат "iv:ciphertext" + + // 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат + // "iv:ciphertext" val colonCount = dataJson.count { it == ':' } - + if (dataJson.contains(":") && dataJson.split(":").size == 2) { val privateKey = myPrivateKey var decryptionSuccess = false - - // 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих сообщений) + + // 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих + // сообщений) if (privateKey != null) { try { - val decrypted = CryptoManager.decryptWithPassword(dataJson, privateKey) + val decrypted = + CryptoManager.decryptWithPassword(dataJson, privateKey) if (decrypted != null) { dataJson = decrypted decryptionSuccess = true } - } catch (e: Exception) { - } + } catch (e: Exception) {} } - - // 🔥 Способ 2: Пробуем расшифровать с ChaCha ключом сообщения (для входящих) - if (!decryptionSuccess && content.isNotEmpty() && chachaKey.isNotEmpty() && privateKey != null) { + + // 🔥 Способ 2: Пробуем расшифровать с ChaCha ключом сообщения (для + // входящих) + if (!decryptionSuccess && + content.isNotEmpty() && + chachaKey.isNotEmpty() && + privateKey != null + ) { try { - val decrypted = MessageCrypto.decryptAttachmentBlob(dataJson, chachaKey, privateKey) + val decrypted = + MessageCrypto.decryptAttachmentBlob( + dataJson, + chachaKey, + privateKey + ) if (decrypted != null) { dataJson = decrypted decryptionSuccess = true } - } catch (e: Exception) { - } + } catch (e: Exception) {} } - + // 🔥 Способ 3: Пробуем decryptReplyBlob с plainKeyAndNonce - if (!decryptionSuccess && content.isNotEmpty() && chachaKey.isNotEmpty() && privateKey != null) { + if (!decryptionSuccess && + content.isNotEmpty() && + chachaKey.isNotEmpty() && + privateKey != null + ) { try { - val decryptResult = MessageCrypto.decryptIncomingFull(content, chachaKey, privateKey) + val decryptResult = + MessageCrypto.decryptIncomingFull( + content, + chachaKey, + privateKey + ) val plainKeyAndNonce = decryptResult.plainKeyAndNonce - val decrypted = MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce) + val decrypted = + MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce) if (decrypted.isNotEmpty()) { dataJson = decrypted decryptionSuccess = true } - } catch (e: Exception) { - } + } catch (e: Exception) {} } - + if (!decryptionSuccess) { continue } - } else { - } - - val messagesArray = try { - JSONArray(dataJson) - } catch (e: Exception) { - continue - } - - + } else {} + + val messagesArray = + try { + JSONArray(dataJson) + } catch (e: Exception) { + continue + } + if (messagesArray.length() > 0) { val replyMessage = messagesArray.getJSONObject(0) val replyPublicKey = replyMessage.optString("publicKey", "") val replyText = replyMessage.optString("message", "") val replyMessageIdFromJson = replyMessage.optString("message_id", "") val replyTimestamp = replyMessage.optLong("timestamp", 0L) - + // 📸 Парсим attachments из JSON reply (как в Desktop) val replyAttachmentsFromJson = mutableListOf() try { @@ -1106,90 +1186,104 @@ val newList = messages + optimisticMessages val attType = AttachmentType.fromInt(attJson.optInt("type", 0)) val attPreview = attJson.optString("preview", "") val attBlob = attJson.optString("blob", "") - + if (attId.isNotEmpty()) { - replyAttachmentsFromJson.add(MessageAttachment( - id = attId, - type = attType, - preview = attPreview, - blob = attBlob - )) + replyAttachmentsFromJson.add( + MessageAttachment( + id = attId, + type = attType, + preview = attPreview, + blob = attBlob + ) + ) } } } - } catch (e: Exception) { - } - - + } catch (e: Exception) {} + // 🔥 ВАЖНО: message_id из JSON может не совпадать с messageId в Android БД! - // Пытаемся найти реальный messageId в текущих сообщениях по тексту и timestamp + // Пытаемся найти реальный messageId в текущих сообщениях по тексту и + // timestamp val account = myPublicKey ?: return null val dialogKey = getDialogKey(account, opponentKey ?: "") - val realMessageId = try { - // Ищем сообщение в БД по публичному ключу, тексту и timestamp (с допуском ±5 секунд) - messageDao.findMessageByContent( - account = account, - dialogKey = dialogKey, - fromPublicKey = replyPublicKey, - timestampFrom = replyTimestamp - 5000, - timestampTo = replyTimestamp + 5000 - )?.messageId ?: replyMessageIdFromJson - } catch (e: Exception) { - replyMessageIdFromJson - } - - + val realMessageId = + try { + // Ищем сообщение в БД по публичному ключу, тексту и timestamp + // (с допуском ±5 секунд) + messageDao.findMessageByContent( + account = account, + dialogKey = dialogKey, + fromPublicKey = replyPublicKey, + timestampFrom = replyTimestamp - 5000, + timestampTo = replyTimestamp + 5000 + ) + ?.messageId + ?: replyMessageIdFromJson + } catch (e: Exception) { + replyMessageIdFromJson + } + // Определяем, кто автор цитируемого сообщения val isReplyFromMe = replyPublicKey == myPublicKey - + // � Используем attachments из JSON если есть, иначе загружаем из БД - val originalAttachments = if (replyAttachmentsFromJson.isNotEmpty()) { - // Используем attachments из JSON reply - replyAttachmentsFromJson - } else { - // Fallback: загружаем из БД - try { - val originalMessage = messageDao.findMessageByContent( - account = account, - dialogKey = dialogKey, - fromPublicKey = replyPublicKey, - timestampFrom = replyTimestamp - 5000, - timestampTo = replyTimestamp + 5000 - ) - if (originalMessage != null && originalMessage.attachments.isNotEmpty()) { - parseAllAttachments(originalMessage.attachments) + val originalAttachments = + if (replyAttachmentsFromJson.isNotEmpty()) { + // Используем attachments из JSON reply + replyAttachmentsFromJson } else { - emptyList() + // Fallback: загружаем из БД + try { + val originalMessage = + messageDao.findMessageByContent( + account = account, + dialogKey = dialogKey, + fromPublicKey = replyPublicKey, + timestampFrom = replyTimestamp - 5000, + timestampTo = replyTimestamp + 5000 + ) + if (originalMessage != null && + originalMessage.attachments.isNotEmpty() + ) { + parseAllAttachments(originalMessage.attachments) + } else { + emptyList() + } + } catch (e: Exception) { + emptyList() + } } - } catch (e: Exception) { - emptyList() - } - } - - val result = ReplyData( - messageId = realMessageId, - senderName = if (isReplyFromMe) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }, - text = replyText, - isFromMe = isReplyFromMe, - attachments = originalAttachments, - senderPublicKey = if (isReplyFromMe) myPublicKey ?: "" else opponentKey ?: "", - recipientPrivateKey = myPrivateKey ?: "" - ) + + val result = + ReplyData( + messageId = realMessageId, + senderName = + if (isReplyFromMe) "You" + else + opponentTitle.ifEmpty { + opponentUsername.ifEmpty { "User" } + }, + text = replyText, + isFromMe = isReplyFromMe, + attachments = originalAttachments, + senderPublicKey = + if (isReplyFromMe) myPublicKey ?: "" + else opponentKey ?: "", + recipientPrivateKey = myPrivateKey ?: "" + ) return result - } else { - } - } else { - } + } else {} + } else {} } null } catch (e: Exception) { null } } - + /** - * Получить ключ диалога для группировки сообщений - * 📁 SAVED MESSAGES: Для saved messages (account == opponent) возвращает просто account + * Получить ключ диалога для группировки сообщений 📁 SAVED MESSAGES: Для saved messages + * (account == opponent) возвращает просто account */ private fun getDialogKey(account: String, opponent: String): String { // Для saved messages dialog_key = просто publicKey @@ -1203,57 +1297,53 @@ val newList = messages + optimisticMessages "$opponent:$account" } } - - /** - * Обновить текст ввода - */ + + /** Обновить текст ввода */ fun updateInputText(text: String) { _inputText.value = text } - + /** - * 🔥 Установить сообщения для Reply (как в React Native) - * Сохраняем publicKey отправителя для правильного отображения цитаты + * 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для + * правильного отображения цитаты */ fun setReplyMessages(messages: List) { val sender = myPublicKey ?: "" val opponent = opponentKey ?: "" - - _replyMessages.value = messages.map { msg -> - ReplyMessage( - messageId = msg.id, - text = msg.text, - timestamp = msg.timestamp.time, - isOutgoing = msg.isOutgoing, - // Если сообщение от меня - мой publicKey, иначе - собеседника - publicKey = if (msg.isOutgoing) sender else opponent - ) - } + + _replyMessages.value = + messages.map { msg -> + ReplyMessage( + messageId = msg.id, + text = msg.text, + timestamp = msg.timestamp.time, + isOutgoing = msg.isOutgoing, + // Если сообщение от меня - мой publicKey, иначе - собеседника + publicKey = if (msg.isOutgoing) sender else opponent + ) + } _isForwardMode.value = false } - - /** - * 🔥 Установить сообщения для Forward - */ + + /** 🔥 Установить сообщения для Forward */ fun setForwardMessages(messages: List) { val sender = myPublicKey ?: "" val opponent = opponentKey ?: "" - - _replyMessages.value = messages.map { msg -> - ReplyMessage( - messageId = msg.id, - text = msg.text, - timestamp = msg.timestamp.time, - isOutgoing = msg.isOutgoing, - publicKey = if (msg.isOutgoing) sender else opponent - ) - } + + _replyMessages.value = + messages.map { msg -> + ReplyMessage( + messageId = msg.id, + text = msg.text, + timestamp = msg.timestamp.time, + isOutgoing = msg.isOutgoing, + publicKey = if (msg.isOutgoing) sender else opponent + ) + } _isForwardMode.value = true } - - /** - * 🔥 Очистить reply/forward - */ + + /** 🔥 Очистить reply/forward */ fun clearReplyMessages() { viewModelScope.launch { delay(350) // Задержка после закрытия панели (анимация fadeOut + shrinkVertically) @@ -1261,31 +1351,27 @@ val newList = messages + optimisticMessages _isForwardMode.value = false } } - - /** - * 🔥 Удалить сообщение (для ошибки отправки) - */ + + /** 🔥 Удалить сообщение (для ошибки отправки) */ fun deleteMessage(messageId: String) { // Удаляем из UI сразу на main _messages.value = _messages.value.filter { it.id != messageId } - + // Удаляем из БД в IO viewModelScope.launch(Dispatchers.IO) { val account = myPublicKey ?: return@launch messageDao.deleteMessage(account, messageId) } } - - /** - * 🔥 Повторить отправку сообщения (для ошибки) - */ + + /** 🔥 Повторить отправку сообщения (для ошибки) */ fun retryMessage(message: ChatMessage) { // Удаляем старое сообщение deleteMessage(message.id) - + // Устанавливаем текст в инпут и отправляем _inputText.value = message.text - + // Отправляем с небольшой задержкой viewModelScope.launch { delay(100) @@ -1307,7 +1393,7 @@ val newList = messages + optimisticMessages val privateKey = myPrivateKey val replyMsgs = _replyMessages.value val isForward = _isForwardMode.value - + // Разрешаем отправку пустого текста если есть reply/forward if (text.isEmpty() && replyMsgs.isEmpty()) { return @@ -1335,47 +1421,57 @@ val newList = messages + optimisticMessages // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение) // Работает и для reply, и для forward - val replyData: ReplyData? = if (replyMsgs.isNotEmpty()) { - val firstReply = replyMsgs.first() - // 🖼️ Получаем attachments из текущих сообщений для превью - val replyAttachments = _messages.value.find { it.id == firstReply.messageId }?.attachments ?: emptyList() - ReplyData( - messageId = firstReply.messageId, - senderName = if (firstReply.isOutgoing) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }, - text = firstReply.text, - isFromMe = firstReply.isOutgoing, - isForwarded = isForward, - attachments = replyAttachments, - senderPublicKey = if (firstReply.isOutgoing) myPublicKey ?: "" else opponentKey ?: "", - recipientPrivateKey = myPrivateKey ?: "" - ) - } else null - + val replyData: ReplyData? = + if (replyMsgs.isNotEmpty()) { + val firstReply = replyMsgs.first() + // 🖼️ Получаем attachments из текущих сообщений для превью + val replyAttachments = + _messages.value.find { it.id == firstReply.messageId }?.attachments + ?: emptyList() + ReplyData( + messageId = firstReply.messageId, + senderName = + if (firstReply.isOutgoing) "You" + else + opponentTitle.ifEmpty { + opponentUsername.ifEmpty { "User" } + }, + text = firstReply.text, + isFromMe = firstReply.isOutgoing, + isForwarded = isForward, + attachments = replyAttachments, + senderPublicKey = + if (firstReply.isOutgoing) myPublicKey ?: "" + else opponentKey ?: "", + recipientPrivateKey = myPrivateKey ?: "" + ) + } else null + // Сохраняем reply для отправки ПЕРЕД очисткой val replyMsgsToSend = replyMsgs.toList() val isForwardToSend = isForward - + // 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble - val optimisticMessage = ChatMessage( - id = messageId, - text = text, // Только основной текст, без prefix - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - replyData = replyData // Данные для reply bubble - ) - + val optimisticMessage = + ChatMessage( + id = messageId, + text = text, // Только основной текст, без prefix + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + replyData = replyData // Данные для reply bubble + ) + // � Безопасное добавление с проверкой дубликатов addMessageSafely(optimisticMessage) _inputText.value = "" - + // 🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации clearReplyMessages() - + // Кэшируем текст decryptionCache[messageId] = text - - + // 2. 🔥 Шифрование и отправка в IO потоке viewModelScope.launch(Dispatchers.IO) { try { @@ -1383,74 +1479,85 @@ val newList = messages + optimisticMessages val encryptResult = MessageCrypto.encryptForSending(text, recipient) val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey - val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments - + val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - + // 🔥 Формируем attachments с reply (как в React Native) val messageAttachments = mutableListOf() - var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом) + var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом) if (replyMsgsToSend.isNotEmpty()) { - + // Формируем JSON массив с цитируемыми сообщениями (как в Desktop) val replyJsonArray = JSONArray() replyMsgsToSend.forEach { msg -> // Формируем attachments JSON (как в Desktop) val attachmentsArray = JSONArray() msg.attachments.forEach { att -> - attachmentsArray.put(JSONObject().apply { - put("id", att.id) - put("type", att.type.value) - put("preview", att.preview) - // Для IMAGE/FILE - blob не включаем (слишком большой) - // Для MESSAGES - включаем blob - put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "") - }) - } - - val replyJson = JSONObject().apply { - put("message_id", msg.messageId) - put("publicKey", msg.publicKey) - put("message", msg.text) - put("timestamp", msg.timestamp) - put("attachments", attachmentsArray) + attachmentsArray.put( + JSONObject().apply { + put("id", att.id) + put("type", att.type.value) + put("preview", att.preview) + // Для IMAGE/FILE - blob не включаем (слишком большой) + // Для MESSAGES - включаем blob + put( + "blob", + if (att.type == AttachmentType.MESSAGES) att.blob + else "" + ) + } + ) } + + val replyJson = + JSONObject().apply { + put("message_id", msg.messageId) + put("publicKey", msg.publicKey) + put("message", msg.text) + put("timestamp", msg.timestamp) + put("attachments", attachmentsArray) + } replyJsonArray.put(replyJson) } - + val replyBlobPlaintext = replyJsonArray.toString() - + // 🔥 Шифруем reply blob (для network transmission) с ChaCha ключом - val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce) - + val encryptedReplyBlob = + MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce) + // 🔥 Re-encrypt с приватным ключом для хранения в БД (как в Desktop Архиве) - replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey) - + replyBlobForDatabase = + CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey) + val replyAttachmentId = "reply_${timestamp}" - messageAttachments.add(MessageAttachment( - id = replyAttachmentId, - blob = encryptedReplyBlob, - type = AttachmentType.MESSAGES, - preview = "" - )) + messageAttachments.add( + MessageAttachment( + id = replyAttachmentId, + blob = encryptedReplyBlob, + type = AttachmentType.MESSAGES, + preview = "" + ) + ) } - - val packet = PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = messageAttachments - } - + + val packet = + PacketMessage().apply { + fromPublicKey = sender + toPublicKey = recipient + content = encryptedContent + chachaKey = encryptedKey + this.timestamp = timestamp + this.privateKey = privateKeyHash + this.messageId = messageId + attachments = messageAttachments + } + // 🔥 DEBUG: Log packet before sending - packet.attachments.forEachIndexed { idx, att -> - } - + packet.attachments.forEachIndexed { idx, att -> } + // 📁 Для Saved Messages - НЕ отправляем пакет на сервер // Только сохраняем локально val isSavedMessages = (sender == recipient) @@ -1458,62 +1565,74 @@ val newList = messages + optimisticMessages // Отправляем пакет только для обычных диалогов ProtocolManager.send(packet) } - + // 3. 🎯 UI обновление в Main потоке withContext(Dispatchers.Main) { // 📁 Для Saved Messages - сразу SENT, для обычных - ждём delivery updateMessageStatus(messageId, MessageStatus.SENT) } - + // 4. 💾 Сохранение в БД с attachments - // ⚠️ НЕ сохраняем blob для IMAGE/FILE - слишком большие (SQLite CursorWindow 2MB limit) + // ⚠️ НЕ сохраняем blob для IMAGE/FILE - слишком большие (SQLite CursorWindow 2MB + // limit) // Только MESSAGES (reply) сохраняем - они небольшие - val attachmentsJson = if (messageAttachments.isNotEmpty()) { - JSONArray().apply { - messageAttachments.forEach { att -> - put(JSONObject().apply { - put("id", att.id) - put("type", att.type.value) - put("preview", att.preview) - // Только для MESSAGES сохраняем blob (reply data небольшие) - // Для IMAGE/FILE - пустой blob - val blobToSave = when (att.type) { - AttachmentType.MESSAGES -> replyBlobForDatabase ?: "" - else -> "" // IMAGE, FILE - не сохраняем blob - } - put("blob", blobToSave) - }) - } - }.toString() - } else "[]" - + val attachmentsJson = + if (messageAttachments.isNotEmpty()) { + JSONArray() + .apply { + messageAttachments.forEach { att -> + put( + JSONObject().apply { + put("id", att.id) + put("type", att.type.value) + put("preview", att.preview) + // Только для MESSAGES сохраняем blob (reply + // data небольшие) + // Для IMAGE/FILE - пустой blob + val blobToSave = + when (att.type) { + AttachmentType.MESSAGES -> + replyBlobForDatabase + ?: "" + else -> "" // IMAGE, FILE - не + // сохраняем blob + } + put("blob", blobToSave) + } + ) + } + } + .toString() + } else "[]" + saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = encryptedContent, - encryptedKey = encryptedKey, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 2 else 0, // 📁 Saved Messages: сразу DELIVERED (2), иначе SENDING (0) - attachmentsJson = attachmentsJson + messageId = messageId, + text = text, + encryptedContent = encryptedContent, + encryptedKey = encryptedKey, + timestamp = timestamp, + isFromMe = true, + delivered = + if (isSavedMessages) 2 + else 0, // 📁 Saved Messages: сразу DELIVERED (2), иначе SENDING (0) + attachmentsJson = attachmentsJson ) - + saveDialog(text, timestamp) - } catch (e: Exception) { withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) // Changed from ERROR + updateMessageStatus(messageId, MessageStatus.SENT) // Changed from ERROR } } finally { isSending = false } } } - + /** - * 📸🚀 Отправка изображения по URI с МГНОВЕННЫМ optimistic UI - * Фото появляется в чате СРАЗУ, конвертация и отправка происходят в фоне - * + * 📸🚀 Отправка изображения по URI с МГНОВЕННЫМ optimistic UI Фото появляется в чате СРАЗУ, + * конвертация и отправка происходят в фоне + * * @param imageUri URI изображения * @param caption Подпись к изображению */ @@ -1522,113 +1641,138 @@ val newList = messages + optimisticMessages val sender = myPublicKey val privateKey = myPrivateKey val context = getApplication() - + if (recipient == null || sender == null || privateKey == null) { return } - + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() val text = caption.trim() val attachmentId = "img_$timestamp" - + // 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла) // Это предотвращает "расширение" пузырька при первом показе - val (imageWidth, imageHeight) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) - + val (imageWidth, imageHeight) = + com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) + // 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ // Используем URI напрямую для отображения (без конвертации в base64) - val optimisticMessage = ChatMessage( - id = messageId, - text = text, - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = listOf( - MessageAttachment( - id = attachmentId, - blob = "", // Пока пустой, обновим после конвертации - type = AttachmentType.IMAGE, - preview = "", // Пока пустой, обновим после генерации - width = imageWidth, // 🔥 Используем реальные размеры сразу! - height = imageHeight, // 🔥 Используем реальные размеры сразу! - localUri = imageUri.toString() // 🔥 Используем localUri для мгновенного показа + val optimisticMessage = + ChatMessage( + id = messageId, + text = text, + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + attachments = + listOf( + MessageAttachment( + id = attachmentId, + blob = "", // Пока пустой, обновим после конвертации + type = AttachmentType.IMAGE, + preview = + "", // Пока пустой, обновим после генерации + width = imageWidth, // 🔥 Используем реальные + // размеры сразу! + height = imageHeight, // 🔥 Используем реальные + // размеры сразу! + localUri = imageUri.toString() // 🔥 Используем + // localUri для + // мгновенного показа + ) + ) ) - ) - ) addMessageSafely(optimisticMessage) _inputText.value = "" - + // 2. 💾 СРАЗУ сохраняем в БД со статусом SENDING (delivered = 0) // Чтобы при выходе из диалога сообщение не пропадало viewModelScope.launch(Dispatchers.IO) { try { - // Сохраняем с localUri и размерами в attachments для восстановления при возврате в чат - val attachmentsJson = JSONArray().apply { - put(JSONObject().apply { - put("id", attachmentId) - put("type", AttachmentType.IMAGE.value) - put("preview", "") - put("blob", "") - put("width", imageWidth) // 🔥 Сохраняем размеры в БД - put("height", imageHeight) // 🔥 Сохраняем размеры в БД - put("localUri", imageUri.toString()) // 🔥 Сохраняем localUri - }) - }.toString() - + // Сохраняем с localUri и размерами в attachments для восстановления при возврате в + // чат + val attachmentsJson = + JSONArray() + .apply { + put( + JSONObject().apply { + put("id", attachmentId) + put("type", AttachmentType.IMAGE.value) + put("preview", "") + put("blob", "") + put( + "width", + imageWidth + ) // 🔥 Сохраняем размеры в БД + put( + "height", + imageHeight + ) // 🔥 Сохраняем размеры в БД + put( + "localUri", + imageUri.toString() + ) // 🔥 Сохраняем localUri + } + ) + } + .toString() + // Сохраняем optimistic сообщение в БД saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = "", // Пустой пока не зашифровали - encryptedKey = "", - timestamp = timestamp, - isFromMe = true, - delivered = 0, // SENDING - attachmentsJson = attachmentsJson + messageId = messageId, + text = text, + encryptedContent = "", // Пустой пока не зашифровали + encryptedKey = "", + timestamp = timestamp, + isFromMe = true, + delivered = 0, // SENDING + attachmentsJson = attachmentsJson ) } catch (e: Exception) { // Игнорируем ошибку - главное что в UI показали } } - + // 3. 🔄 В ФОНЕ: конвертируем, генерируем blurhash и отправляем viewModelScope.launch(Dispatchers.IO) { try { // Получаем размеры изображения - val (width, height) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) - + val (width, height) = + com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) + // Конвертируем в base64 - val imageBase64 = com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri) + val imageBase64 = + com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri) if (imageBase64 == null) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } return@launch } - + // Генерируем blurhash - val blurhash = com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri) - + val blurhash = + com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri) + // 4. 🔄 Обновляем optimistic сообщение с реальными данными withContext(Dispatchers.Main) { updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height) } - + // 5. 📤 Отправляем (шифрование + загрузка на сервер) sendImageMessageInternal( - messageId = messageId, - imageBase64 = imageBase64, - blurhash = blurhash, - caption = text, - width = width, - height = height, - timestamp = timestamp, - recipient = recipient, - sender = sender, - privateKey = privateKey + messageId = messageId, + imageBase64 = imageBase64, + blurhash = blurhash, + caption = text, + width = width, + height = height, + timestamp = timestamp, + recipient = recipient, + sender = sender, + privateKey = privateKey ) - } catch (e: Exception) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) @@ -1636,143 +1780,150 @@ val newList = messages + optimisticMessages } } } - - /** - * 🔄 Обновляет optimistic сообщение с реальными данными изображения - */ - private fun updateOptimisticImageMessage(messageId: String, base64: String, blurhash: String, width: Int, height: Int) { + + /** 🔄 Обновляет optimistic сообщение с реальными данными изображения */ + private fun updateOptimisticImageMessage( + messageId: String, + base64: String, + blurhash: String, + width: Int, + height: Int + ) { val currentMessages = _messages.value.toMutableList() val index = currentMessages.indexOfFirst { it.id == messageId } if (index != -1) { val message = currentMessages[index] - val updatedAttachments = message.attachments.map { att -> - if (att.type == AttachmentType.IMAGE) { - att.copy( - preview = blurhash, - blob = base64, - width = width, - height = height - ) - } else att - } + val updatedAttachments = + message.attachments.map { att -> + if (att.type == AttachmentType.IMAGE) { + att.copy( + preview = blurhash, + blob = base64, + width = width, + height = height + ) + } else att + } currentMessages[index] = message.copy(attachments = updatedAttachments) _messages.value = currentMessages } } - - /** - * 📤 Внутренняя функция отправки изображения (уже с готовым base64) - */ + + /** 📤 Внутренняя функция отправки изображения (уже с готовым base64) */ private suspend fun sendImageMessageInternal( - messageId: String, - imageBase64: String, - blurhash: String, - caption: String, - width: Int, - height: Int, - timestamp: Long, - recipient: String, - sender: String, - privateKey: String + messageId: String, + imageBase64: String, + blurhash: String, + caption: String, + width: Int, + height: Int, + timestamp: Long, + recipient: String, + sender: String, + privateKey: String ) { try { val context = getApplication() - + // Шифрование текста val encryptResult = MessageCrypto.encryptForSending(caption, recipient) val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey val plainKeyAndNonce = encryptResult.plainKeyAndNonce - + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - + // 🚀 Шифруем изображение с ChaCha ключом для Transport Server val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce) - + val attachmentId = "img_$timestamp" - + // 📤 Загружаем на Transport Server val isSavedMessages = (sender == recipient) var uploadTag = "" - + if (!isSavedMessages) { uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) } - + // Preview содержит tag::blurhash val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash - - val imageAttachment = MessageAttachment( - id = attachmentId, - blob = "", - type = AttachmentType.IMAGE, - preview = previewWithTag, - width = width, - height = height - ) - - val packet = PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = listOf(imageAttachment) - } - + + val imageAttachment = + MessageAttachment( + id = attachmentId, + blob = "", + type = AttachmentType.IMAGE, + preview = previewWithTag, + width = width, + height = height + ) + + val packet = + PacketMessage().apply { + fromPublicKey = sender + toPublicKey = recipient + content = encryptedContent + chachaKey = encryptedKey + this.timestamp = timestamp + this.privateKey = privateKeyHash + this.messageId = messageId + attachments = listOf(imageAttachment) + } + // Отправляем пакет if (!isSavedMessages) { ProtocolManager.send(packet) } - + // 💾 Сохраняем изображение в файл локально AttachmentFileManager.saveAttachment( - context = context, - blob = imageBase64, - attachmentId = attachmentId, - publicKey = sender, - privateKey = privateKey + context = context, + blob = imageBase64, + attachmentId = attachmentId, + publicKey = sender, + privateKey = privateKey ) - + // Сохраняем в БД - val attachmentsJson = JSONArray().apply { - put(JSONObject().apply { - put("id", attachmentId) - put("type", AttachmentType.IMAGE.value) - put("preview", previewWithTag) - put("blob", "") - put("width", width) - put("height", height) - }) - }.toString() - + val attachmentsJson = + JSONArray() + .apply { + put( + JSONObject().apply { + put("id", attachmentId) + put("type", AttachmentType.IMAGE.value) + put("preview", previewWithTag) + put("blob", "") + put("width", width) + put("height", height) + } + ) + } + .toString() + // 🔄 Обновляем статус на SENT и очищаем localUri в attachments // (сообщение уже существует в БД от optimistic UI, поэтому просто обновляем) - val finalAttachmentsJson = attachmentsJson // Уже без localUri - + val finalAttachmentsJson = attachmentsJson // Уже без localUri + if (!isSavedMessages) { updateMessageStatusAndAttachmentsInDb(messageId, 2, finalAttachmentsJson) } else { updateMessageStatusAndAttachmentsInDb(messageId, 2, finalAttachmentsJson) } - + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) // Также очищаем localUri в UI updateMessageAttachments(messageId, null) } - + saveDialog(if (caption.isNotEmpty()) caption else "photo", timestamp) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - } + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } } } - + /** * 📸 Отправка сообщения с изображением * @param imageBase64 Base64 изображения @@ -1781,47 +1932,54 @@ val newList = messages + optimisticMessages * @param width Ширина изображения (для правильного отображения) * @param height Высота изображения (для правильного отображения) */ - fun sendImageMessage(imageBase64: String, blurhash: String, caption: String = "", width: Int = 0, height: Int = 0) { + fun sendImageMessage( + imageBase64: String, + blurhash: String, + caption: String = "", + width: Int = 0, + height: Int = 0 + ) { val recipient = opponentKey val sender = myPublicKey val privateKey = myPrivateKey - + if (recipient == null || sender == null || privateKey == null) { return } if (isSending) { return } - + isSending = true - + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() val text = caption.trim() - + // 1. 🚀 Optimistic UI - показываем сообщение с placeholder - val optimisticMessage = ChatMessage( - id = messageId, - text = text, - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = listOf( - MessageAttachment( - id = "img_$timestamp", - type = AttachmentType.IMAGE, - preview = blurhash, - blob = imageBase64, // Для локального отображения - width = width, - height = height + val optimisticMessage = + ChatMessage( + id = messageId, + text = text, + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + attachments = + listOf( + MessageAttachment( + id = "img_$timestamp", + type = AttachmentType.IMAGE, + preview = blurhash, + blob = imageBase64, // Для локального отображения + width = width, + height = height + ) + ) ) - ) - ) // 🔥 Безопасное добавление с проверкой дубликатов addMessageSafely(optimisticMessage) _inputText.value = "" - - + viewModelScope.launch(Dispatchers.IO) { try { // Шифрование текста @@ -1829,164 +1987,180 @@ val newList = messages + optimisticMessages val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey val plainKeyAndNonce = encryptResult.plainKeyAndNonce - + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - + // 🚀 Шифруем изображение с ChaCha ключом для Transport Server - val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce) - + val encryptedImageBlob = + MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce) + val attachmentId = "img_$timestamp" - + // 📤 Загружаем на Transport Server (как в desktop) // НЕ для Saved Messages - там не нужно загружать val isSavedMessages = (sender == recipient) var uploadTag = "" - + if (!isSavedMessages) { uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) } - + // Preview содержит tag::blurhash (как в desktop) - val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash - - val imageAttachment = MessageAttachment( - id = attachmentId, - blob = "", // 🔥 Пустой blob - файл на Transport Server! - type = AttachmentType.IMAGE, - preview = previewWithTag, - width = width, - height = height - ) - - val packet = PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = listOf(imageAttachment) - } - + val previewWithTag = + if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash + + val imageAttachment = + MessageAttachment( + id = attachmentId, + blob = "", // 🔥 Пустой blob - файл на Transport Server! + type = AttachmentType.IMAGE, + preview = previewWithTag, + width = width, + height = height + ) + + val packet = + PacketMessage().apply { + fromPublicKey = sender + toPublicKey = recipient + content = encryptedContent + chachaKey = encryptedKey + this.timestamp = timestamp + this.privateKey = privateKeyHash + this.messageId = messageId + attachments = listOf(imageAttachment) + } + // Отправляем пакет (без blob!) if (!isSavedMessages) { ProtocolManager.send(packet) } - + // 💾 Сохраняем изображение в файл локально (как в desktop) AttachmentFileManager.saveAttachment( - context = getApplication(), - blob = imageBase64, - attachmentId = attachmentId, - publicKey = sender, - privateKey = privateKey + context = getApplication(), + blob = imageBase64, + attachmentId = attachmentId, + publicKey = sender, + privateKey = privateKey ) - + // ⚠️ НЕ сохраняем blob в БД - он слишком большой (SQLite CursorWindow 2MB limit) // Изображение хранится в файловой системе - val attachmentsJson = JSONArray().apply { - put(JSONObject().apply { - put("id", attachmentId) - put("type", AttachmentType.IMAGE.value) - put("preview", previewWithTag) - put("blob", "") // Пустой blob - не сохраняем в БД! - put("width", width) - put("height", height) - }) - }.toString() - + val attachmentsJson = + JSONArray() + .apply { + put( + JSONObject().apply { + put("id", attachmentId) + put("type", AttachmentType.IMAGE.value) + put("preview", previewWithTag) + put("blob", "") // Пустой blob - не сохраняем в БД! + put("width", width) + put("height", height) + } + ) + } + .toString() + saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = encryptedContent, - encryptedKey = encryptedKey, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных - attachmentsJson = attachmentsJson + messageId = messageId, + text = text, + encryptedContent = encryptedContent, + encryptedKey = encryptedKey, + timestamp = timestamp, + isFromMe = true, + delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных + attachmentsJson = attachmentsJson ) - + // 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI if (!isSavedMessages) { - updateMessageStatusInDb(messageId, 2) // SENT + updateMessageStatusInDb(messageId, 2) // SENT } - - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - + + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } + saveDialog(if (text.isNotEmpty()) text else "photo", timestamp) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } } finally { isSending = false } } } - + /** * 🖼️ Отправка группы изображений как коллаж (как в Telegram) * @param images Список пар (base64, blurhash) для каждого изображения * @param caption Подпись к группе изображений (опционально) */ - data class ImageData(val base64: String, val blurhash: String, val width: Int = 0, val height: Int = 0) - + data class ImageData( + val base64: String, + val blurhash: String, + val width: Int = 0, + val height: Int = 0 + ) + fun sendImageGroup(images: List, caption: String = "") { if (images.isEmpty()) return - + // Если одно изображение - отправляем обычным способом if (images.size == 1) { - sendImageMessage(images[0].base64, images[0].blurhash, caption, images[0].width, images[0].height) + sendImageMessage( + images[0].base64, + images[0].blurhash, + caption, + images[0].width, + images[0].height + ) return } - + val recipient = opponentKey val sender = myPublicKey val privateKey = myPrivateKey - + if (recipient == null || sender == null || privateKey == null) { return } if (isSending) { return } - + isSending = true - + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() val text = caption.trim() - + // Создаём attachments для всех изображений - val attachmentsList = images.mapIndexed { index, imageData -> - MessageAttachment( - id = "img_${timestamp}_$index", - type = AttachmentType.IMAGE, - preview = imageData.blurhash, - blob = imageData.base64, // Для локального отображения - width = imageData.width, - height = imageData.height - ) - } - + val attachmentsList = + images.mapIndexed { index, imageData -> + MessageAttachment( + id = "img_${timestamp}_$index", + type = AttachmentType.IMAGE, + preview = imageData.blurhash, + blob = imageData.base64, // Для локального отображения + width = imageData.width, + height = imageData.height + ) + } + // 1. 🚀 Optimistic UI - val optimisticMessage = ChatMessage( - id = messageId, - text = text, - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = attachmentsList - ) + val optimisticMessage = + ChatMessage( + id = messageId, + text = text, + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + attachments = attachmentsList + ) // 🔥 Безопасное добавление с проверкой дубликатов addMessageSafely(optimisticMessage) _inputText.value = "" - - + viewModelScope.launch(Dispatchers.IO) { try { // Шифрование текста @@ -1994,110 +2168,111 @@ val newList = messages + optimisticMessages val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey val plainKeyAndNonce = encryptResult.plainKeyAndNonce - + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - + // Загружаем каждое изображение на Transport Server и создаём attachments val networkAttachments = mutableListOf() val attachmentsJsonArray = JSONArray() - + for ((index, imageData) in images.withIndex()) { val attachmentId = "img_${timestamp}_$index" - + // Шифруем изображение с ChaCha ключом - val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageData.base64, plainKeyAndNonce) - + val encryptedImageBlob = + MessageCrypto.encryptReplyBlob(imageData.base64, plainKeyAndNonce) + // Загружаем на Transport Server val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) - val previewWithTag = if (uploadTag != null) { - "$uploadTag::${imageData.blurhash}" - } else { - imageData.blurhash - } - + val previewWithTag = + if (uploadTag != null) { + "$uploadTag::${imageData.blurhash}" + } else { + imageData.blurhash + } + // Сохраняем в файл локально AttachmentFileManager.saveAttachment( - context = getApplication(), - blob = imageData.base64, - attachmentId = attachmentId, - publicKey = sender, - privateKey = privateKey + context = getApplication(), + blob = imageData.base64, + attachmentId = attachmentId, + publicKey = sender, + privateKey = privateKey ) - + // Для сети - networkAttachments.add(MessageAttachment( - id = attachmentId, - blob = if (uploadTag != null) "" else encryptedImageBlob, - type = AttachmentType.IMAGE, - preview = previewWithTag, - width = imageData.width, - height = imageData.height - )) - + networkAttachments.add( + MessageAttachment( + id = attachmentId, + blob = if (uploadTag != null) "" else encryptedImageBlob, + type = AttachmentType.IMAGE, + preview = previewWithTag, + width = imageData.width, + height = imageData.height + ) + ) + // Для БД - attachmentsJsonArray.put(JSONObject().apply { - put("id", attachmentId) - put("type", AttachmentType.IMAGE.value) - put("preview", previewWithTag) - put("blob", "") // Пустой blob - изображения в файловой системе - put("width", imageData.width) - put("height", imageData.height) - }) - + attachmentsJsonArray.put( + JSONObject().apply { + put("id", attachmentId) + put("type", AttachmentType.IMAGE.value) + put("preview", previewWithTag) + put("blob", "") // Пустой blob - изображения в файловой системе + put("width", imageData.width) + put("height", imageData.height) + } + ) } - + // Создаём пакет - val packet = PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = networkAttachments - } - + val packet = + PacketMessage().apply { + fromPublicKey = sender + toPublicKey = recipient + content = encryptedContent + chachaKey = encryptedKey + this.timestamp = timestamp + this.privateKey = privateKeyHash + this.messageId = messageId + attachments = networkAttachments + } + // Для Saved Messages не отправляем на сервер val isSavedMessages = (sender == recipient) if (!isSavedMessages) { ProtocolManager.send(packet) } - + // Сохраняем в БД saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = encryptedContent, - encryptedKey = encryptedKey, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 2 else 0, - attachmentsJson = attachmentsJsonArray.toString() + messageId = messageId, + text = text, + encryptedContent = encryptedContent, + encryptedKey = encryptedKey, + timestamp = timestamp, + isFromMe = true, + delivered = if (isSavedMessages) 2 else 0, + attachmentsJson = attachmentsJsonArray.toString() ) - + // 🔥 Обновляем статус в БД после отправки if (!isSavedMessages) { - updateMessageStatusInDb(messageId, 2) // SENT + updateMessageStatusInDb(messageId, 2) // SENT } - + // Обновляем UI - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } + saveDialog(if (text.isNotEmpty()) text else "📷 ${images.size} photos", timestamp) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } } finally { isSending = false } } } - + /** * 📄 Отправка сообщения с файлом * @param fileBase64 Base64 содержимого файла @@ -2105,360 +2280,381 @@ val newList = messages + optimisticMessages * @param fileSize Размер файла в байтах * @param caption Подпись к файлу (опционально) */ - fun sendFileMessage(fileBase64: String, fileName: String, fileSize: Long, caption: String = "") { + fun sendFileMessage( + fileBase64: String, + fileName: String, + fileSize: Long, + caption: String = "" + ) { val recipient = opponentKey val sender = myPublicKey val privateKey = myPrivateKey - + if (recipient == null || sender == null || privateKey == null) { return } if (isSending) { return } - + isSending = true - + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() val text = caption.trim() - val preview = "$fileSize::$fileName" // Format: "size::name" как в Desktop - + val preview = "$fileSize::$fileName" // Format: "size::name" как в Desktop + // 1. 🚀 Optimistic UI - val optimisticMessage = ChatMessage( - id = messageId, - text = text, - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = listOf( - MessageAttachment( - id = "file_$timestamp", - type = AttachmentType.FILE, - preview = preview, - blob = fileBase64 + val optimisticMessage = + ChatMessage( + id = messageId, + text = text, + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + attachments = + listOf( + MessageAttachment( + id = "file_$timestamp", + type = AttachmentType.FILE, + preview = preview, + blob = fileBase64 + ) + ) ) - ) - ) // 🔥 Безопасное добавление с проверкой дубликатов addMessageSafely(optimisticMessage) _inputText.value = "" - - + viewModelScope.launch(Dispatchers.IO) { try { val encryptResult = MessageCrypto.encryptForSending(text, recipient) val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey val plainKeyAndNonce = encryptResult.plainKeyAndNonce - + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - + // 🚀 Шифруем файл с ChaCha ключом для Transport Server val encryptedFileBlob = MessageCrypto.encryptReplyBlob(fileBase64, plainKeyAndNonce) - + val attachmentId = "file_$timestamp" - + // 📤 Загружаем на Transport Server (как в desktop) // НЕ для Saved Messages - там не нужно загружать val isSavedMessages = (sender == recipient) var uploadTag = "" - + if (!isSavedMessages) { uploadTag = TransportManager.uploadFile(attachmentId, encryptedFileBlob) } - + // Preview содержит tag::size::name (как в desktop) val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$preview" else preview - - val fileAttachment = MessageAttachment( - id = attachmentId, - blob = "", // 🔥 Пустой blob - файл на Transport Server! - type = AttachmentType.FILE, - preview = previewWithTag - ) - - val packet = PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = listOf(fileAttachment) - } - + + val fileAttachment = + MessageAttachment( + id = attachmentId, + blob = "", // 🔥 Пустой blob - файл на Transport Server! + type = AttachmentType.FILE, + preview = previewWithTag + ) + + val packet = + PacketMessage().apply { + fromPublicKey = sender + toPublicKey = recipient + content = encryptedContent + chachaKey = encryptedKey + this.timestamp = timestamp + this.privateKey = privateKeyHash + this.messageId = messageId + attachments = listOf(fileAttachment) + } + // Отправляем пакет (без blob!) if (!isSavedMessages) { ProtocolManager.send(packet) } - + // ⚠️ НЕ сохраняем файл локально - они слишком большие // Файлы загружаются с Transport Server при необходимости - val attachmentsJson = JSONArray().apply { - put(JSONObject().apply { - put("id", attachmentId) - put("type", AttachmentType.FILE.value) - put("preview", previewWithTag) - put("blob", "") // Пустой blob - не сохраняем в БД! - }) - }.toString() - + val attachmentsJson = + JSONArray() + .apply { + put( + JSONObject().apply { + put("id", attachmentId) + put("type", AttachmentType.FILE.value) + put("preview", previewWithTag) + put("blob", "") // Пустой blob - не сохраняем в БД! + } + ) + } + .toString() + // 🔥 Сохраняем сначала с SENDING, потом обновляем на SENT saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = encryptedContent, - encryptedKey = encryptedKey, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных, SENT для saved - attachmentsJson = attachmentsJson + messageId = messageId, + text = text, + encryptedContent = encryptedContent, + encryptedKey = encryptedKey, + timestamp = timestamp, + isFromMe = true, + delivered = + if (isSavedMessages) 2 + else 0, // SENDING для обычных, SENT для saved + attachmentsJson = attachmentsJson ) - + // 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI if (!isSavedMessages) { - updateMessageStatusInDb(messageId, 2) // SENT + updateMessageStatusInDb(messageId, 2) // SENT } - - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - + + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } + saveDialog(if (text.isNotEmpty()) text else "file", timestamp) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } } finally { isSending = false } } } - + /** - * Отправка аватарки пользователя - * По аналогии с desktop - отправляет текущий аватар как вложение + * Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение */ fun sendAvatarMessage() { val recipient = opponentKey val sender = myPublicKey val userPrivateKey = myPrivateKey - + if (recipient == null || sender == null || userPrivateKey == null) { return } if (isSending) { return } - + isSending = true - + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() - + viewModelScope.launch(Dispatchers.IO) { try { // Получаем свой аватар из AvatarRepository val avatarDao = database.avatarDao() val myAvatar = avatarDao.getLatestAvatar(sender) - + if (myAvatar == null) { withContext(Dispatchers.Main) { android.widget.Toast.makeText( - getApplication(), - "No avatar to send", - android.widget.Toast.LENGTH_SHORT - ).show() + getApplication(), + "No avatar to send", + android.widget.Toast.LENGTH_SHORT + ) + .show() } isSending = false return@launch } - + // Читаем и расшифровываем аватар - val avatarBlob = com.rosetta.messenger.utils.AvatarFileManager.readAvatar( - getApplication(), - myAvatar.avatar - ) - + val avatarBlob = + com.rosetta.messenger.utils.AvatarFileManager.readAvatar( + getApplication(), + myAvatar.avatar + ) + if (avatarBlob == null || avatarBlob.isEmpty()) { withContext(Dispatchers.Main) { android.widget.Toast.makeText( - getApplication(), - "Failed to read avatar", - android.widget.Toast.LENGTH_SHORT - ).show() + getApplication(), + "Failed to read avatar", + android.widget.Toast.LENGTH_SHORT + ) + .show() } isSending = false return@launch } - - + // 🔥 КРИТИЧНО: Desktop ожидает полный data URL, а не просто Base64! // Добавляем префикс если его нет - val avatarDataUrl = if (avatarBlob.startsWith("data:image")) { - avatarBlob - } else { - "data:image/png;base64,$avatarBlob" - } - - // Генерируем blurhash для preview (как на desktop) - val avatarBlurhash = withContext(Dispatchers.IO) { - try { - val bitmap = base64ToBitmap(avatarBlob) - if (bitmap != null) { - com.rosetta.messenger.utils.MediaUtils.generateBlurhashFromBitmap(bitmap) + val avatarDataUrl = + if (avatarBlob.startsWith("data:image")) { + avatarBlob } else { - "" + "data:image/png;base64,$avatarBlob" } - } catch (e: Exception) { - "" - } - } - + + // Генерируем blurhash для preview (как на desktop) + val avatarBlurhash = + withContext(Dispatchers.IO) { + try { + val bitmap = base64ToBitmap(avatarBlob) + if (bitmap != null) { + com.rosetta.messenger.utils.MediaUtils + .generateBlurhashFromBitmap(bitmap) + } else { + "" + } + } catch (e: Exception) { + "" + } + } + // 1. 🚀 Optimistic UI - val optimisticMessage = ChatMessage( - id = messageId, - text = "", - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = listOf( - MessageAttachment( - id = "avatar_$timestamp", - type = AttachmentType.AVATAR, - preview = avatarBlurhash, - blob = avatarBlob // Для локального отображения + val optimisticMessage = + ChatMessage( + id = messageId, + text = "", + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + attachments = + listOf( + MessageAttachment( + id = "avatar_$timestamp", + type = AttachmentType.AVATAR, + preview = avatarBlurhash, + blob = avatarBlob // Для локального + // отображения + ) + ) ) - ) - ) - withContext(Dispatchers.Main) { - addMessageSafely(optimisticMessage) - } - + withContext(Dispatchers.Main) { addMessageSafely(optimisticMessage) } + // 2. Шифрование текста (пустой текст для аватарки) val encryptResult = MessageCrypto.encryptForSending("", recipient) val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey val plainKeyAndNonce = encryptResult.plainKeyAndNonce - + val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey) - + // 🔥 КРИТИЧНО: Как в desktop - шифруем аватар с ChaCha ключом (plainKeyAndNonce) // НЕ с AVATAR_PASSWORD! AVATAR_PASSWORD используется только для локального хранения // Используем avatarDataUrl (с префиксом data:image/...) а не avatarBlob! - val encryptedAvatarBlob = MessageCrypto.encryptReplyBlob(avatarDataUrl, plainKeyAndNonce) - + val encryptedAvatarBlob = + MessageCrypto.encryptReplyBlob(avatarDataUrl, plainKeyAndNonce) + val avatarAttachmentId = "avatar_$timestamp" - + // 📤 Загружаем на Transport Server (как в desktop!) val isSavedMessages = (sender == recipient) var uploadTag = "" - + if (!isSavedMessages) { try { - uploadTag = TransportManager.uploadFile(avatarAttachmentId, encryptedAvatarBlob) + uploadTag = + TransportManager.uploadFile(avatarAttachmentId, encryptedAvatarBlob) } catch (e: Exception) { throw e } } - + // Preview содержит tag::blurhash (как в desktop) - val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$avatarBlurhash" else avatarBlurhash - - val avatarAttachment = MessageAttachment( - id = avatarAttachmentId, - blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server! - type = AttachmentType.AVATAR, - preview = previewWithTag - ) - + val previewWithTag = + if (uploadTag.isNotEmpty()) "$uploadTag::$avatarBlurhash" + else avatarBlurhash + + val avatarAttachment = + MessageAttachment( + id = avatarAttachmentId, + blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server! + type = AttachmentType.AVATAR, + preview = previewWithTag + ) + // 3. Отправляем пакет (с ПУСТЫМ blob!) - val packet = PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = listOf(avatarAttachment) - } - + val packet = + PacketMessage().apply { + fromPublicKey = sender + toPublicKey = recipient + content = encryptedContent + chachaKey = encryptedKey + this.timestamp = timestamp + this.privateKey = privateKeyHash + this.messageId = messageId + attachments = listOf(avatarAttachment) + } + if (!isSavedMessages) { ProtocolManager.send(packet) } - + // 💾 Сохраняем аватар в файл локально (как IMAGE - с приватным ключом) AttachmentFileManager.saveAttachment( - context = getApplication(), - blob = avatarBlob, - attachmentId = avatarAttachmentId, - publicKey = sender, - privateKey = userPrivateKey + context = getApplication(), + blob = avatarBlob, + attachmentId = avatarAttachmentId, + publicKey = sender, + privateKey = userPrivateKey ) - + // 4. 💾 Сохраняем в БД (БЕЗ blob - он в файле) - как в sendImageMessage - val attachmentsJson = JSONArray().apply { - put(JSONObject().apply { - put("id", avatarAttachmentId) - put("type", AttachmentType.AVATAR.value) - put("preview", previewWithTag) // tag::blurhash - put("blob", "") // Пустой blob - не сохраняем в БД! - }) - }.toString() - + val attachmentsJson = + JSONArray() + .apply { + put( + JSONObject().apply { + put("id", avatarAttachmentId) + put("type", AttachmentType.AVATAR.value) + put("preview", previewWithTag) // tag::blurhash + put("blob", "") // Пустой blob - не сохраняем в БД! + } + ) + } + .toString() + saveMessageToDatabase( - messageId = messageId, - text = "", // Аватар без текста - encryptedContent = encryptedContent, - encryptedKey = encryptedKey, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 2 else 0, // Как в sendImageMessage - attachmentsJson = attachmentsJson + messageId = messageId, + text = "", // Аватар без текста + encryptedContent = encryptedContent, + encryptedKey = encryptedKey, + timestamp = timestamp, + isFromMe = true, + delivered = if (isSavedMessages) 2 else 0, // Как в sendImageMessage + attachmentsJson = attachmentsJson ) - + // 🔥 Обновляем статус в БД после отправки if (!isSavedMessages) { - updateMessageStatusInDb(messageId, 2) // SENT + updateMessageStatusInDb(messageId, 2) // SENT } - + // Обновляем UI - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } + saveDialog("\$a=Avatar", timestamp) - - - } catch (e: Exception) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) android.widget.Toast.makeText( - getApplication(), - "Failed to send avatar: ${e.message}", - android.widget.Toast.LENGTH_SHORT - ).show() + getApplication(), + "Failed to send avatar: ${e.message}", + android.widget.Toast.LENGTH_SHORT + ) + .show() } } finally { isSending = false } } } - + /** - * Сохранить диалог в базу данных - * 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages - * 📁 SAVED MESSAGES: Использует специальный метод для saved messages + * Сохранить диалог в базу данных 🔥 Используем updateDialogFromMessages для пересчета счетчиков + * из messages 📁 SAVED MESSAGES: Использует специальный метод для saved messages */ private suspend fun saveDialog(lastMessage: String, timestamp: Long) { val account = myPublicKey ?: return val opponent = opponentKey ?: return - + try { // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики // напрямую из таблицы messages, как в Архиве! @@ -2468,217 +2664,228 @@ val newList = messages + optimisticMessages } else { dialogDao.updateDialogFromMessages(account, opponent) } - } catch (e: Exception) { - } + } catch (e: Exception) {} } - + /** - * Обновить диалог при входящем сообщении - * 📁 SAVED MESSAGES: Использует специальный метод для saved messages + * Обновить диалог при входящем сообщении 📁 SAVED MESSAGES: Использует специальный метод для + * saved messages */ - private suspend fun updateDialog(opponentKey: String, lastMessage: String, timestamp: Long, incrementUnread: Boolean) { + private suspend fun updateDialog( + opponentKey: String, + lastMessage: String, + timestamp: Long, + incrementUnread: Boolean + ) { val account = myPublicKey ?: return - + try { // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики // напрямую из таблицы messages, как в Архиве! - // Это гарантирует что unread_count всегда соответствует реальному количеству непрочитанных + // Это гарантирует что unread_count всегда соответствует реальному количеству + // непрочитанных // 📁 Используем специальный метод для saved messages if (opponentKey == account) { dialogDao.updateSavedMessagesDialogFromMessages(account) } else { dialogDao.updateDialogFromMessages(account, opponentKey) } - } catch (e: Exception) { - } + } catch (e: Exception) {} } - + /** - * Сохранить сообщение в базу данных - * 🔒 Безопасность: plainMessage НЕ сохраняется - только зашифрованный content + chachaKey - * @param text - расшифрованный текст (используется только для логов и обновления диалога) - * @param opponentPublicKey - публичный ключ собеседника (используется вместо глобального opponentKey) + * Сохранить сообщение в базу данных 🔒 Безопасность: plainMessage НЕ сохраняется - только + * зашифрованный content + chachaKey + * @param text + * - расшифрованный текст (используется только для логов и обновления диалога) + * @param opponentPublicKey + * - публичный ключ собеседника (используется вместо глобального opponentKey) */ private suspend fun saveMessageToDatabase( - messageId: String, - text: String, - encryptedContent: String, - encryptedKey: String, - timestamp: Long, - isFromMe: Boolean, - delivered: Int = 0, - attachmentsJson: String = "[]", - opponentPublicKey: String? = null + messageId: String, + text: String, + encryptedContent: String, + encryptedKey: String, + timestamp: Long, + isFromMe: Boolean, + delivered: Int = 0, + attachmentsJson: String = "[]", + opponentPublicKey: String? = null ) { val account = myPublicKey ?: return val opponent = opponentPublicKey ?: opponentKey ?: return val privateKey = myPrivateKey ?: return - + try { val dialogKey = getDialogKey(account, opponent) - + // 🔒 Проверяем messageId - если пустой, генерируем новый - val finalMessageId = if (messageId.isEmpty()) { - val generated = UUID.randomUUID().toString().replace("-", "").take(32) - generated - } else { - messageId - } - + val finalMessageId = + if (messageId.isEmpty()) { + val generated = UUID.randomUUID().toString().replace("-", "").take(32) + generated + } else { + messageId + } + // 🔒 Шифруем plainMessage с использованием приватного ключа val encryptedPlainMessage = CryptoManager.encryptWithPassword(text, privateKey) - + // ✅ Проверяем существует ли сообщение - ИСПОЛЬЗУЕМ результат! val exists = messageDao.messageExists(account, finalMessageId) if (exists) { // Сообщение уже есть в БД - не дублируем return } - - val entity = MessageEntity( - account = account, - fromPublicKey = if (isFromMe) account else opponent, - toPublicKey = if (isFromMe) opponent else account, - content = encryptedContent, - timestamp = timestamp, - chachaKey = encryptedKey, - read = if (isFromMe) 1 else 0, - fromMe = if (isFromMe) 1 else 0, - delivered = delivered, - messageId = finalMessageId, - plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в БД - attachments = attachmentsJson, - replyToMessageId = null, - dialogKey = dialogKey - ) - + + val entity = + MessageEntity( + account = account, + fromPublicKey = if (isFromMe) account else opponent, + toPublicKey = if (isFromMe) opponent else account, + content = encryptedContent, + timestamp = timestamp, + chachaKey = encryptedKey, + read = if (isFromMe) 1 else 0, + fromMe = if (isFromMe) 1 else 0, + delivered = delivered, + messageId = finalMessageId, + plainMessage = + encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в + // БД + attachments = attachmentsJson, + replyToMessageId = null, + dialogKey = dialogKey + ) + val insertedId = messageDao.insertMessage(entity) - - } catch (e: Exception) { - } + } catch (e: Exception) {} } - + private fun showTypingIndicator() { _opponentTyping.value = true // Отменяем предыдущий таймер, чтобы избежать race condition typingTimeoutJob?.cancel() - typingTimeoutJob = viewModelScope.launch(Dispatchers.Default) { - kotlinx.coroutines.delay(3000) - _opponentTyping.value = false - } + typingTimeoutJob = + viewModelScope.launch(Dispatchers.Default) { + kotlinx.coroutines.delay(3000) + _opponentTyping.value = false + } } - + /** - * 📝 Отправить индикатор "печатает..." - * С throttling чтобы не спамить сервер - * 📁 Для Saved Messages - не отправляем (нельзя печатать самому себе) + * 📝 Отправить индикатор "печатает..." С throttling чтобы не спамить сервер 📁 Для Saved + * Messages - не отправляем (нельзя печатать самому себе) */ fun sendTypingIndicator() { val now = System.currentTimeMillis() if (now - lastTypingSentTime < TYPING_THROTTLE_MS) return - - val opponent = opponentKey ?: run { - return - } - val sender = myPublicKey ?: run { - return - } - + + val opponent = + opponentKey + ?: run { + return + } + val sender = + myPublicKey + ?: run { + return + } + // 📁 Для Saved Messages - не отправляем typing indicator if (opponent == sender) { return } - - val privateKey = myPrivateKey ?: run { - return - } - + + val privateKey = + myPrivateKey + ?: run { + return + } + lastTypingSentTime = now - + viewModelScope.launch(Dispatchers.IO) { try { val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - - - val packet = PacketTyping().apply { - this.privateKey = privateKeyHash - fromPublicKey = sender - toPublicKey = opponent - } - + + val packet = + PacketTyping().apply { + this.privateKey = privateKeyHash + fromPublicKey = sender + toPublicKey = opponent + } + ProtocolManager.send(packet) - } catch (e: Exception) { - } + } catch (e: Exception) {} } } - + /** - * 👁️ Отправить read receipt собеседнику - * Как в архиве - просто отправляем PacketRead без messageId - * Означает что мы прочитали все сообщения от этого собеседника - * 📁 SAVED MESSAGES: НЕ отправляет read receipt для saved messages (нельзя слать самому себе) + * 👁️ Отправить read receipt собеседнику Как в архиве - просто отправляем PacketRead без + * messageId Означает что мы прочитали все сообщения от этого собеседника 📁 SAVED MESSAGES: НЕ + * отправляет read receipt для saved messages (нельзя слать самому себе) */ private fun sendReadReceiptToOpponent() { // 🔥 Не отправляем read receipt если диалог не активен (как в архиве) if (!isDialogActive) { return } - + val opponent = opponentKey ?: return val sender = myPublicKey ?: return - + // 📁 НЕ отправляем read receipt для saved messages (opponent == sender) if (opponent == sender) { return } - + val privateKey = myPrivateKey ?: return - + // Обновляем timestamp последнего прочитанного val lastIncoming = _messages.value.lastOrNull { !it.isOutgoing } if (lastIncoming != null) { lastReadMessageTimestamp = lastIncoming.timestamp.time } - + viewModelScope.launch(Dispatchers.IO) { try { val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - + // Desktop формат: privateKey, fromPublicKey, toPublicKey - val packet = PacketRead().apply { - this.privateKey = privateKeyHash - fromPublicKey = sender // Мы (кто прочитал) - toPublicKey = opponent // Кому отправляем уведомление (собеседник) - } - + val packet = + PacketRead().apply { + this.privateKey = privateKeyHash + fromPublicKey = sender // Мы (кто прочитал) + toPublicKey = opponent // Кому отправляем уведомление (собеседник) + } + ProtocolManager.send(packet) readReceiptSentForCurrentDialog = true - } catch (e: Exception) { - } + } catch (e: Exception) {} } } - + /** - * 👁️ Публичный метод для отправки read receipt (вызывается из ChatDetailScreen) - * Теперь работает как в архиве - при изменении списка сообщений + * 👁️ Публичный метод для отправки read receipt (вызывается из ChatDetailScreen) Теперь + * работает как в архиве - при изменении списка сообщений */ fun markVisibleMessagesAsRead() { // 🔥 Не читаем если диалог не активен if (!isDialogActive) { return } - + val opponent = opponentKey ?: return val account = myPublicKey ?: return - + // Находим последнее входящее сообщение val lastIncoming = _messages.value.lastOrNull { !it.isOutgoing } if (lastIncoming == null) return - + // Если timestamp не изменился - не отправляем повторно if (lastIncoming.timestamp.time <= lastReadMessageTimestamp) return - - + // Отмечаем в БД и пересчитываем счетчики viewModelScope.launch(Dispatchers.IO) { try { @@ -2691,74 +2898,67 @@ val newList = messages + optimisticMessages } else { dialogDao.updateDialogFromMessages(account, opponent) } - } catch (e: Exception) { - } + } catch (e: Exception) {} } - + // Отправляем read receipt sendReadReceiptToOpponent() } - + /** - * 🟢 Подписаться на онлайн статус собеседника - * 📁 Для Saved Messages - не подписываемся - * 🔥 Проверяем флаг чтобы не подписываться повторно + * 🟢 Подписаться на онлайн статус собеседника 📁 Для Saved Messages - не подписываемся 🔥 + * Проверяем флаг чтобы не подписываться повторно */ fun subscribeToOnlineStatus() { // 🔥 Если уже подписаны - не подписываемся повторно! if (subscribedToOnlineStatus) return - + val opponent = opponentKey ?: return val privateKey = myPrivateKey ?: return val account = myPublicKey ?: return - + // 📁 Для Saved Messages - не нужно подписываться на свой собственный статус if (account == opponent) { return } - + // 🔥 Устанавливаем флаг ДО отправки пакета subscribedToOnlineStatus = true - + viewModelScope.launch(Dispatchers.IO) { try { val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - - val packet = PacketOnlineSubscribe().apply { - this.privateKey = privateKeyHash - addPublicKey(opponent) - } - + + val packet = + PacketOnlineSubscribe().apply { + this.privateKey = privateKeyHash + addPublicKey(opponent) + } + ProtocolManager.send(packet) - } catch (e: Exception) { - } + } catch (e: Exception) {} } } - + fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending - - /** - * 🗑️ Очистка истории чата - * Удаляет все сообщения между текущим пользователем и собеседником - */ + + /** 🗑️ Очистка истории чата Удаляет все сообщения между текущим пользователем и собеседником */ fun clearChatHistory() { val account = myPublicKey ?: return val opponent = opponentKey ?: return - + viewModelScope.launch(Dispatchers.IO) { try { // Удаляем все сообщения из БД messageDao.deleteMessagesBetweenUsers(account, account, opponent) - + // Очищаем кэш val dialogKey = getDialogKey(account, opponent) dialogMessagesCache.remove(dialogKey) - + // Очищаем UI - withContext(Dispatchers.Main) { - _messages.value = emptyList() - } - + withContext(Dispatchers.Main) { _messages.value = emptyList() } + // Обновляем диалог (последнее сообщение и счётчики) val isSavedMessages = (opponent == account) if (isSavedMessages) { @@ -2766,29 +2966,26 @@ val newList = messages + optimisticMessages } else { dialogDao.updateDialogFromMessages(account, opponent) } - - } catch (e: Exception) { - } + } catch (e: Exception) {} } } - - /** - * Вспомогательная функция для конвертации base64 в Bitmap - */ + + /** Вспомогательная функция для конвертации base64 в Bitmap */ private fun base64ToBitmap(base64: String): Bitmap? { return try { - val cleanBase64 = if (base64.contains(",")) { - base64.substringAfter(",") - } else { - base64 - } + val cleanBase64 = + if (base64.contains(",")) { + base64.substringAfter(",") + } else { + base64 + } val bytes = Base64.decode(cleanBase64, Base64.DEFAULT) BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } catch (e: Exception) { null } } - + override fun onCleared() { super.onCleared() @@ -2798,7 +2995,7 @@ val newList = messages + optimisticMessages lastReadMessageTimestamp = 0L readReceiptSentForCurrentDialog = false - subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке + subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке opponentKey = null } }