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-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")

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.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
}
)
}
)
}
}
}
}

View File

@@ -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() }
// 🔥 Запрашиваем информацию о пользователях, у которых нет имени

View File

@@ -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")
}
/**

View File

@@ -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)
}

View File

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

View File

@@ -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")
}
}

View File

@@ -105,11 +105,17 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _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...")
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
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
// 🟢 Подписываемся на онлайн-статусы всех собеседников

View File

@@ -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
)
}
}
}
}
)
)
}
}
}

View File

@@ -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<List<MediaItem>>(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)
)
}
}

View File

@@ -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)

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 = {},
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>(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<String?>(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) {