fix: add detailed logging for unlock process to improve debugging and performance tracking

This commit is contained in:
2026-02-05 01:56:18 +05:00
parent e307e8d35d
commit 4dc7c6e9bc
13 changed files with 640 additions and 169 deletions

View File

@@ -98,6 +98,9 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
// Jsoup for HTML parsing (Link Preview OG tags)
implementation("org.jsoup:jsoup:1.17.2")
// uCrop for image cropping // uCrop for image cropping
implementation("com.github.yalantis:ucrop:2.2.8") implementation("com.github.yalantis:ucrop:2.2.8")

View File

@@ -43,6 +43,7 @@ import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.OptimizedEmojiCache
import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.settings.BackupScreen 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.OtherProfileScreen
import com.rosetta.messenger.ui.settings.ProfileScreen import com.rosetta.messenger.ui.settings.ProfileScreen
import com.rosetta.messenger.ui.settings.SafetyScreen import com.rosetta.messenger.ui.settings.SafetyScreen
@@ -527,6 +528,7 @@ fun MainScreen(
var showBackupScreen by remember { mutableStateOf(false) } var showBackupScreen by remember { mutableStateOf(false) }
var showLogsScreen by remember { mutableStateOf(false) } var showLogsScreen by remember { mutableStateOf(false) }
var showCrashLogsScreen by remember { mutableStateOf(false) } var showCrashLogsScreen by remember { mutableStateOf(false) }
var showBiometricScreen by remember { mutableStateOf(false) }
// ProfileViewModel для логов // ProfileViewModel для логов
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel() val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
@@ -555,7 +557,8 @@ fun MainScreen(
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen && visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen &&
!showUpdatesScreen && selectedUser == null && !showSearchScreen && !showUpdatesScreen && selectedUser == null && !showSearchScreen &&
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen && !showCrashLogsScreen, !showProfileScreen && !showOtherProfileScreen && !showLogsScreen &&
!showCrashLogsScreen && !showBiometricScreen,
enter = fadeIn(animationSpec = tween(300)), enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200)) exit = fadeOut(animationSpec = tween(200))
) { ) {
@@ -799,6 +802,10 @@ fun MainScreen(
showProfileScreen = false showProfileScreen = false
showCrashLogsScreen = true showCrashLogsScreen = true
}, },
onNavigateToBiometric = {
showProfileScreen = false
showBiometricScreen = true
},
viewModel = profileViewModel, viewModel = profileViewModel,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao() dialogDao = RosettaDatabase.getDatabase(context).dialogDao()
@@ -858,5 +865,49 @@ fun MainScreen(
) )
} }
} }
// Biometric Enable Screen
androidx.compose.animation.AnimatedVisibility(
visible = showBiometricScreen,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200))
) {
if (showBiometricScreen) {
val biometricManager = remember { com.rosetta.messenger.biometric.BiometricAuthManager(context) }
val biometricPrefs = remember { com.rosetta.messenger.biometric.BiometricPreferences(context) }
val activity = context as? FragmentActivity
BiometricEnableScreen(
isDarkTheme = isDarkTheme,
onBack = {
showBiometricScreen = false
showProfileScreen = true
},
onEnable = { password, onSuccess, onError ->
if (activity == null) {
onError("Activity not available")
return@BiometricEnableScreen
}
biometricManager.encryptPassword(
activity = activity,
password = password,
onSuccess = { encryptedPassword ->
mainScreenScope.launch {
biometricPrefs.saveEncryptedPassword(accountPublicKey, encryptedPassword)
biometricPrefs.enableBiometric()
onSuccess()
}
},
onError = { error -> onError(error) },
onCancel = {
showBiometricScreen = false
showProfileScreen = true
}
)
}
)
}
}
} }
} }

View File

