Files
mobile-android/app/src/main/java/com/rosetta/messenger/MainActivity.kt
k1ngsterr1 30327fade2 Фикс: расшифровка групповых фото — fallback на hex group key (Desktop v1.2.1 parity)
Desktop теперь шифрует аттачменты в группах hex-версией ключа.
Android пробует raw key, при неудаче — hex key. Фикс в 3 местах:
processDownloadedImage, downloadAndDecryptImage, loadBitmapForViewerImage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:20:46 +05:00

1943 lines
94 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>()
val fcmLogs: List<String>
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)
com.rosetta.messenger.ui.chats.components.AttachmentDownloadDebugLogger.init(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<Boolean?>(null) }
var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) }
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) }
var startCreateAccountFlow by remember { mutableStateOf(false) }
var preservedMainNavStack by remember { mutableStateOf<List<Screen>>(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<Screen> = emptyList(),
onNavStackChanged: (List<Screen>) -> 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<SearchUser?>(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<Screen.ChatDetail>().lastOrNull() }
}
val selectedUser = chatDetailScreen?.user
val groupInfoScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.GroupInfo>().lastOrNull() }
}
val selectedGroup = groupInfoScreen?.group
val otherProfileScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.OtherProfile>().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)
},
onToggleTheme = onToggleTheme
)
}
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)
)
}
}
)
}
}
}