package com.rosetta.messenger // commit import android.Manifest import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.* 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 import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.CallActionResult import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState 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.auth.DeviceConfirmScreen import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.ConnectionLogsScreen import com.rosetta.messenger.ui.chats.GroupInfoScreen import com.rosetta.messenger.ui.chats.GroupSetupScreen import com.rosetta.messenger.ui.chats.RequestsListScreen import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.chats.calls.CallOverlay import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect import com.rosetta.messenger.ui.components.SwipeBackContainer import com.rosetta.messenger.ui.components.SwipeBackEnterAnimation 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 import com.rosetta.messenger.ui.settings.OtherProfileScreen 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.ThemeWallpapers import com.rosetta.messenger.ui.settings.UpdatesScreen import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class MainActivity : FragmentActivity() { private lateinit var preferencesManager: PreferencesManager private lateinit var accountManager: AccountManager companion object { private const val TAG = "MainActivity" // Process-memory session cache: lets app return without password while process is alive. private var cachedDecryptedAccount: DecryptedAccount? = null // 🔔 FCM Логи для отображения в UI private val _fcmLogs = mutableStateListOf() val fcmLogs: List get() = _fcmLogs fun addFcmLog(message: String) { val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) _fcmLogs.add(0, "[$timestamp] $message") // Добавляем в начало списка // Ограничиваем количество логов if (_fcmLogs.size > 20) { _fcmLogs.removeAt(_fcmLogs.size - 1) } } fun clearFcmLogs() { _fcmLogs.clear() } private fun cacheSessionAccount(account: DecryptedAccount?) { cachedDecryptedAccount = account } private fun getCachedSessionAccount(): DecryptedAccount? = cachedDecryptedAccount private fun clearCachedSessionAccount() { cachedDecryptedAccount = null } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() preferencesManager = PreferencesManager(this) accountManager = AccountManager(this) RecentSearchesManager.init(this) // 🔥 Инициализируем ProtocolManager для обработки онлайн статусов ProtocolManager.initialize(this) CallManager.initialize(this) // 🔔 Инициализируем Firebase для push-уведомлений initializeFirebase() // 🔥 Помечаем что приложение в foreground com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true // 📱 Предзагружаем эмодзи в фоне для мгновенного открытия пикера // Используем новый оптимизированный кэш OptimizedEmojiCache.preload(this) setContent { // 🔔 Запрос разрешения на уведомления для Android 13+ val notificationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), onResult = { isGranted -> } ) // Запрашиваем разрешение при первом запуске (Android 13+) LaunchedEffect(Unit) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val hasPermission = ContextCompat.checkSelfPermission( this@MainActivity, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED if (!hasPermission) { notificationPermissionLauncher.launch( Manifest.permission.POST_NOTIFICATIONS ) } } } 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 isLoggedIn by accountManager.isLoggedIn.collectAsState(initial = null) val protocolState by ProtocolManager.state.collectAsState() var showSplash by remember { mutableStateOf(true) } var showOnboarding by remember { mutableStateOf(true) } var hasExistingAccount by remember { mutableStateOf(null) } var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) } var accountInfoList by remember { mutableStateOf>(emptyList()) } var startCreateAccountFlow by remember { mutableStateOf(false) } // Check for existing accounts and build AccountInfo list LaunchedEffect(Unit) { val accounts = accountManager.getAllAccounts() hasExistingAccount = accounts.isNotEmpty() accountInfoList = accounts.map { it.toAccountInfo() } } // Wait for initial load if (hasExistingAccount == null) { Box( modifier = Modifier.fillMaxSize() .background( if (isDarkTheme) Color(0xFF1B1B1B) else Color.White ) ) return@setContent } RosettaAndroidTheme(darkTheme = isDarkTheme, animated = true) { Surface( modifier = Modifier.fillMaxSize(), color = if (isDarkTheme) Color(0xFF1B1B1B) else Color.White ) { AnimatedContent( targetState = when { showSplash -> "splash" showOnboarding && hasExistingAccount == false -> "onboarding" isLoggedIn != true && hasExistingAccount == false -> "auth_new" isLoggedIn != true && hasExistingAccount == true -> "auth_unlock" isLoggedIn == true && currentAccount == null && hasExistingAccount == true -> "auth_unlock" protocolState == ProtocolState.DEVICE_VERIFICATION_REQUIRED -> "device_confirm" else -> "main" }, transitionSpec = { // Новый экран плавно появляется ПОВЕРХ старого. // Старый остаётся видимым (alpha=1) пока новый не готов → // нет белой вспышки от Surface. fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(1, delayMillis = 350)) }, label = "screenTransition" ) { screen -> when (screen) { "splash" -> { SplashScreen( isDarkTheme = isDarkTheme, onSplashComplete = { showSplash = false } ) } "onboarding" -> { OnboardingScreen( isDarkTheme = isDarkTheme, onThemeToggle = { scope.launch { val newMode = if (isDarkTheme) "light" else "dark" preferencesManager.setThemeMode(newMode) } }, onStartMessaging = { showOnboarding = false } ) } "auth_new", "auth_unlock" -> { AuthFlow( isDarkTheme = isDarkTheme, hasExistingAccount = screen == "auth_unlock", accounts = accountInfoList, accountManager = accountManager, startInCreateMode = startCreateAccountFlow, onAuthComplete = { account -> startCreateAccountFlow = false currentAccount = account cacheSessionAccount(account) hasExistingAccount = true // Save as last logged account account?.let { accountManager.setLastLoggedPublicKey(it.publicKey) } // Reload accounts list scope.launch { val accounts = accountManager.getAllAccounts() accountInfoList = accounts.map { it.toAccountInfo() } } }, onLogout = { startCreateAccountFlow = false // Set currentAccount to null immediately to prevent UI // lag currentAccount = null clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager .disconnect() accountManager.logout() } } ) } "main" -> { MainScreen( account = currentAccount, isDarkTheme = isDarkTheme, themeMode = themeMode, onToggleTheme = { scope.launch { val newMode = if (isDarkTheme) "light" else "dark" preferencesManager.setThemeMode(newMode) } }, onThemeModeChange = { mode -> scope.launch { preferencesManager.setThemeMode(mode) } }, onLogout = { startCreateAccountFlow = false // Set currentAccount to null immediately to prevent UI // lag currentAccount = null clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager .disconnect() accountManager.logout() } }, onDeleteAccount = { startCreateAccountFlow = false val publicKey = currentAccount?.publicKey ?: return@MainScreen scope.launch { try { val database = RosettaDatabase.getDatabase(this@MainActivity) // 1. Delete all messages database.messageDao().deleteAllByAccount(publicKey) // 2. Delete all dialogs database.dialogDao().deleteAllByAccount(publicKey) // 3. Delete blacklist database.blacklistDao().deleteAllByAccount(publicKey) // 4. Delete avatars from DB database.avatarDao().deleteAvatars(publicKey) // 5. Delete account from Room DB database.accountDao().deleteAccount(publicKey) // 6. Disconnect protocol com.rosetta.messenger.network.ProtocolManager.disconnect() // 7. Delete account from AccountManager DataStore (removes from accounts list + clears login) accountManager.deleteAccount(publicKey) // 8. Refresh accounts list val accounts = accountManager.getAllAccounts() accountInfoList = accounts.map { it.toAccountInfo() } hasExistingAccount = accounts.isNotEmpty() // 8. Navigate away last currentAccount = null clearCachedSessionAccount() } catch (e: Exception) { android.util.Log.e("DeleteAccount", "Failed to delete account", e) } } }, onAccountInfoUpdated = { // Reload account list when profile is updated val accounts = accountManager.getAllAccounts() accountInfoList = accounts.map { it.toAccountInfo() } }, onDeleteAccountFromSidebar = { targetPublicKey -> startCreateAccountFlow = false scope.launch { try { val database = RosettaDatabase.getDatabase(this@MainActivity) // 1. Delete all messages database.messageDao().deleteAllByAccount(targetPublicKey) // 2. Delete all dialogs database.dialogDao().deleteAllByAccount(targetPublicKey) // 3. Delete blacklist database.blacklistDao().deleteAllByAccount(targetPublicKey) // 4. Delete avatars from DB database.avatarDao().deleteAvatars(targetPublicKey) // 5. Delete account from Room DB database.accountDao().deleteAccount(targetPublicKey) // 6. Disconnect protocol only if deleting currently open account if (currentAccount?.publicKey == targetPublicKey) { com.rosetta.messenger.network.ProtocolManager.disconnect() } // 7. Delete account from AccountManager DataStore accountManager.deleteAccount(targetPublicKey) // 8. Refresh accounts list val accounts = accountManager.getAllAccounts() accountInfoList = accounts.map { it.toAccountInfo() } hasExistingAccount = accounts.isNotEmpty() // 9. If current account is deleted, return to main login screen if (currentAccount?.publicKey == targetPublicKey) { currentAccount = null clearCachedSessionAccount() } } catch (e: Exception) { android.util.Log.e("DeleteAccount", "Failed to delete account from sidebar", e) } } }, onSwitchAccount = { targetPublicKey -> startCreateAccountFlow = false // Save target account before leaving main screen so Unlock // screen preselects the account the user tapped. accountManager.setLastLoggedPublicKey(targetPublicKey) // Switch to another account: logout current, then show unlock. currentAccount = null clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager.disconnect() accountManager.logout() } }, onAddAccount = { startCreateAccountFlow = true currentAccount = null clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager.disconnect() accountManager.logout() } } ) } "device_confirm" -> { DeviceConfirmScreen( isDarkTheme = isDarkTheme, onExit = { currentAccount = null clearCachedSessionAccount() scope.launch { ProtocolManager.disconnect() accountManager.logout() } } ) } } } } } } } override fun onResume() { super.onResume() // 🔥 Приложение стало видимым - отключаем уведомления com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true // 🔔 Сбрасываем все уведомления из шторки при открытии приложения (getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll() // ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff. ProtocolManager.reconnectNowIfNeeded("activity_onResume") } override fun onPause() { super.onPause() // 🔥 Приложение ушло в background - включаем уведомления com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = false } /** 🔔 Инициализация Firebase Cloud Messaging */ private fun initializeFirebase() { lifecycleScope.launch(Dispatchers.Default) { try { addFcmLog("🔔 Инициализация Firebase...") // Инициализируем Firebase (тяжёлая операция — не на Main) FirebaseApp.initializeApp(this@MainActivity) addFcmLog("✅ Firebase инициализирован") // Получаем FCM токен addFcmLog("📲 Запрос FCM токена...") FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> if (!task.isSuccessful) { addFcmLog("❌ Ошибка получения токена: ${task.exception?.message}") return@addOnCompleteListener } val token = task.result if (token != null) { val shortToken = "${token.take(12)}...${token.takeLast(8)}" addFcmLog("✅ FCM токен получен: $shortToken") // Сохраняем токен локально saveFcmToken(token) addFcmLog("💾 Токен сохранен локально") if (ProtocolManager.isAuthenticated()) { runCatching { ProtocolManager.subscribePushTokenIfAvailable( forceToken = token ) } .onSuccess { addFcmLog("🔔 Push token отправлен на сервер сразу") } .onFailure { error -> addFcmLog( "❌ Ошибка отправки push token: ${error.message}" ) } } } else { addFcmLog("⚠️ Токен пустой") } } } catch (e: Exception) { addFcmLog("❌ Ошибка Firebase: ${e.message}") } } } /** Сохранить FCM токен в SharedPreferences */ private fun saveFcmToken(token: String) { val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE) prefs.edit().putString("fcm_token", token).apply() } } private fun buildInitials(displayName: String): String = displayName .trim() .split(Regex("\\s+")) .filter { it.isNotEmpty() } .let { words -> when { words.isEmpty() -> "??" words.size == 1 -> words[0].take(2).uppercase() else -> "${words[0].first()}${words[1].first()}".uppercase() } } private fun EncryptedAccount.toAccountInfo(): AccountInfo { val displayName = resolveAccountDisplayName(publicKey, name, username) return AccountInfo( id = publicKey, name = displayName, username = username ?: "", initials = buildInitials(displayName), publicKey = publicKey ) } /** * 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 ProfileFromChat : Screen() data object Requests : Screen() data object Search : Screen() data object GroupSetup : Screen() data class GroupInfo(val group: SearchUser) : 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 ConnectionLogs : Screen() data object CrashLogs : Screen() data object Biometric : Screen() data object Appearance : Screen() } @Composable fun MainScreen( account: DecryptedAccount? = null, isDarkTheme: Boolean = true, themeMode: String = "dark", onToggleTheme: () -> Unit = {}, onThemeModeChange: (String) -> Unit = {}, onLogout: () -> Unit = {}, onDeleteAccount: () -> Unit = {}, onAccountInfoUpdated: suspend () -> Unit = {}, onSwitchAccount: (String) -> Unit = {}, onDeleteAccountFromSidebar: (String) -> Unit = {}, onAddAccount: () -> Unit = {} ) { val accountPublicKey = account?.publicKey.orEmpty() // Reactive state for account name and username var accountName by remember(accountPublicKey) { mutableStateOf(resolveAccountDisplayName(accountPublicKey, account?.name, null)) } val accountPhone = account?.publicKey?.take(16)?.let { "+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}" } .orEmpty() 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 accountVerified by remember(accountPublicKey) { mutableIntStateOf(0) } var reloadTrigger by remember { mutableIntStateOf(0) } // Load username AND name from AccountManager (persisted in DataStore) val context = LocalContext.current val callScope = rememberCoroutineScope() val callUiState by CallManager.state.collectAsState() var pendingOutgoingCall by remember { mutableStateOf(null) } var pendingIncomingAccept by remember { mutableStateOf(false) } var callPermissionsRequestedOnce by remember { mutableStateOf(false) } val mandatoryCallPermissions = remember { listOf(Manifest.permission.RECORD_AUDIO) } val optionalCallPermissions = remember { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { listOf(Manifest.permission.BLUETOOTH_CONNECT) } else { emptyList() } } val permissionsToRequest = remember(mandatoryCallPermissions, optionalCallPermissions) { mandatoryCallPermissions + optionalCallPermissions } val hasMandatoryCallPermissions: () -> Boolean = remember(context, mandatoryCallPermissions) { { mandatoryCallPermissions.all { permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } } } val hasOptionalCallPermissions: () -> Boolean = remember(context, optionalCallPermissions) { { optionalCallPermissions.all { permission -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } } } val showCallError: (CallActionResult) -> Unit = { result -> val message = when (result) { CallActionResult.STARTED -> "" CallActionResult.ALREADY_IN_CALL -> "Сначала заверши текущий звонок" CallActionResult.NOT_AUTHENTICATED -> "Нет подключения к серверу" CallActionResult.ACCOUNT_NOT_BOUND -> "Аккаунт еще не инициализирован" CallActionResult.INVALID_TARGET -> "Не удалось определить пользователя для звонка" CallActionResult.NOT_INCOMING -> "Входящий звонок не найден" } if (message.isNotBlank()) { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } val resolveCallableUser: suspend (SearchUser) -> SearchUser? = resolve@{ user -> val publicKey = user.publicKey.trim() if (publicKey.isNotBlank()) { return@resolve user.copy(publicKey = publicKey) } val usernameQuery = user.username.trim().trimStart('@') if (usernameQuery.isBlank()) { return@resolve null } ProtocolManager.getCachedUserByUsername(usernameQuery)?.let { cached -> if (cached.publicKey.isNotBlank()) return@resolve cached } val results = ProtocolManager.searchUsers(usernameQuery) results.firstOrNull { it.publicKey.isNotBlank() && it.username.trim().trimStart('@') .equals(usernameQuery, ignoreCase = true) }?.let { return@resolve it } return@resolve results.firstOrNull { it.publicKey.isNotBlank() } } val startOutgoingCallSafely: (SearchUser) -> Unit = { user -> callScope.launch { val resolved = resolveCallableUser(user) if (resolved == null) { showCallError(CallActionResult.INVALID_TARGET) return@launch } val result = CallManager.startOutgoingCall(resolved) if (result != CallActionResult.STARTED) { showCallError(result) } } } val acceptIncomingCallSafely: () -> Unit = { val result = CallManager.acceptIncomingCall() if (result != CallActionResult.STARTED) { showCallError(result) } } val callPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { grantedMap -> callPermissionsRequestedOnce = true val micGranted = grantedMap[Manifest.permission.RECORD_AUDIO] == true || ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED val bluetoothGranted = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { true } else { grantedMap[Manifest.permission.BLUETOOTH_CONNECT] == true || ContextCompat.checkSelfPermission( context, Manifest.permission.BLUETOOTH_CONNECT ) == PackageManager.PERMISSION_GRANTED } if (!micGranted) { Toast.makeText( context, "Для звонков нужен доступ к микрофону", Toast.LENGTH_SHORT ).show() } else { pendingOutgoingCall?.let { startOutgoingCallSafely(it) } if (pendingIncomingAccept) { acceptIncomingCallSafely() } if (!bluetoothGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Toast.makeText( context, "Bluetooth недоступен: гарнитура может не работать", Toast.LENGTH_SHORT ).show() } } pendingOutgoingCall = null pendingIncomingAccept = false } val startCallWithPermission: (SearchUser) -> Unit = { user -> val shouldRequestPermissions = !hasMandatoryCallPermissions() || (!callPermissionsRequestedOnce && !hasOptionalCallPermissions()) if (!shouldRequestPermissions) { startOutgoingCallSafely(user) } else { pendingOutgoingCall = user callPermissionLauncher.launch(permissionsToRequest.toTypedArray()) } } val acceptCallWithPermission: () -> Unit = { val shouldRequestPermissions = !hasMandatoryCallPermissions() || (!callPermissionsRequestedOnce && !hasOptionalCallPermissions()) if (!shouldRequestPermissions) { acceptIncomingCallSafely() } else { pendingIncomingAccept = true callPermissionLauncher.launch(permissionsToRequest.toTypedArray()) } } LaunchedEffect(accountPublicKey) { CallManager.bindAccount(accountPublicKey) } LaunchedEffect(accountPublicKey, reloadTrigger) { if (accountPublicKey.isNotBlank()) { val accountManager = AccountManager(context) val encryptedAccount = accountManager.getAccount(accountPublicKey) val username = encryptedAccount?.username accountUsername = username.orEmpty() accountVerified = ProtocolManager.getCachedUserInfo(accountPublicKey)?.verified ?: 0 accountName = resolveAccountDisplayName( accountPublicKey, encryptedAccount?.name ?: accountName, username ) } else { accountVerified = 0 } } // Состояние протокола для передачи в SearchScreen val protocolState by ProtocolManager.state.collectAsState() // Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile() val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() LaunchedEffect(ownProfileUpdated) { if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) { val accountManager = AccountManager(context) val encryptedAccount = accountManager.getAccount(accountPublicKey) val username = encryptedAccount?.username accountUsername = username.orEmpty() accountVerified = ProtocolManager.getCachedUserInfo(accountPublicKey)?.verified ?: 0 accountName = resolveAccountDisplayName( accountPublicKey, encryptedAccount?.name ?: accountName, username ) } } // ═══════════════════════════════════════════════════════════ // 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 isProfileFromChatVisible by remember { derivedStateOf { navStack.any { it is Screen.ProfileFromChat } } } val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } } val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } } val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } } val chatDetailScreen by remember { derivedStateOf { navStack.filterIsInstance().lastOrNull() } } val selectedUser = chatDetailScreen?.user val groupInfoScreen by remember { derivedStateOf { navStack.filterIsInstance().lastOrNull() } } val selectedGroup = groupInfoScreen?.group 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 isConnectionLogsVisible by remember { derivedStateOf { navStack.any { it is Screen.ConnectionLogs } } } 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) { // Anti-spam: do not stack duplicate screens from rapid taps. if (navStack.lastOrNull() == screen) return if (screen is Screen.Requests && navStack.any { it is Screen.Requests }) return navStack = navStack + screen } fun isCurrentAccountUser(user: SearchUser): Boolean { val candidatePublicKey = user.publicKey.trim() val normalizedAccountPublicKey = accountPublicKey.trim() if ( candidatePublicKey.isNotBlank() && normalizedAccountPublicKey.isNotBlank() && candidatePublicKey.equals(normalizedAccountPublicKey, ignoreCase = true) ) { return true } val candidateUsername = user.username.trim().trimStart('@') val normalizedAccountUsername = accountUsername.trim().trimStart('@') return candidatePublicKey.isBlank() && candidateUsername.isNotBlank() && normalizedAccountUsername.isNotBlank() && candidateUsername.equals(normalizedAccountUsername, ignoreCase = true) } fun popScreen() { navStack = navStack.dropLast(1) } fun openOwnProfile() { val filteredStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo } // Single state update avoids intermediate frame (chat list flash/jitter) when opening // profile from a mention inside chat. navStack = if (filteredStack.lastOrNull() == Screen.Profile) { filteredStack } else { filteredStack + Screen.Profile } } 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 || it is Screen.GroupInfo } } // ProfileViewModel для логов val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel() val profileState by profileViewModel.state.collectAsState() val chatsListViewModel: com.rosetta.messenger.ui.chats.ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel() // Appearance: background blur color preference val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } val backgroundBlurColorId by prefsManager .backgroundBlurColorIdForAccount(accountPublicKey) .collectAsState(initial = "avatar") val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "") val chatWallpaperIdLight by prefsManager.chatWallpaperIdLight.collectAsState(initial = "") val chatWallpaperIdDark by prefsManager.chatWallpaperIdDark.collectAsState(initial = "") val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet()) // AvatarRepository для работы с аватарами val avatarRepository = remember(accountPublicKey) { if (accountPublicKey.isNotBlank()) { val database = RosettaDatabase.getDatabase(context) AvatarRepository( context = context, avatarDao = database.avatarDao(), currentPublicKey = accountPublicKey ) } else { null } } // Coroutine scope for profile updates val mainScreenScope = rememberCoroutineScope() LaunchedEffect(isDarkTheme, chatWallpaperId, chatWallpaperIdLight, chatWallpaperIdDark) { val targetWallpaperId = ThemeWallpapers.resolveWallpaperForTheme( currentWallpaperId = chatWallpaperId, isDarkTheme = isDarkTheme, darkThemeWallpaperId = chatWallpaperIdDark, lightThemeWallpaperId = chatWallpaperIdLight ) if (targetWallpaperId != chatWallpaperId) { prefsManager.setChatWallpaperId(targetWallpaperId) } val currentThemeStored = if (isDarkTheme) chatWallpaperIdDark else chatWallpaperIdLight if (currentThemeStored != targetWallpaperId) { prefsManager.setChatWallpaperIdForTheme( isDarkTheme = isDarkTheme, value = targetWallpaperId ) } } // 🔥 Простая навигация с swipe back Box(modifier = Modifier.fillMaxSize()) { // Base layer - chats list (всегда видимый, чтобы его было видно при свайпе) SwipeBackBackgroundEffect(modifier = Modifier.fillMaxSize(), layer = 0) { ChatsListScreen( isDarkTheme = isDarkTheme, accountName = accountName, accountUsername = accountUsername, accountVerified = accountVerified, accountPhone = accountPhone, accountPublicKey = accountPublicKey, accountPrivateKey = accountPrivateKey, privateKeyHash = privateKeyHash, onToggleTheme = onToggleTheme, onProfileClick = { pushScreen(Screen.Profile) }, onNewGroupClick = { pushScreen(Screen.GroupSetup) }, onContactsClick = { // TODO: Navigate to contacts }, onCallsClick = { // TODO: Navigate to calls }, onSavedMessagesClick = { // Открываем чат с самим собой (Saved Messages) pushScreen( Screen.ChatDetail( SearchUser( title = "Saved Messages", username = "", publicKey = accountPublicKey, verified = 0, online = 1 ) ) ) }, onSettingsClick = { pushScreen(Screen.Profile) }, onInviteFriendsClick = { // TODO: Share invite link }, onSearchClick = { pushScreen(Screen.Search) }, onRequestsClick = { pushScreen(Screen.Requests) }, onNewChat = { // TODO: Show new chat screen }, onUserSelect = { selectedChatUser -> pushScreen(Screen.ChatDetail(selectedChatUser)) }, backgroundBlurColorId = backgroundBlurColorId, pinnedChats = pinnedChats, onTogglePin = { opponentKey -> mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } }, chatsViewModel = chatsListViewModel, avatarRepository = avatarRepository, onAddAccount = { onAddAccount() }, onSwitchAccount = onSwitchAccount, onDeleteAccountFromSidebar = onDeleteAccountFromSidebar ) } SwipeBackContainer( isVisible = isRequestsVisible, onBack = { navStack = navStack.filterNot { it is Screen.Requests } }, isDarkTheme = isDarkTheme, layer = 1 ) { RequestsListScreen( isDarkTheme = isDarkTheme, chatsViewModel = chatsListViewModel, pinnedChats = pinnedChats, onTogglePin = { opponentKey -> mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } }, onBack = { navStack = navStack.filterNot { it is Screen.Requests } }, onUserSelect = { selectedRequestUser -> navStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile } + Screen.ChatDetail(selectedRequestUser) }, avatarRepository = avatarRepository ) } // ═══════════════════════════════════════════════════════════ // Profile Screen — MUST be before sub-screens so it stays // visible beneath them during swipe-back animation // ═══════════════════════════════════════════════════════════ SwipeBackContainer( isVisible = isProfileVisible, onBack = { popProfileAndChildren() }, isDarkTheme = isDarkTheme, layer = 1, propagateBackgroundProgress = false ) { // Экран профиля ProfileScreen( isDarkTheme = isDarkTheme, accountName = accountName, accountUsername = accountUsername, accountVerified = accountVerified, accountPublicKey = accountPublicKey, accountPrivateKeyHash = privateKeyHash, onBack = { popProfileAndChildren() }, onSaveProfile = { name, username -> accountName = name accountUsername = username mainScreenScope.launch { onAccountInfoUpdated() } }, onLogout = onLogout, onNavigateToTheme = { pushScreen(Screen.Theme) }, onNavigateToAppearance = { pushScreen(Screen.Appearance) }, onNavigateToSafety = { pushScreen(Screen.Safety) }, onNavigateToLogs = { pushScreen(Screen.Logs) }, onNavigateToBiometric = { pushScreen(Screen.Biometric) }, viewModel = profileViewModel, avatarRepository = avatarRepository, dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), backgroundBlurColorId = backgroundBlurColorId ) } // Other screens with swipe back SwipeBackContainer( isVisible = isSafetyVisible, onBack = { navStack = navStack.filterNot { it is Screen.Safety } }, isDarkTheme = isDarkTheme, layer = 2 ) { SafetyScreen( isDarkTheme = isDarkTheme, accountPublicKey = accountPublicKey, accountPrivateKey = accountPrivateKey, onBack = { navStack = navStack.filterNot { it is Screen.Safety } }, onBackupClick = { navStack = navStack + Screen.Backup }, onDeleteAccount = onDeleteAccount ) } SwipeBackContainer( isVisible = isBackupVisible, onBack = { navStack = navStack.filterNot { it is Screen.Backup } }, isDarkTheme = isDarkTheme, layer = 3 ) { BackupScreen( isDarkTheme = isDarkTheme, onBack = { navStack = navStack.filterNot { it is Screen.Backup } }, 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 (decryptedPrivateKey != null) { // Password is correct, decrypt seed phrase com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword( encryptedAccount.encryptedSeedPhrase, password ) } else { null } } else { null } } catch (e: Exception) { null } } ) } SwipeBackContainer( isVisible = isThemeVisible, onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, isDarkTheme = isDarkTheme, layer = 2, deferToChildren = true ) { ThemeScreen( isDarkTheme = isDarkTheme, currentThemeMode = themeMode, currentWallpaperId = chatWallpaperId, onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, onThemeModeChange = onThemeModeChange, onWallpaperChange = { wallpaperId -> mainScreenScope.launch { prefsManager.setChatWallpaperIdForTheme( isDarkTheme = isDarkTheme, value = wallpaperId ) prefsManager.setChatWallpaperId(wallpaperId) } } ) } SwipeBackContainer( isVisible = isAppearanceVisible, onBack = { navStack = navStack.filterNot { it is Screen.Appearance } }, isDarkTheme = isDarkTheme, layer = 2 ) { com.rosetta.messenger.ui.settings.AppearanceScreen( isDarkTheme = isDarkTheme, currentBlurColorId = backgroundBlurColorId, onBack = { navStack = navStack.filterNot { it is Screen.Appearance } }, onBlurColorChange = { newId -> mainScreenScope.launch { prefsManager.setBackgroundBlurColorId(accountPublicKey, newId) } }, onToggleTheme = onToggleTheme, accountPublicKey = accountPublicKey, accountName = accountName, avatarRepository = avatarRepository ) } SwipeBackContainer( isVisible = isUpdatesVisible, onBack = { navStack = navStack.filterNot { it is Screen.Updates } }, isDarkTheme = isDarkTheme, layer = 2 ) { UpdatesScreen( isDarkTheme = isDarkTheme, onBack = { navStack = navStack.filterNot { it is Screen.Updates } } ) } // 🔒 Lock swipe-back while chat overlays are open (image viewer/editor/media picker/camera). var isChatSwipeLocked by remember { mutableStateOf(false) } // 🔴 Badge: total unread from OTHER chats (excluding current) for back-chevron badge val totalUnreadFromOthers by remember(accountPublicKey, selectedUser?.publicKey) { val db = RosettaDatabase.getDatabase(context) val opponentKey = selectedUser?.publicKey ?: "" if (accountPublicKey.isNotBlank() && opponentKey.isNotBlank()) { db.dialogDao().getTotalUnreadCountExcludingFlow(accountPublicKey, opponentKey) } else { kotlinx.coroutines.flow.flowOf(0) } }.collectAsState(initial = 0) SwipeBackContainer( isVisible = selectedUser != null, onBack = { popChatAndChildren() }, isDarkTheme = isDarkTheme, layer = 1, swipeEnabled = !isChatSwipeLocked, enterAnimation = SwipeBackEnterAnimation.SlideFromRight, propagateBackgroundProgress = false ) { selectedUser?.let { currentChatUser -> // Экран чата ChatDetailScreen( user = currentChatUser, currentUserPublicKey = accountPublicKey, currentUserPrivateKey = accountPrivateKey, currentUserName = accountName, currentUserUsername = accountUsername, totalUnreadFromOthers = totalUnreadFromOthers, onBack = { popChatAndChildren() }, onCallClick = { callableUser -> startCallWithPermission(callableUser) }, onUserProfileClick = { user -> if (isCurrentAccountUser(user)) { // Свой профиль из чата открываем поверх текущего чата, // чтобы возврат оставался в этот чат, а не в chat list. pushScreen(Screen.ProfileFromChat) } else { // Открываем профиль другого пользователя pushScreen(Screen.OtherProfile(user)) } }, onGroupInfoClick = { groupUser -> pushScreen(Screen.GroupInfo(groupUser)) }, onNavigateToChat = { forwardUser -> // 📨 Forward: переход в выбранный чат с полными данными navStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile } + Screen.ChatDetail(forwardUser) }, isDarkTheme = isDarkTheme, chatWallpaperId = chatWallpaperId, avatarRepository = avatarRepository, onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked } ) } } SwipeBackContainer( isVisible = isProfileFromChatVisible, onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, isDarkTheme = isDarkTheme, layer = 1, propagateBackgroundProgress = false ) { ProfileScreen( isDarkTheme = isDarkTheme, accountName = accountName, accountUsername = accountUsername, accountVerified = accountVerified, accountPublicKey = accountPublicKey, accountPrivateKeyHash = privateKeyHash, onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, onSaveProfile = { name, username -> accountName = name accountUsername = username mainScreenScope.launch { onAccountInfoUpdated() } }, onLogout = onLogout, onNavigateToTheme = { pushScreen(Screen.Theme) }, onNavigateToAppearance = { pushScreen(Screen.Appearance) }, onNavigateToSafety = { pushScreen(Screen.Safety) }, onNavigateToLogs = { pushScreen(Screen.Logs) }, onNavigateToBiometric = { pushScreen(Screen.Biometric) }, viewModel = profileViewModel, avatarRepository = avatarRepository, dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), backgroundBlurColorId = backgroundBlurColorId ) } var isGroupInfoSwipeEnabled by remember { mutableStateOf(true) } LaunchedEffect(selectedGroup?.publicKey) { isGroupInfoSwipeEnabled = true } SwipeBackContainer( isVisible = selectedGroup != null, onBack = { navStack = navStack.filterNot { it is Screen.GroupInfo } }, isDarkTheme = isDarkTheme, layer = 2, swipeEnabled = isGroupInfoSwipeEnabled, propagateBackgroundProgress = false ) { selectedGroup?.let { groupUser -> GroupInfoScreen( groupUser = groupUser, currentUserPublicKey = accountPublicKey, currentUserPrivateKey = accountPrivateKey, isDarkTheme = isDarkTheme, avatarRepository = avatarRepository, onBack = { navStack = navStack.filterNot { it is Screen.GroupInfo } }, onMemberClick = { member -> if (isCurrentAccountUser(member)) { openOwnProfile() } else { pushScreen(Screen.OtherProfile(member)) } }, onSwipeBackEnabledChanged = { enabled -> isGroupInfoSwipeEnabled = enabled }, onGroupLeft = { navStack = navStack.filterNot { it is Screen.GroupInfo || (it is Screen.ChatDetail && it.user.publicKey == groupUser.publicKey) } } ) } } SwipeBackContainer( isVisible = isSearchVisible, onBack = { navStack = navStack.filterNot { it is Screen.Search } }, isDarkTheme = isDarkTheme, layer = 1, deferToChildren = true ) { // Экран поиска SearchScreen( privateKeyHash = privateKeyHash, currentUserPublicKey = accountPublicKey, isDarkTheme = isDarkTheme, protocolState = protocolState, onBackClick = { navStack = navStack.filterNot { it is Screen.Search } }, onUserSelect = { selectedSearchUser -> navStack = navStack.filterNot { it is Screen.Search } + Screen.ChatDetail(selectedSearchUser) }, onNavigateToCrashLogs = { navStack = navStack.filterNot { it is Screen.Search } + Screen.CrashLogs } ) } SwipeBackContainer( isVisible = isGroupSetupVisible, onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } }, isDarkTheme = isDarkTheme, layer = 1 ) { GroupSetupScreen( isDarkTheme = isDarkTheme, accountPublicKey = accountPublicKey, accountPrivateKey = accountPrivateKey, accountName = accountName, accountUsername = accountUsername, avatarRepository = avatarRepository, onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } }, onGroupOpened = { groupUser -> navStack = navStack.filterNot { it is Screen.GroupSetup } + Screen.ChatDetail(groupUser) } ) } SwipeBackContainer( isVisible = isLogsVisible, onBack = { navStack = navStack.filterNot { it is Screen.Logs } }, isDarkTheme = isDarkTheme, layer = 2 ) { com.rosetta.messenger.ui.settings.ProfileLogsScreen( isDarkTheme = isDarkTheme, logs = profileState.logs, onBack = { navStack = navStack.filterNot { it is Screen.Logs } }, onClearLogs = { profileViewModel.clearLogs() } ) } SwipeBackContainer( isVisible = isCrashLogsVisible, onBack = { navStack = navStack.filterNot { it is Screen.CrashLogs } }, isDarkTheme = isDarkTheme, layer = 1 ) { CrashLogsScreen( onBackClick = { navStack = navStack.filterNot { it is Screen.CrashLogs } } ) } SwipeBackContainer( isVisible = isConnectionLogsVisible, onBack = { navStack = navStack.filterNot { it is Screen.ConnectionLogs } }, isDarkTheme = isDarkTheme, layer = 1 ) { ConnectionLogsScreen( isDarkTheme = isDarkTheme, onBack = { navStack = navStack.filterNot { it is Screen.ConnectionLogs } } ) } var isOtherProfileSwipeEnabled by remember { mutableStateOf(true) } LaunchedEffect(selectedOtherUser?.publicKey) { isOtherProfileSwipeEnabled = true } SwipeBackContainer( isVisible = selectedOtherUser != null, onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, isDarkTheme = isDarkTheme, layer = 2, swipeEnabled = isOtherProfileSwipeEnabled, propagateBackgroundProgress = false ) { selectedOtherUser?.let { currentOtherUser -> OtherProfileScreen( user = currentOtherUser, isDarkTheme = isDarkTheme, onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, onSwipeBackEnabledChanged = { enabled -> isOtherProfileSwipeEnabled = enabled }, avatarRepository = avatarRepository, currentUserPublicKey = accountPublicKey, currentUserPrivateKey = accountPrivateKey, onWriteMessage = { chatUser -> // Close profile and navigate to chat navStack = navStack.filterNot { it is Screen.OtherProfile || it is Screen.ChatDetail } + Screen.ChatDetail(chatUser) } ) } } // Biometric Enable Screen SwipeBackContainer( isVisible = isBiometricVisible, onBack = { navStack = navStack.filterNot { it is Screen.Biometric } }, isDarkTheme = isDarkTheme, layer = 2 ) { val biometricManager = remember { com.rosetta.messenger.biometric.BiometricAuthManager(context) } val biometricPrefs = remember { com.rosetta.messenger.biometric.BiometricPreferences(context) } val biometricAccountManager = remember { AccountManager(context) } val activity = context as? FragmentActivity val isFingerprintSupported = remember { biometricManager.isFingerprintHardwareAvailable() } if (!isFingerprintSupported) { LaunchedEffect(Unit) { navStack = navStack.filterNot { it is Screen.Biometric } } return@SwipeBackContainer } BiometricEnableScreen( isDarkTheme = isDarkTheme, onBack = { navStack = navStack.filterNot { it is Screen.Biometric } }, onEnable = { password, onSuccess, onError -> if (activity == null) { onError("Activity not available") return@BiometricEnableScreen } // Verify password against the real account before saving mainScreenScope.launch { val account = biometricAccountManager.getAccount(accountPublicKey) if (account == null) { onError("Account not found") return@launch } val decryptedKey = try { CryptoManager.decryptWithPassword(account.encryptedPrivateKey, password) } catch (_: Exception) { null } if (decryptedKey == null) { onError("Incorrect password") return@launch } 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 } } ) } } ) } CallOverlay( state = callUiState, isDarkTheme = isDarkTheme, onAccept = { acceptCallWithPermission() }, onDecline = { CallManager.declineIncomingCall() }, onEnd = { CallManager.endCall() }, onToggleMute = { CallManager.toggleMute() }, onToggleSpeaker = { CallManager.toggleSpeaker() } ) } }