@@ -106,17 +106,25 @@ class MessageRepository private constructor(private val context: Context) {
* Инициализация с текущим аккаунтом * Инициализация с текущим аккаунтом
*/ */
fun initialize(publicKey: String, privateKey: String) { fun initialize(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis()
android.util.Log.d("MessageRepository", "🔧 initialize started for ${publicKey.take(8)}...")
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта // 🔥 Очищаем кэш запрошенных user info при смене аккаунта
if (currentAccount != publicKey) { if (currentAccount != publicKey) {
requestedUserInfoKeys.clear() requestedUserInfoKeys.clear()
android.util.Log.d("MessageRepository", "🔧 Cleared user info cache (account changed)")
} }
currentAccount = publicKey currentAccount = publicKey
currentPrivateKey = privateKey currentPrivateKey = privateKey
android.util.Log.d("MessageRepository", "🔧 initialize completed in ${System.currentTimeMillis() - start}ms (launching dialogs flow in background)")
// Загрузка диалогов // Загрузка диалогов
scope.launch { scope.launch {
android.util.Log.d("MessageRepository", "📂 Starting dialogs flow collection...")
dialogDao.getDialogsFlow(publicKey).collect { entities -> dialogDao.getDialogsFlow(publicKey).collect { entities ->
android.util.Log.d("MessageRepository", "📂 Got ${entities.size} dialogs from DB")
_dialogs.value = entities.map { it.toDialog() } _dialogs.value = entities.map { it.toDialog() }
// 🔥 Запрашиваем информацию о пользователях, у которых нет имени // 🔥 Запрашиваем информацию о пользователях, у которых нет имени

View File

@@ -80,7 +80,10 @@ object ProtocolManager {
* Должен вызываться после авторизации пользователя * Должен вызываться после авторизации пользователя
*/ */
fun initializeAccount(publicKey: String, privateKey: String) { fun initializeAccount(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis()
android.util.Log.d("ProtocolManager", "🔧 initializeAccount started")
messageRepository?.initialize(publicKey, privateKey) messageRepository?.initialize(publicKey, privateKey)
android.util.Log.d("ProtocolManager", "🔧 initializeAccount completed in ${System.currentTimeMillis() - start}ms")
} }
/** /**

View File

@@ -73,34 +73,53 @@ private suspend fun performUnlock(
onError: (String) -> Unit, onError: (String) -> Unit,
onSuccess: (DecryptedAccount) -> Unit onSuccess: (DecryptedAccount) -> Unit
) { ) {
val TAG = "UnlockScreen"
val totalStart = System.currentTimeMillis()
if (selectedAccount == null) { if (selectedAccount == null) {
onError("Please select an account") onError("Please select an account")
return return
} }
onUnlocking(true) onUnlocking(true)
android.util.Log.d(TAG, "🔓 Starting unlock for account: ${selectedAccount.name}")
try { try {
val account = selectedAccount.encryptedAccount val account = selectedAccount.encryptedAccount
// Try to decrypt // Try to decrypt private key
val decryptStart = System.currentTimeMillis()
android.util.Log.d(TAG, "🔐 Decrypting private key (PBKDF2)...")
val decryptedPrivateKey = CryptoManager.decryptWithPassword( val decryptedPrivateKey = CryptoManager.decryptWithPassword(
account.encryptedPrivateKey, account.encryptedPrivateKey,
password password
) )
val decryptPrivateKeyTime = System.currentTimeMillis() - decryptStart
android.util.Log.d(TAG, "🔐 Private key decrypted in ${decryptPrivateKeyTime}ms")
if (decryptedPrivateKey == null) { if (decryptedPrivateKey == null) {
android.util.Log.w(TAG, "❌ Decryption failed - incorrect password")
onError("Incorrect password") onError("Incorrect password")
onUnlocking(false) onUnlocking(false)
return return
} }
// Decrypt seed phrase
val seedStart = System.currentTimeMillis()
android.util.Log.d(TAG, "🌱 Decrypting seed phrase...")
val decryptedSeedPhrase = CryptoManager.decryptWithPassword( val decryptedSeedPhrase = CryptoManager.decryptWithPassword(
account.encryptedSeedPhrase, account.encryptedSeedPhrase,
password password
)?.split(" ") ?: emptyList() )?.split(" ") ?: emptyList()
val seedTime = System.currentTimeMillis() - seedStart
android.util.Log.d(TAG, "🌱 Seed phrase decrypted in ${seedTime}ms")
// Generate private key hash
val hashStart = System.currentTimeMillis()
android.util.Log.d(TAG, "🔑 Generating private key hash...")
val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey)
val hashTime = System.currentTimeMillis() - hashStart
android.util.Log.d(TAG, "🔑 Hash generated in ${hashTime}ms")
val decryptedAccount = DecryptedAccount( val decryptedAccount = DecryptedAccount(
publicKey = account.publicKey, publicKey = account.publicKey,
@@ -110,8 +129,9 @@ private suspend fun performUnlock(
name = account.name name = account.name
) )
// Connect to server
// Connect to server and authenticate val connectStart = System.currentTimeMillis()
android.util.Log.d(TAG, "🌐 Connecting to server...")
ProtocolManager.connect() ProtocolManager.connect()
// Wait for websocket connection // Wait for websocket connection
@@ -120,8 +140,11 @@ private suspend fun performUnlock(
kotlinx.coroutines.delay(100) kotlinx.coroutines.delay(100)
waitAttempts++ waitAttempts++
} }
val connectTime = System.currentTimeMillis() - connectStart
android.util.Log.d(TAG, "🌐 Connected in ${connectTime}ms (attempts: $waitAttempts)")
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) { if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
android.util.Log.e(TAG, "❌ Connection failed after $waitAttempts attempts")
onError("Failed to connect to server") onError("Failed to connect to server")
onUnlocking(false) onUnlocking(false)
return return
@@ -129,12 +152,22 @@ private suspend fun performUnlock(
kotlinx.coroutines.delay(300) kotlinx.coroutines.delay(300)
// Authenticate
val authStart = System.currentTimeMillis()
android.util.Log.d(TAG, "🔒 Authenticating...")
ProtocolManager.authenticate(account.publicKey, privateKeyHash) ProtocolManager.authenticate(account.publicKey, privateKeyHash)
val authTime = System.currentTimeMillis() - authStart
android.util.Log.d(TAG, "🔒 Auth request sent in ${authTime}ms")
accountManager.setCurrentAccount(account.publicKey) accountManager.setCurrentAccount(account.publicKey)
val totalTime = System.currentTimeMillis() - totalStart
android.util.Log.d(TAG, "✅ UNLOCK COMPLETE in ${totalTime}ms")
android.util.Log.d(TAG, " 📊 Breakdown: privateKey=${decryptPrivateKeyTime}ms, seed=${seedTime}ms, hash=${hashTime}ms, connect=${connectTime}ms, auth=${authTime}ms")
onSuccess(decryptedAccount) onSuccess(decryptedAccount)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e(TAG, "❌ Unlock failed: ${e.message}", e)
onError("Failed to unlock: ${e.message}") onError("Failed to unlock: ${e.message}")
onUnlocking(false) onUnlocking(false)
} }

View File

@@ -294,7 +294,8 @@ fun ChatDetailScreen(
val isOnline by viewModel.opponentOnline.collectAsState() val isOnline by viewModel.opponentOnline.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
// 🔥 Reply/Forward state
// <20>🔥 Reply/Forward state
val replyMessages by viewModel.replyMessages.collectAsState() val replyMessages by viewModel.replyMessages.collectAsState()
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
val isForwardMode by viewModel.isForwardMode.collectAsState() val isForwardMode by viewModel.isForwardMode.collectAsState()

View File

@@ -251,12 +251,21 @@ fun ChatsListScreen(
// Load dialogs when account is available // Load dialogs when account is available
LaunchedEffect(accountPublicKey, accountPrivateKey) { LaunchedEffect(accountPublicKey, accountPrivateKey) {
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
val launchStart = System.currentTimeMillis()
android.util.Log.d("ChatsListScreen", "🚀 LaunchedEffect started")
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey) chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
android.util.Log.d("ChatsListScreen", "📋 setAccount took ${System.currentTimeMillis() - launchStart}ms")
// Устанавливаем аккаунт для RecentSearchesManager // Устанавливаем аккаунт для RecentSearchesManager
RecentSearchesManager.setAccount(accountPublicKey) RecentSearchesManager.setAccount(accountPublicKey)
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
// сообщений // сообщений
val initStart = System.currentTimeMillis()
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey) ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
android.util.Log.d("ChatsListScreen", "🌐 initializeAccount took ${System.currentTimeMillis() - initStart}ms")
android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms")
} }
} }

View File

@@ -105,11 +105,17 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val _isLoading = MutableStateFlow(false) private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val TAG = "ChatsListVM"
/** /**
* Установить текущий аккаунт и загрузить диалоги * Установить текущий аккаунт и загрузить диалоги
*/ */
fun setAccount(publicKey: String, privateKey: String) { fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis()
android.util.Log.d(TAG, "📋 setAccount called for: ${publicKey.take(8)}...")
if (currentAccount == publicKey) { if (currentAccount == publicKey) {
android.util.Log.d(TAG, "📋 Account already set, skipping")
return return
} }
@@ -119,11 +125,15 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
currentAccount = publicKey currentAccount = publicKey
currentPrivateKey = privateKey currentPrivateKey = privateKey
android.util.Log.d(TAG, "📋 Starting dialogs subscription...")
// Подписываемся на обычные диалоги // Подписываемся на обычные диалоги
viewModelScope.launch { viewModelScope.launch {
dialogDao.getDialogsFlow(publicKey) dialogDao.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.map { dialogsList -> .map { dialogsList ->
val mapStart = System.currentTimeMillis()
android.util.Log.d(TAG, "🔄 Processing ${dialogsList.size} dialogs...")
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений // <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
dialogsList.map { dialog -> dialogsList.map { dialog ->
@@ -194,10 +204,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
) )
} }
}.awaitAll() }.awaitAll()
}.also {
val mapTime = System.currentTimeMillis() - mapStart
android.util.Log.d(TAG, "🔄 Dialogs processed in ${mapTime}ms (${dialogsList.size} items)")
} }
} }
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs -> .collect { decryptedDialogs ->
android.util.Log.d(TAG, "✅ Dialogs collected: ${decryptedDialogs.size} items")
_dialogs.value = decryptedDialogs _dialogs.value = decryptedDialogs
// 🟢 Подписываемся на онлайн-статусы всех собеседников // 🟢 Подписываемся на онлайн-статусы всех собеседников

View File

@@ -704,43 +704,43 @@ fun MessageBubble(
} }
) )
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) { } else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
// Telegram-style: текст + время с автоматическим переносом // Telegram-style: текст + время с автоматическим переносом
TelegramStyleMessageContent( TelegramStyleMessageContent(
textContent = { textContent = {
AppleEmojiText( AppleEmojiText(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 17.sp, fontSize = 17.sp,
linkColor = linkColor linkColor = linkColor
)
},
timeContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle =
androidx.compose.ui.text.font.FontStyle.Italic
) )
if (message.isOutgoing) { },
val displayStatus = timeContent = {
if (isSavedMessages) MessageStatus.READ Row(
else message.status verticalAlignment = Alignment.CenterVertically,
AnimatedMessageStatus( horizontalArrangement = Arrangement.spacedBy(2.dp)
status = displayStatus, ) {
timeColor = timeColor, Text(
timestamp = message.timestamp.time, text = timeFormat.format(message.timestamp),
onRetry = onRetry, color = timeColor,
onDelete = onDelete fontSize = 11.sp,
fontStyle =
androidx.compose.ui.text.font.FontStyle.Italic
) )
if (message.isOutgoing) {
val displayStatus =
if (isSavedMessages) MessageStatus.READ
else message.status
AnimatedMessageStatus(
status = displayStatus,
timeColor = timeColor,
timestamp = message.timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
}
} }
} }
} )
)
} }
} }
} }

