From 4dc7c6e9bc0cd212dd816c443a5741afbb548dd2 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 5 Feb 2026 01:56:18 +0500 Subject: [PATCH] fix: add detailed logging for unlock process to improve debugging and performance tracking --- app/build.gradle.kts | 3 + .../com/rosetta/messenger/MainActivity.kt | 59 ++- .../messenger/data/MessageRepository.kt | 8 + .../messenger/network/ProtocolManager.kt | 3 + .../rosetta/messenger/ui/auth/UnlockScreen.kt | 39 +- .../messenger/ui/chats/ChatDetailScreen.kt | 3 +- .../messenger/ui/chats/ChatsListScreen.kt | 9 + .../messenger/ui/chats/ChatsListViewModel.kt | 14 + .../chats/components/ChatDetailComponents.kt | 66 +-- .../components/MediaPickerBottomSheet.kt | 21 +- .../ui/chats/input/ChatDetailInput.kt | 6 + .../ui/settings/BiometricEnableScreen.kt | 430 ++++++++++++++++++ .../messenger/ui/settings/ProfileScreen.kt | 148 +----- 13 files changed, 640 insertions(+), 169 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index edd7d33..5a23f80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,6 +98,9 @@ dependencies { implementation("io.coil-kt:coil-compose:2.5.0") 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 implementation("com.github.yalantis:ucrop:2.2.8") diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index aa38cc2..ae78f4b 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -43,6 +43,7 @@ import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.settings.BackupScreen +import com.rosetta.messenger.ui.settings.BiometricEnableScreen import com.rosetta.messenger.ui.settings.OtherProfileScreen import com.rosetta.messenger.ui.settings.ProfileScreen import com.rosetta.messenger.ui.settings.SafetyScreen @@ -527,7 +528,8 @@ fun MainScreen( var showBackupScreen by remember { mutableStateOf(false) } var showLogsScreen by remember { mutableStateOf(false) } var showCrashLogsScreen by remember { mutableStateOf(false) } - + var showBiometricScreen by remember { mutableStateOf(false) } + // ProfileViewModel для логов val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel() val profileState by profileViewModel.state.collectAsState() @@ -553,9 +555,10 @@ fun MainScreen( Box(modifier = Modifier.fillMaxSize()) { // Base layer - chats list androidx.compose.animation.AnimatedVisibility( - visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen && - !showUpdatesScreen && selectedUser == null && !showSearchScreen && - !showProfileScreen && !showOtherProfileScreen && !showLogsScreen && !showCrashLogsScreen, + visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen && + !showUpdatesScreen && selectedUser == null && !showSearchScreen && + !showProfileScreen && !showOtherProfileScreen && !showLogsScreen && + !showCrashLogsScreen && !showBiometricScreen, enter = fadeIn(animationSpec = tween(300)), exit = fadeOut(animationSpec = tween(200)) ) { @@ -799,6 +802,10 @@ fun MainScreen( showProfileScreen = false showCrashLogsScreen = true }, + onNavigateToBiometric = { + showProfileScreen = false + showBiometricScreen = true + }, viewModel = profileViewModel, avatarRepository = avatarRepository, 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 + } + ) + } + ) + } + } } } diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 3f85cae..fcd0757 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -106,17 +106,25 @@ class MessageRepository private constructor(private val context: Context) { * Инициализация с текущим аккаунтом */ fun initialize(publicKey: String, privateKey: String) { + val start = System.currentTimeMillis() + android.util.Log.d("MessageRepository", "🔧 initialize started for ${publicKey.take(8)}...") + // 🔥 Очищаем кэш запрошенных user info при смене аккаунта if (currentAccount != publicKey) { requestedUserInfoKeys.clear() + android.util.Log.d("MessageRepository", "🔧 Cleared user info cache (account changed)") } currentAccount = publicKey currentPrivateKey = privateKey + android.util.Log.d("MessageRepository", "🔧 initialize completed in ${System.currentTimeMillis() - start}ms (launching dialogs flow in background)") + // Загрузка диалогов scope.launch { + android.util.Log.d("MessageRepository", "📂 Starting dialogs flow collection...") dialogDao.getDialogsFlow(publicKey).collect { entities -> + android.util.Log.d("MessageRepository", "📂 Got ${entities.size} dialogs from DB") _dialogs.value = entities.map { it.toDialog() } // 🔥 Запрашиваем информацию о пользователях, у которых нет имени diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index dc4a49e..6f2cadd 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -80,7 +80,10 @@ object ProtocolManager { * Должен вызываться после авторизации пользователя */ fun initializeAccount(publicKey: String, privateKey: String) { + val start = System.currentTimeMillis() + android.util.Log.d("ProtocolManager", "🔧 initializeAccount started") messageRepository?.initialize(publicKey, privateKey) + android.util.Log.d("ProtocolManager", "🔧 initializeAccount completed in ${System.currentTimeMillis() - start}ms") } /** diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 32714dd..1ddec01 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -73,34 +73,53 @@ private suspend fun performUnlock( onError: (String) -> Unit, onSuccess: (DecryptedAccount) -> Unit ) { + val TAG = "UnlockScreen" + val totalStart = System.currentTimeMillis() + if (selectedAccount == null) { onError("Please select an account") return } onUnlocking(true) + android.util.Log.d(TAG, "🔓 Starting unlock for account: ${selectedAccount.name}") try { 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( account.encryptedPrivateKey, password ) + val decryptPrivateKeyTime = System.currentTimeMillis() - decryptStart + android.util.Log.d(TAG, "🔐 Private key decrypted in ${decryptPrivateKeyTime}ms") if (decryptedPrivateKey == null) { + android.util.Log.w(TAG, "❌ Decryption failed - incorrect password") onError("Incorrect password") onUnlocking(false) return } + // Decrypt seed phrase + val seedStart = System.currentTimeMillis() + android.util.Log.d(TAG, "🌱 Decrypting seed phrase...") val decryptedSeedPhrase = CryptoManager.decryptWithPassword( account.encryptedSeedPhrase, password )?.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 hashTime = System.currentTimeMillis() - hashStart + android.util.Log.d(TAG, "🔑 Hash generated in ${hashTime}ms") val decryptedAccount = DecryptedAccount( publicKey = account.publicKey, @@ -110,8 +129,9 @@ private suspend fun performUnlock( name = account.name ) - - // Connect to server and authenticate + // Connect to server + val connectStart = System.currentTimeMillis() + android.util.Log.d(TAG, "🌐 Connecting to server...") ProtocolManager.connect() // Wait for websocket connection @@ -120,8 +140,11 @@ private suspend fun performUnlock( kotlinx.coroutines.delay(100) waitAttempts++ } + val connectTime = System.currentTimeMillis() - connectStart + android.util.Log.d(TAG, "🌐 Connected in ${connectTime}ms (attempts: $waitAttempts)") if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) { + android.util.Log.e(TAG, "❌ Connection failed after $waitAttempts attempts") onError("Failed to connect to server") onUnlocking(false) return @@ -129,12 +152,22 @@ private suspend fun performUnlock( kotlinx.coroutines.delay(300) + // Authenticate + val authStart = System.currentTimeMillis() + android.util.Log.d(TAG, "🔒 Authenticating...") 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) + 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) } catch (e: Exception) { + android.util.Log.e(TAG, "❌ Unlock failed: ${e.message}", e) onError("Failed to unlock: ${e.message}") onUnlocking(false) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 2e047ed..456c315 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -294,7 +294,8 @@ fun ChatDetailScreen( val isOnline by viewModel.opponentOnline.collectAsState() val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона - // 🔥 Reply/Forward state + + // �🔥 Reply/Forward state val replyMessages by viewModel.replyMessages.collectAsState() val hasReply = replyMessages.isNotEmpty() val isForwardMode by viewModel.isForwardMode.collectAsState() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 0dcf2c8..10bab8c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -251,12 +251,21 @@ fun ChatsListScreen( // Load dialogs when account is available LaunchedEffect(accountPublicKey, accountPrivateKey) { if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { + val launchStart = System.currentTimeMillis() + android.util.Log.d("ChatsListScreen", "🚀 LaunchedEffect started") + chatsViewModel.setAccount(accountPublicKey, accountPrivateKey) + android.util.Log.d("ChatsListScreen", "📋 setAccount took ${System.currentTimeMillis() - launchStart}ms") + // Устанавливаем аккаунт для RecentSearchesManager RecentSearchesManager.setAccount(accountPublicKey) + // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих // сообщений + val initStart = System.currentTimeMillis() 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") } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index e23709d..05943d4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -105,11 +105,17 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() + private val TAG = "ChatsListVM" + /** * Установить текущий аккаунт и загрузить диалоги */ fun setAccount(publicKey: String, privateKey: String) { + val setAccountStart = System.currentTimeMillis() + android.util.Log.d(TAG, "📋 setAccount called for: ${publicKey.take(8)}...") + if (currentAccount == publicKey) { + android.util.Log.d(TAG, "📋 Account already set, skipping") return } @@ -119,11 +125,15 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio currentAccount = publicKey currentPrivateKey = privateKey + android.util.Log.d(TAG, "📋 Starting dialogs subscription...") + // Подписываемся на обычные диалоги viewModelScope.launch { dialogDao.getDialogsFlow(publicKey) .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .map { dialogsList -> + val mapStart = System.currentTimeMillis() + android.util.Log.d(TAG, "🔄 Processing ${dialogsList.size} dialogs...") // � ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений withContext(Dispatchers.Default) { dialogsList.map { dialog -> @@ -194,10 +204,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio ) } }.awaitAll() + }.also { + val mapTime = System.currentTimeMillis() - mapStart + android.util.Log.d(TAG, "🔄 Dialogs processed in ${mapTime}ms (${dialogsList.size} items)") } } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedDialogs -> + android.util.Log.d(TAG, "✅ Dialogs collected: ${decryptedDialogs.size} items") _dialogs.value = decryptedDialogs // 🟢 Подписываемся на онлайн-статусы всех собеседников diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 1043262..74ad5ca 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -704,43 +704,43 @@ fun MessageBubble( } ) } else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) { - // Telegram-style: текст + время с автоматическим переносом - TelegramStyleMessageContent( - textContent = { - AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 17.sp, - 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 + // Telegram-style: текст + время с автоматическим переносом + TelegramStyleMessageContent( + textContent = { + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp, + linkColor = linkColor ) - 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 + }, + 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 = + if (isSavedMessages) MessageStatus.READ + else message.status + AnimatedMessageStatus( + status = displayStatus, + timeColor = timeColor, + timestamp = message.timestamp.time, + onRetry = onRetry, + onDelete = onDelete + ) + } } } - } - ) + ) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index d6b336f..417e237 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -116,6 +116,15 @@ fun MediaPickerBottomSheet( val context = LocalContext.current val scope = rememberCoroutineScope() 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 var mediaItems by remember { mutableStateOf>(emptyList()) } @@ -496,6 +505,7 @@ fun MediaPickerBottomSheet( QuickActionsRow( isDarkTheme = isDarkTheme, onCameraClick = { + hideKeyboard() animatedClose() onOpenCamera() }, @@ -578,6 +588,7 @@ fun MediaPickerBottomSheet( mediaItems = mediaItems, selectedItems = selectedItems, onCameraClick = { + hideKeyboard() animatedClose() onOpenCamera() }, @@ -1222,11 +1233,11 @@ private fun MediaGridItem( contentAlignment = Alignment.Center ) { if (isSelected) { - Text( - text = "$selectionIndex", - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Bold + Icon( + imageVector = TablerIcons.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 41d2488..3e1569a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* 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.Modifier import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -30,6 +33,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition 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.ui.components.AppleEmojiTextField import com.rosetta.messenger.ui.components.OptimizedEmojiPicker @@ -48,6 +53,7 @@ import kotlinx.coroutines.launch * Telegram UX rules: * 1. Input always linked to keyboard (imePadding) * 2. Last message always visible + * 3. No layout jumps * 4. After sending: input clears, keyboard stays open * 5. Input grows upward for multi-line (up to 6 lines) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt new file mode 100644 index 0000000..07e3a50 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt @@ -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(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(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)) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 25626aa..71a9d85 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -191,6 +191,7 @@ fun ProfileScreen( onNavigateToSafety: () -> Unit = {}, onNavigateToLogs: () -> Unit = {}, onNavigateToCrashLogs: () -> Unit = {}, + onNavigateToBiometric: () -> Unit = {}, viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), avatarRepository: AvatarRepository? = null, dialogDao: com.rosetta.messenger.database.DialogDao? = null @@ -198,22 +199,20 @@ fun ProfileScreen( val context = LocalContext.current val activity = context as? FragmentActivity val biometricManager = remember { BiometricAuthManager(context) } - val biometricPrefs = remember { BiometricPreferences(context) } val scope = rememberCoroutineScope() - // Biometric state + // Biometric availability var biometricAvailable by remember { mutableStateOf(BiometricAvailability.NotAvailable("Checking...")) } - var isBiometricEnabled by remember { mutableStateOf(false) } - var showPasswordDialog by remember { mutableStateOf(false) } - var passwordInput by remember { mutableStateOf("") } - var passwordError by remember { mutableStateOf(null) } + + // Biometric enabled state - read directly to always show current state + val biometricPrefs = remember { BiometricPreferences(context) } + val isBiometricEnabled by biometricPrefs.isBiometricEnabled.collectAsState(initial = false) // Check biometric availability LaunchedEffect(Unit) { biometricAvailable = biometricManager.isBiometricAvailable() - isBiometricEnabled = biometricPrefs.isBiometricEnabled.first() } // Состояние меню аватара для установки фото профиля @@ -627,32 +626,17 @@ fun ProfileScreen( showDivider = biometricAvailable is BiometricAvailability.Available ) - // Biometric toggle (only show if available) + // Biometric settings (only show if available) if (biometricAvailable is BiometricAvailability.Available && activity != null) { - TelegramBiometricItem( - isEnabled = isBiometricEnabled, - onToggle = { - if (isBiometricEnabled) { - // Disable biometric - 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 - } + TelegramSettingsItem( + icon = TablerIcons.Fingerprint, + title = "Biometric Authentication", + onClick = { + onNavigateToBiometric() }, - 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 ProfilePhotoPicker( isVisible = showPhotoPicker, @@ -1342,9 +1236,11 @@ private fun TelegramSettingsItem( title: String, onClick: () -> Unit, isDarkTheme: Boolean, - showDivider: Boolean = false + showDivider: Boolean = false, + subtitle: String? = null ) { 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 dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0) @@ -1365,7 +1261,13 @@ private fun TelegramSettingsItem( 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) {