package com.rosetta.messenger // fix import android.Manifest import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.WindowManager 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.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.AlertDialog import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat 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.CallPhase 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.NotificationsScreen 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 // Флаг: Activity открыта для ответа на звонок с lock screen — пропускаем auth // mutableStateOf чтобы Compose реагировал на изменение (избежать race condition) private var openedForCall by mutableStateOf(false) 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() handleCallLockScreen(intent) 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 ) } } // Android 14+: запрос fullScreenIntent для входящих звонков if (Build.VERSION.SDK_INT >= 34) { val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager if (!nm.canUseFullScreenIntent()) { try { startActivity( android.content.Intent( android.provider.Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, android.net.Uri.parse("package:$packageName") ) ) } catch (_: Throwable) {} } } } 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) } var preservedMainNavStack by remember { mutableStateOf>(emptyList()) } var preservedMainNavAccountKey by remember { mutableStateOf("") } // 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" // При открытии по звонку с lock screen — пропускаем auth openedForCall && hasExistingAccount == true -> "main" 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 preservedMainNavStack = emptyList() preservedMainNavAccountKey = "" // Set currentAccount to null immediately to prevent UI // lag currentAccount = null clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager .disconnect() accountManager.logout() } } ) } "main" -> { val activeAccountKey = currentAccount?.publicKey.orEmpty() MainScreen( account = currentAccount, initialNavStack = if ( activeAccountKey.isNotBlank() && preservedMainNavAccountKey.equals( activeAccountKey, ignoreCase = true ) ) { preservedMainNavStack } else { emptyList() }, onNavStackChanged = { stack -> if (activeAccountKey.isNotBlank()) { preservedMainNavAccountKey = activeAccountKey preservedMainNavStack = stack } }, 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 preservedMainNavStack = emptyList() preservedMainNavAccountKey = "" // 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 preservedMainNavStack = emptyList() preservedMainNavAccountKey = "" 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) { preservedMainNavStack = emptyList() preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() } } catch (e: Exception) { android.util.Log.e("DeleteAccount", "Failed to delete account from sidebar", e) } } }, onSwitchAccount = { targetPublicKey -> startCreateAccountFlow = false preservedMainNavStack = emptyList() preservedMainNavAccountKey = "" // 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 preservedMainNavStack = emptyList() preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() scope.launch { com.rosetta.messenger.network.ProtocolManager.disconnect() accountManager.logout() } } ) } "device_confirm" -> { DeviceConfirmScreen( isDarkTheme = isDarkTheme, onExit = { preservedMainNavStack = emptyList() preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() scope.launch { ProtocolManager.disconnect() accountManager.logout() } } ) } } } } } } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleCallLockScreen(intent) } private var callIntentResetJob: kotlinx.coroutines.Job? = null /** * Обрабатывает переход из call-notification. * На lockscreen НЕ поднимаем MainActivity поверх keyguard и НЕ снимаем блокировку. */ private fun handleCallLockScreen(intent: Intent?) { val isCallIntent = intent?.getBooleanExtra( com.rosetta.messenger.network.CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, false ) == true if (isCallIntent) { val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? android.app.KeyguardManager val isDeviceLocked = keyguardManager?.isDeviceLocked == true // Если экран заблокирован — не обходим auth и не показываем MainActivity поверх keyguard. openedForCall = !isDeviceLocked if (openedForCall) { callIntentResetJob?.cancel() callIntentResetJob = lifecycleScope.launch { com.rosetta.messenger.network.CallManager.state.collect { state -> if (state.phase == com.rosetta.messenger.network.CallPhase.IDLE) { openedForCall = false callIntentResetJob?.cancel() callIntentResetJob = null } } } } // На всякий случай принудительно чистим lock-screen флаги. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { setShowWhenLocked(false) setTurnScreenOn(false) } else { @Suppress("DEPRECATION") window.clearFlags( android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or android.view.WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD ) } } } 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 Notifications : 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() data object QrScanner : Screen() data object MyQr : Screen() } @Composable fun MainScreen( account: DecryptedAccount? = null, initialNavStack: List = emptyList(), onNavStackChanged: (List) -> Unit = {}, 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 focusManager = LocalFocusManager.current val rootView = LocalView.current val callScope = rememberCoroutineScope() val callUiState by CallManager.state.collectAsState() var isCallOverlayExpanded by remember { mutableStateOf(true) } var pendingOutgoingCall by remember { mutableStateOf(null) } var pendingIncomingAccept by remember { mutableStateOf(false) } var callPermissionsRequestedOnce by remember { mutableStateOf(false) } val isCallScreenVisible = callUiState.isVisible && (isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING) 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(callUiState.isVisible) { if (callUiState.isVisible) { isCallOverlayExpanded = true } else { isCallOverlayExpanded = false } } val forceHideCallKeyboard: () -> Unit = { focusManager.clearFocus(force = true) rootView.findFocus()?.clearFocus() rootView.clearFocus() val activity = rootView.context as? android.app.Activity activity?.window?.let { window -> WindowCompat.getInsetsController(window, rootView) .hide(WindowInsetsCompat.Type.ime()) } } DisposableEffect(isCallScreenVisible) { val activity = rootView.context as? android.app.Activity val previousSoftInputMode = activity?.window?.attributes?.softInputMode if (isCallScreenVisible) { forceHideCallKeyboard() activity?.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN ) } onDispose { if (isCallScreenVisible && previousSoftInputMode != null) { activity.window.setSoftInputMode(previousSoftInputMode) } } } if (isCallScreenVisible) { SideEffect { forceHideCallKeyboard() } } // Telegram-style behavior: while call screen is open, Back should minimize call to top banner. BackHandler( enabled = callUiState.isVisible && isCallOverlayExpanded && callUiState.phase != CallPhase.INCOMING ) { isCallOverlayExpanded = false } 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(accountPublicKey) { mutableStateOf(initialNavStack) } LaunchedEffect(navStack) { onNavStackChanged(navStack) } // 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 isNotificationsVisible by remember { derivedStateOf { navStack.any { it is Screen.Notifications } } } 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 } } } val isQrScannerVisible by remember { derivedStateOf { navStack.any { it is Screen.QrScanner } } } val isMyQrVisible by remember { derivedStateOf { navStack.any { it is Screen.MyQr } } } var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) } var showDiscardProfileChangesDialog by remember { mutableStateOf(false) } var discardProfileChangesFromChat by remember { mutableStateOf(false) } // 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 // Hide keyboard and clear focus when navigating to any screen (context as? android.app.Activity)?.currentFocus?.let { focusedView -> val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager imm.hideSoftInputFromWindow(focusedView.windowToken, 0) focusedView.clearFocus() } 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.Notifications || 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 performProfileBack(fromChat: Boolean) { if (fromChat) { navStack = navStack.filterNot { it is Screen.ProfileFromChat } } else { popProfileAndChildren() } } fun requestProfileBack(fromChat: Boolean) { if (profileHasUnsavedChanges) { discardProfileChangesFromChat = fromChat showDiscardProfileChangesDialog = true } else { performProfileBack(fromChat) } } fun popChatAndChildren() { navStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo } } LaunchedEffect(isProfileVisible, isProfileFromChatVisible) { if (!isProfileVisible && !isProfileFromChatVisible) { profileHasUnsavedChanges = false showDiscardProfileChangesDialog = false } } // 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, accountPublicKey = accountPublicKey, accountPrivateKey = accountPrivateKey, onToggleTheme = onToggleTheme, onProfileClick = { pushScreen(Screen.Profile) }, onNewGroupClick = { pushScreen(Screen.GroupSetup) }, 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) }, onSearchClick = { pushScreen(Screen.Search) }, onRequestsClick = { pushScreen(Screen.Requests) }, onNewChat = { // TODO: Show new chat screen }, onUserSelect = { selectedChatUser -> pushScreen(Screen.ChatDetail(selectedChatUser)) }, onStartCall = { user -> startCallWithPermission(user) }, pinnedChats = pinnedChats, onTogglePin = { opponentKey -> mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } }, chatsViewModel = chatsListViewModel, avatarRepository = avatarRepository, callUiState = callUiState, isCallOverlayExpanded = isCallOverlayExpanded, onOpenCallOverlay = { isCallOverlayExpanded = true }, onAddAccount = { onAddAccount() }, onSwitchAccount = onSwitchAccount, onDeleteAccountFromSidebar = onDeleteAccountFromSidebar, onQrScanClick = { pushScreen(Screen.QrScanner) }, onMyQrClick = { pushScreen(Screen.MyQr) } ) } 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 = { requestProfileBack(fromChat = false) }, isDarkTheme = isDarkTheme, layer = 1, swipeEnabled = !profileHasUnsavedChanges, propagateBackgroundProgress = false ) { // Экран профиля ProfileScreen( isDarkTheme = isDarkTheme, accountName = accountName, accountUsername = accountUsername, accountVerified = accountVerified, accountPublicKey = accountPublicKey, accountPrivateKeyHash = privateKeyHash, onBack = { requestProfileBack(fromChat = false) }, onHasChangesChanged = { profileHasUnsavedChanges = it }, onSaveProfile = { name, username -> accountName = name accountUsername = username mainScreenScope.launch { onAccountInfoUpdated() } }, onLogout = onLogout, onNavigateToNotifications = { pushScreen(Screen.Notifications) }, onNavigateToTheme = { pushScreen(Screen.Theme) }, onNavigateToAppearance = { pushScreen(Screen.Appearance) }, onNavigateToSafety = { pushScreen(Screen.Safety) }, onNavigateToLogs = { pushScreen(Screen.Logs) }, onNavigateToBiometric = { pushScreen(Screen.Biometric) }, onNavigateToMyQr = { pushScreen(Screen.MyQr) }, viewModel = profileViewModel, avatarRepository = avatarRepository, dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), backgroundBlurColorId = backgroundBlurColorId ) } // Other screens with swipe back SwipeBackContainer( isVisible = isNotificationsVisible, onBack = { navStack = navStack.filterNot { it is Screen.Notifications } }, isDarkTheme = isDarkTheme, layer = 2 ) { NotificationsScreen( isDarkTheme = isDarkTheme, onBack = { navStack = navStack.filterNot { it is Screen.Notifications } } ) } 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 }, isCallActive = callUiState.isVisible, onOpenCallOverlay = { isCallOverlayExpanded = true } ) } } SwipeBackContainer( isVisible = isProfileFromChatVisible, onBack = { requestProfileBack(fromChat = true) }, isDarkTheme = isDarkTheme, layer = 1, swipeEnabled = !profileHasUnsavedChanges, propagateBackgroundProgress = false ) { ProfileScreen( isDarkTheme = isDarkTheme, accountName = accountName, accountUsername = accountUsername, accountVerified = accountVerified, accountPublicKey = accountPublicKey, accountPrivateKeyHash = privateKeyHash, onBack = { requestProfileBack(fromChat = true) }, onHasChangesChanged = { profileHasUnsavedChanges = it }, onSaveProfile = { name, username -> accountName = name accountUsername = username mainScreenScope.launch { onAccountInfoUpdated() } }, onLogout = onLogout, onNavigateToNotifications = { pushScreen(Screen.Notifications) }, onNavigateToTheme = { pushScreen(Screen.Theme) }, onNavigateToAppearance = { pushScreen(Screen.Appearance) }, onNavigateToSafety = { pushScreen(Screen.Safety) }, onNavigateToLogs = { pushScreen(Screen.Logs) }, onNavigateToBiometric = { pushScreen(Screen.Biometric) }, onNavigateToMyQr = { pushScreen(Screen.MyQr) }, 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 ) { // Экран поиска 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 }, onNavigateToConnectionLogs = { navStack = navStack.filterNot { it is Screen.Search } + Screen.ConnectionLogs } ) } 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, dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), 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) }, onCall = { callableUser -> startCallWithPermission(callableUser) } ) } } // 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(accountPublicKey) onSuccess() } }, onError = { error -> onError(error) }, onCancel = { navStack = navStack.filterNot { it is Screen.Biometric } } ) } } ) } // QR Scanner SwipeBackContainer( isVisible = isQrScannerVisible, onBack = { navStack = navStack.filterNot { it is Screen.QrScanner } }, isDarkTheme = isDarkTheme, layer = 3 ) { com.rosetta.messenger.ui.qr.QrScannerScreen( onBack = { navStack = navStack.filterNot { it is Screen.QrScanner } }, onResult = { result -> navStack = navStack.filterNot { it is Screen.QrScanner } when (result.type) { com.rosetta.messenger.ui.qr.QrResultType.PROFILE -> { mainScreenScope.launch { val users = com.rosetta.messenger.network.ProtocolManager.searchUsers(result.payload, 5000) val user = users.firstOrNull() if (user != null) { pushScreen(Screen.OtherProfile(user)) } else { val searchUser = com.rosetta.messenger.network.SearchUser( publicKey = result.payload, title = "", username = "", verified = 0, online = 0 ) pushScreen(Screen.ChatDetail(searchUser)) } } } com.rosetta.messenger.ui.qr.QrResultType.GROUP -> { mainScreenScope.launch { val groupRepo = com.rosetta.messenger.data.GroupRepository.getInstance(context) val joinResult = groupRepo.joinGroup(accountPublicKey, accountPrivateKey, result.payload) if (joinResult.success && !joinResult.dialogPublicKey.isNullOrBlank()) { val groupUser = com.rosetta.messenger.network.SearchUser( publicKey = joinResult.dialogPublicKey, title = joinResult.title.ifBlank { "Group" }, username = "", verified = 0, online = 0 ) pushScreen(Screen.ChatDetail(groupUser)) } } } else -> {} } } ) } // My QR Code SwipeBackContainer( isVisible = isMyQrVisible, onBack = { navStack = navStack.filterNot { it is Screen.MyQr } }, isDarkTheme = isDarkTheme, layer = 2 ) { com.rosetta.messenger.ui.qr.MyQrCodeScreen( isDarkTheme = isDarkTheme, publicKey = accountPublicKey, displayName = accountName, username = accountUsername, avatarRepository = avatarRepository, onBack = { navStack = navStack.filterNot { it is Screen.MyQr } }, onScanQr = { navStack = navStack.filterNot { it is Screen.MyQr } pushScreen(Screen.QrScanner) } ) } if (isCallScreenVisible) { // Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay. // Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.). val blockerInteraction = remember { MutableInteractionSource() } Box( modifier = Modifier .fillMaxSize() .clickable( interactionSource = blockerInteraction, indication = null, onClick = {} ) ) } CallOverlay( state = callUiState, isDarkTheme = isDarkTheme, avatarRepository = avatarRepository, isExpanded = isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING, onAccept = { acceptCallWithPermission() }, onDecline = { CallManager.declineIncomingCall() }, onEnd = { CallManager.endCall() }, onToggleMute = { CallManager.toggleMute() }, onToggleSpeaker = { CallManager.toggleSpeaker() }, onMinimize = { if (callUiState.phase != CallPhase.INCOMING) { isCallOverlayExpanded = false } } ) if (showDiscardProfileChangesDialog) { AlertDialog( onDismissRequest = { showDiscardProfileChangesDialog = false }, containerColor = if (isDarkTheme) Color(0xFF1E1E20) else Color.White, title = { Text( text = "Discard changes?", color = if (isDarkTheme) Color.White else Color.Black ) }, text = { Text( text = "You have unsaved profile changes. If you leave now, they will be lost.", color = if (isDarkTheme) Color(0xFFB5B5BC) else Color(0xFF5A5A66) ) }, confirmButton = { TextButton( onClick = { showDiscardProfileChangesDialog = false profileHasUnsavedChanges = false performProfileBack(discardProfileChangesFromChat) } ) { Text("Discard", color = Color(0xFFFF3B30)) } }, dismissButton = { TextButton(onClick = { showDiscardProfileChangesDialog = false }) { Text( "Stay", color = if (isDarkTheme) Color(0xFF5FA8FF) else Color(0xFF0D8CF4) ) } } ) } } }