View File

@@ -116,6 +116,15 @@ fun MediaPickerBottomSheet(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current val density = LocalDensity.current
val focusManager = LocalFocusManager.current
val keyboardView = LocalView.current
// Function to hide keyboard
fun hideKeyboard() {
focusManager.clearFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(keyboardView.windowToken, 0)
}
// Media items from gallery // Media items from gallery
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) } var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
@@ -496,6 +505,7 @@ fun MediaPickerBottomSheet(
QuickActionsRow( QuickActionsRow(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onCameraClick = { onCameraClick = {
hideKeyboard()
animatedClose() animatedClose()
onOpenCamera() onOpenCamera()
}, },
@@ -578,6 +588,7 @@ fun MediaPickerBottomSheet(
mediaItems = mediaItems, mediaItems = mediaItems,
selectedItems = selectedItems, selectedItems = selectedItems,
onCameraClick = { onCameraClick = {
hideKeyboard()
animatedClose() animatedClose()
onOpenCamera() onOpenCamera()
}, },
@@ -1222,11 +1233,11 @@ private fun MediaGridItem(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isSelected) { if (isSelected) {
Text( Icon(
text = "$selectionIndex", imageVector = TablerIcons.Check,
color = Color.White, contentDescription = null,
fontSize = 12.sp, tint = Color.White,
fontWeight = FontWeight.Bold modifier = Modifier.size(16.dp)
) )
} }
} }

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
@@ -16,9 +17,11 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -30,6 +33,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.ui.components.AppleEmojiTextField import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
@@ -48,6 +53,7 @@ import kotlinx.coroutines.launch
* Telegram UX rules: * Telegram UX rules:
* 1. Input always linked to keyboard (imePadding) * 1. Input always linked to keyboard (imePadding)
* 2. Last message always visible * 2. Last message always visible
* 3. No layout jumps * 3. No layout jumps
* 4. After sending: input clears, keyboard stays open * 4. After sending: input clears, keyboard stays open
* 5. Input grows upward for multi-line (up to 6 lines) * 5. Input grows upward for multi-line (up to 6 lines)

View File

@@ -0,0 +1,430 @@
package com.rosetta.messenger.ui.settings
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import kotlinx.coroutines.delay
// Auth colors
private val AuthBackground = Color(0xFF1A1A1A)
private val AuthBackgroundLight = Color(0xFFFFFFFF)
private val AuthSurface = Color(0xFF2C2C2E)
private val AuthSurfaceLight = Color(0xFFF2F2F7)
/**
* Biometric Enable Screen - структура как в auth flow
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BiometricEnableScreen(
isDarkTheme: Boolean,
onBack: () -> Unit,
onEnable: (password: String, onSuccess: () -> Unit, onError: (String) -> Unit) -> Unit
) {
// Theme animation
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
val backgroundColor by animateColorAsState(
if (isDarkTheme) AuthBackground else AuthBackgroundLight,
animationSpec = themeAnimSpec,
label = "bg"
)
val textColor by animateColorAsState(
if (isDarkTheme) Color.White else Color.Black,
animationSpec = themeAnimSpec,
label = "text"
)
val secondaryTextColor by animateColorAsState(
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666),
animationSpec = themeAnimSpec,
label = "secondary"
)
val cardColor by animateColorAsState(
if (isDarkTheme) AuthSurface else AuthSurfaceLight,
animationSpec = themeAnimSpec,
label = "card"
)
val errorRed = Color(0xFFFF3B30)
// State
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
var showSuccess by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val context = LocalContext.current
val view = LocalView.current
// Function to hide keyboard
fun hideKeyboard() {
focusManager.clearFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
// Track keyboard visibility
var isKeyboardVisible by remember { mutableStateOf(false) }
DisposableEffect(view) {
val listener = android.view.ViewTreeObserver.OnGlobalLayoutListener {
val rect = android.graphics.Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
isKeyboardVisible = keypadHeight > screenHeight * 0.15
}
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(listener) }
}
// Lottie animation - одноразовая
val lockComposition by rememberLottieComposition(
LottieCompositionSpec.Asset("lottie/lock.json")
)
val lockProgress by animateLottieCompositionAsState(
composition = lockComposition,
iterations = 1,
speed = 0.8f
)
LaunchedEffect(Unit) { visible = true }
// Handle success - close screen after delay
LaunchedEffect(showSuccess) {
if (showSuccess) {
delay(1500)
onBack()
}
}
// Animated sizes
val lottieSize by animateDpAsState(
targetValue = if (isKeyboardVisible) 80.dp else 160.dp,
animationSpec = tween(300, easing = FastOutSlowInEasing),
label = "lottie"
)
Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) {
Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
// Top Bar - как в auth flow
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack, enabled = !isLoading) {
Icon(
imageVector = TablerIcons.ArrowLeft,
contentDescription = "Back",
tint = textColor.copy(alpha = 0.6f)
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Biometric",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(48.dp))
}
// Content
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 8.dp else 16.dp))
// Lottie Animation
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + scaleIn(
initialScale = 0.5f,
animationSpec = tween(250, easing = FastOutSlowInEasing)
)
) {
Box(
modifier = Modifier.size(lottieSize),
contentAlignment = Alignment.Center
) {
if (lockComposition != null) {
LottieAnimation(
composition = lockComposition,
progress = { lockProgress },
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen
},
maintainOriginalImageBounds = true
)
}
}
}
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp))
// Title
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 100))
) {
Text(
text = if (showSuccess) "Biometric Enabled!" else "Enable Biometric",
fontSize = if (isKeyboardVisible) 20.sp else 24.sp,
fontWeight = FontWeight.Bold,
color = if (showSuccess) PrimaryBlue else textColor
)
}
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp))
// Subtitle
AnimatedVisibility(
visible = visible && !showSuccess,
enter = fadeIn(tween(500, delayMillis = 200)),
exit = fadeOut(tween(200))
) {
Text(
text = "Use Face ID or Touch ID to quickly\nunlock your account.",
fontSize = if (isKeyboardVisible) 12.sp else 14.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = if (isKeyboardVisible) 16.sp else 20.sp
)
}
// Success message
AnimatedVisibility(
visible = showSuccess,
enter = fadeIn(tween(300)) + expandVertically()
) {
Text(
text = "You can now unlock with biometrics",
fontSize = 14.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp))
// Password Field
AnimatedVisibility(
visible = visible && !showSuccess,
enter = fadeIn(tween(400, delayMillis = 300)),
exit = fadeOut(tween(200))
) {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = password,
onValueChange = {
password = it
passwordError = null
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
"Enter your password",
color = secondaryTextColor.copy(alpha = 0.6f)
)
},
leadingIcon = {
Icon(
imageVector = TablerIcons.Lock,
contentDescription = null,
tint = if (passwordError != null) errorRed else secondaryTextColor,
modifier = Modifier.size(22.dp)
)
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
contentDescription = if (passwordVisible) "Hide" else "Show",
tint = secondaryTextColor,
modifier = Modifier.size(22.dp)
)
}
},
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
isError = passwordError != null,
singleLine = true,
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor = if (isDarkTheme) Color(0xFF48484A) else Color(0xFFD1D1D6),
focusedContainerColor = cardColor,
unfocusedContainerColor = cardColor,
focusedTextColor = textColor,
unfocusedTextColor = textColor,
cursorColor = PrimaryBlue,
errorBorderColor = errorRed,
errorContainerColor = cardColor
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
)
// Error message
AnimatedVisibility(
visible = passwordError != null,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Row(
modifier = Modifier.padding(top = 8.dp, start = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.AlertCircle,
contentDescription = null,
tint = errorRed,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = passwordError ?: "",
color = errorRed,
fontSize = 13.sp
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Enable button
AnimatedVisibility(
visible = visible && !showSuccess,
enter = fadeIn(tween(400, delayMillis = 400)),
exit = fadeOut(tween(200))
) {
Button(
onClick = {
if (password.isEmpty()) {
passwordError = "Please enter your password"
return@Button
}
hideKeyboard()
isLoading = true
onEnable(
password,
{
isLoading = false
showSuccess = true
},
{ error ->
isLoading = false
passwordError = error
}
)
},
enabled = password.isNotEmpty() && !isLoading,
modifier = Modifier
.fillMaxWidth()
.height(54.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.4f),
disabledContentColor = Color.White.copy(alpha = 0.6f)
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = null,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Enable Biometric",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Info - hide when keyboard visible
AnimatedVisibility(
visible = visible && !isKeyboardVisible && !showSuccess,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(cardColor)
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = TablerIcons.ShieldLock,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Your password is encrypted and stored securely on this device only.",
fontSize = 13.sp,
color = secondaryTextColor,
lineHeight = 18.sp
)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}

View File

@@ -191,6 +191,7 @@ fun ProfileScreen(
onNavigateToSafety: () -> Unit = {}, onNavigateToSafety: () -> Unit = {},
onNavigateToLogs: () -> Unit = {}, onNavigateToLogs: () -> Unit = {},
onNavigateToCrashLogs: () -> Unit = {}, onNavigateToCrashLogs: () -> Unit = {},
onNavigateToBiometric: () -> Unit = {},
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
dialogDao: com.rosetta.messenger.database.DialogDao? = null dialogDao: com.rosetta.messenger.database.DialogDao? = null
@@ -198,22 +199,20 @@ fun ProfileScreen(
val context = LocalContext.current val context = LocalContext.current
val activity = context as? FragmentActivity val activity = context as? FragmentActivity
val biometricManager = remember { BiometricAuthManager(context) } val biometricManager = remember { BiometricAuthManager(context) }
val biometricPrefs = remember { BiometricPreferences(context) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Biometric state // Biometric availability
var biometricAvailable by remember { var biometricAvailable by remember {
mutableStateOf<BiometricAvailability>(BiometricAvailability.NotAvailable("Checking...")) mutableStateOf<BiometricAvailability>(BiometricAvailability.NotAvailable("Checking..."))
} }
var isBiometricEnabled by remember { mutableStateOf(false) }
var showPasswordDialog by remember { mutableStateOf(false) } // Biometric enabled state - read directly to always show current state
var passwordInput by remember { mutableStateOf("") } val biometricPrefs = remember { BiometricPreferences(context) }
var passwordError by remember { mutableStateOf<String?>(null) } val isBiometricEnabled by biometricPrefs.isBiometricEnabled.collectAsState(initial = false)
// Check biometric availability // Check biometric availability
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
biometricAvailable = biometricManager.isBiometricAvailable() biometricAvailable = biometricManager.isBiometricAvailable()
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
} }
// Состояние меню аватара для установки фото профиля // Состояние меню аватара для установки фото профиля
@@ -627,32 +626,17 @@ fun ProfileScreen(
showDivider = biometricAvailable is BiometricAvailability.Available showDivider = biometricAvailable is BiometricAvailability.Available
) )
// Biometric toggle (only show if available) // Biometric settings (only show if available)
if (biometricAvailable is BiometricAvailability.Available && activity != null) { if (biometricAvailable is BiometricAvailability.Available && activity != null) {
TelegramBiometricItem( TelegramSettingsItem(
isEnabled = isBiometricEnabled, icon = TablerIcons.Fingerprint,
onToggle = { title = "Biometric Authentication",
if (isBiometricEnabled) { onClick = {
// Disable biometric onNavigateToBiometric()
scope.launch {
biometricPrefs.disableBiometric()
biometricPrefs.removeEncryptedPassword(accountPublicKey)
isBiometricEnabled = false
android.widget.Toast.makeText(
context,
"Biometric authentication disabled",
android.widget.Toast.LENGTH_SHORT
)
.show()
}
} else {
// Enable biometric - show password dialog first
passwordInput = ""
passwordError = null
showPasswordDialog = true
}
}, },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
showDivider = false,
subtitle = if (isBiometricEnabled) "Enabled" else "Disabled"
) )
} }
@@ -713,96 +697,6 @@ fun ProfileScreen(
) )
} }
// Password dialog for biometric setup
if (showPasswordDialog && activity != null) {
AlertDialog(
onDismissRequest = {
showPasswordDialog = false
passwordInput = ""
passwordError = null
},
title = { Text("Enable Biometric Authentication") },
text = {
Column {
Text("Enter your password to securely save it for biometric unlock:")
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = passwordInput,
onValueChange = {
passwordInput = it
passwordError = null
},
label = { Text("Password") },
singleLine = true,
visualTransformation =
androidx.compose.ui.text.input
.PasswordVisualTransformation(),
isError = passwordError != null,
modifier = Modifier.fillMaxWidth()
)
if (passwordError != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(text = passwordError!!, color = Color.Red, fontSize = 12.sp)
}
}
},
confirmButton = {
TextButton(
onClick = {
if (passwordInput.isEmpty()) {
passwordError = "Password cannot be empty"
return@TextButton
}
// Try to encrypt the password with biometric
biometricManager.encryptPassword(
activity = activity,
password = passwordInput,
onSuccess = { encryptedPassword ->
scope.launch {
// Save encrypted password
biometricPrefs.saveEncryptedPassword(
accountPublicKey,
encryptedPassword
)
// Enable biometric
biometricPrefs.enableBiometric()
isBiometricEnabled = true
showPasswordDialog = false
passwordInput = ""
passwordError = null
android.widget.Toast.makeText(
context,
"Biometric authentication enabled successfully",
android.widget.Toast.LENGTH_SHORT
)
.show()
}
},
onError = { error -> passwordError = error },
onCancel = {
showPasswordDialog = false
passwordInput = ""
passwordError = null
}
)
}
) { Text("Enable") }
},
dismissButton = {
TextButton(
onClick = {
showPasswordDialog = false
passwordInput = ""
passwordError = null
}
) { Text("Cancel") }
}
)
}
// 🖼️ Кастомный быстрый Photo Picker // 🖼️ Кастомный быстрый Photo Picker
ProfilePhotoPicker( ProfilePhotoPicker(
isVisible = showPhotoPicker, isVisible = showPhotoPicker,
@@ -1342,9 +1236,11 @@ private fun TelegramSettingsItem(
title: String, title: String,
onClick: () -> Unit, onClick: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
showDivider: Boolean = false showDivider: Boolean = false,
subtitle: String? = null
) { ) {
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
@@ -1365,7 +1261,13 @@ private fun TelegramSettingsItem(
Spacer(modifier = Modifier.width(20.dp)) Spacer(modifier = Modifier.width(20.dp))
Text(text = title, fontSize = 16.sp, color = textColor, modifier = Modifier.weight(1f)) Column(modifier = Modifier.weight(1f)) {
Text(text = title, fontSize = 16.sp, color = textColor)
if (subtitle != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(text = subtitle, fontSize = 13.sp, color = secondaryTextColor)
}
}
} }
if (showDivider) { if (showDivider) {