Files
mobile-android/app/src/main/java/com/rosetta/messenger/MainActivity.kt

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