Desktop теперь шифрует аттачменты в группах hex-версией ключа. Android пробует raw key, при неудаче — hex key. Фикс в 3 местах: processDownloadedImage, downloadAndDecryptImage, loadBitmapForViewerImage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1943 lines
94 KiB
Kotlin
1943 lines
94 KiB
Kotlin
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)
|
||
)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|