feat: Implement BiometricAuthManager for biometric authentication and password encryption/decryption

This commit is contained in:
k1ngsterr1
2026-01-22 14:26:30 +05:00
parent 727ae9b5b5
commit f6f8620102
8 changed files with 1094 additions and 142 deletions

View File

@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:allowBackup="true"

View File

@@ -53,8 +53,9 @@ import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.fragment.app.FragmentActivity
class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
private lateinit var preferencesManager: PreferencesManager
private lateinit var accountManager: AccountManager

View File

@@ -0,0 +1,285 @@
package com.rosetta.messenger.biometric
import android.content.Context
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Менеджер для работы с биометрической аутентификацией
* Поддерживает отпечаток пальца, Face ID и другие биометрические методы
*/
class BiometricAuthManager(private val context: Context) {
companion object {
private const val KEY_NAME = "rosetta_biometric_key"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_SEPARATOR = "]"
}
/**
* Проверяет доступность биометрической аутентификации на устройстве
*/
fun isBiometricAvailable(): BiometricAvailability {
val biometricManager = BiometricManager.from(context)
return when (biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.BIOMETRIC_WEAK
)) {
BiometricManager.BIOMETRIC_SUCCESS ->
BiometricAvailability.Available
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
BiometricAvailability.NotAvailable("Биометрическое оборудование недоступно")
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
BiometricAvailability.NotAvailable("Биометрическое оборудование временно недоступно")
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
BiometricAvailability.NotEnrolled("Биометрические данные не настроены")
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED ->
BiometricAvailability.NotAvailable("Требуется обновление безопасности")
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED ->
BiometricAvailability.NotAvailable("Биометрическая аутентификация не поддерживается")
BiometricManager.BIOMETRIC_STATUS_UNKNOWN ->
BiometricAvailability.NotAvailable("Неизвестный статус")
else -> BiometricAvailability.NotAvailable("Неизвестная ошибка")
}
}
/**
* Генерирует или получает ключ из Android Keystore
*/
private fun getSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null)
// Проверяем существование ключа
if (!keyStore.containsAlias(KEY_NAME)) {
generateSecretKey()
}
return keyStore.getKey(KEY_NAME, null) as SecretKey
}
/**
* Генерирует новый ключ в Android Keystore
*/
private fun generateSecretKey() {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE
)
val purposes = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
val builder = KeyGenParameterSpec.Builder(KEY_NAME, purposes)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
// Для Android 11+ можем указать тайм-аут аутентификации
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setUserAuthenticationParameters(
0, // 0 означает, что требуется аутентификация для каждого использования
KeyProperties.AUTH_BIOMETRIC_STRONG
)
}
keyGenerator.init(builder.build())
keyGenerator.generateKey()
}
/**
* Шифрует пароль с помощью биометрического ключа
*/
fun encryptPassword(
activity: FragmentActivity,
password: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit
) {
try {
val cipher = getCipher()
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
val biometricPrompt = createBiometricPrompt(
activity,
onSuccess = { cryptoObject ->
try {
val encryptedPassword = cryptoObject.cipher?.doFinal(password.toByteArray())
val iv = cryptoObject.cipher?.iv
if (encryptedPassword != null && iv != null) {
// Сохраняем IV вместе с зашифрованными данными
val ivString = Base64.encodeToString(iv, Base64.NO_WRAP)
val encryptedString = Base64.encodeToString(encryptedPassword, Base64.NO_WRAP)
onSuccess("$ivString$IV_SEPARATOR$encryptedString")
} else {
onError("Ошибка шифрования")
}
} catch (e: Exception) {
onError("Ошибка при шифровании: ${e.message}")
}
},
onError = onError,
onCancel = onCancel
)
val promptInfo = createPromptInfo("Сохранить пароль")
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
} catch (e: Exception) {
onError("Ошибка инициализации: ${e.message}")
}
}
/**
* Расшифровывает пароль с помощью биометрического ключа
*/
fun decryptPassword(
activity: FragmentActivity,
encryptedData: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit
) {
try {
// Разделяем IV и зашифрованные данные
val parts = encryptedData.split(IV_SEPARATOR)
if (parts.size != 2) {
onError("Неверный формат данных")
return
}
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val encryptedPassword = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = getCipher()
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
val biometricPrompt = createBiometricPrompt(
activity,
onSuccess = { cryptoObject ->
try {
val decryptedPassword = cryptoObject.cipher?.doFinal(encryptedPassword)
if (decryptedPassword != null) {
onSuccess(String(decryptedPassword))
} else {
onError("Ошибка расшифровки")
}
} catch (e: Exception) {
onError("Ошибка при расшифровке: ${e.message}")
}
},
onError = onError,
onCancel = onCancel
)
val promptInfo = createPromptInfo("Разблокировать приложение")
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
} catch (e: Exception) {
onError("Ошибка инициализации: ${e.message}")
}
}
/**
* Создает BiometricPrompt с обработчиками
*/
private fun createBiometricPrompt(
activity: FragmentActivity,
onSuccess: (BiometricPrompt.CryptoObject) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit
): BiometricPrompt {
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
onCancel()
} else {
onError(errString.toString())
}
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
result.cryptoObject?.let { onSuccess(it) }
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
// Не вызываем onError здесь, так как пользователь может попробовать снова
}
}
return BiometricPrompt(activity, executor, callback)
}
/**
* Создает информацию для диалога биометрической аутентификации
*/
private fun createPromptInfo(title: String): BiometricPrompt.PromptInfo {
return BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle("Используйте биометрию для аутентификации")
.setNegativeButtonText("Использовать пароль")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.BIOMETRIC_WEAK
)
.build()
}
/**
* Получает экземпляр Cipher для шифрования/расшифровки
*/
private fun getCipher(): Cipher {
return Cipher.getInstance(TRANSFORMATION)
}
/**
* Удаляет сохраненные биометрические данные
*/
fun removeBiometricData() {
try {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null)
keyStore.deleteEntry(KEY_NAME)
} catch (e: Exception) {
// Игнорируем ошибки при удалении
}
}
}
/**
* Результат проверки доступности биометрии
*/
sealed class BiometricAvailability {
object Available : BiometricAvailability()
data class NotAvailable(val reason: String) : BiometricAvailability()
data class NotEnrolled(val reason: String) : BiometricAvailability()
}

View File

@@ -0,0 +1,95 @@
package com.rosetta.messenger.biometric
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.biometricDataStore: DataStore<Preferences> by preferencesDataStore(name = "biometric_prefs")
/**
* Управление настройками и данными биометрической аутентификации
*/
class BiometricPreferences(private val context: Context) {
companion object {
private val BIOMETRIC_ENABLED = booleanPreferencesKey("biometric_enabled")
private val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_"
}
/**
* Включена ли биометрическая аутентификация
*/
val isBiometricEnabled: Flow<Boolean> = context.biometricDataStore.data
.map { preferences ->
preferences[BIOMETRIC_ENABLED] ?: false
}
/**
* Включить биометрическую аутентификацию
*/
suspend fun enableBiometric() {
context.biometricDataStore.edit { preferences ->
preferences[BIOMETRIC_ENABLED] = true
}
}
/**
* Отключить биометрическую аутентификацию
*/
suspend fun disableBiometric() {
context.biometricDataStore.edit { preferences ->
preferences[BIOMETRIC_ENABLED] = false
}
}
/**
* Сохранить зашифрованный пароль для аккаунта
*/
suspend fun saveEncryptedPassword(publicKey: String, encryptedPassword: String) {
val key = stringPreferencesKey("$ENCRYPTED_PASSWORD_PREFIX$publicKey")
context.biometricDataStore.edit { preferences ->
preferences[key] = encryptedPassword
}
}
/**
* Получить зашифрованный пароль для аккаунта
*/
suspend fun getEncryptedPassword(publicKey: String): String? {
val key = stringPreferencesKey("$ENCRYPTED_PASSWORD_PREFIX$publicKey")
return context.biometricDataStore.data.first()[key]
}
/**
* Удалить зашифрованный пароль для аккаунта
*/
suspend fun removeEncryptedPassword(publicKey: String) {
val key = stringPreferencesKey("$ENCRYPTED_PASSWORD_PREFIX$publicKey")
context.biometricDataStore.edit { preferences ->
preferences.remove(key)
}
}
/**
* Удалить все биометрические данные
*/
suspend fun clearAll() {
context.biometricDataStore.edit { preferences ->
preferences.clear()
}
}
/**
* Проверить, есть ли сохраненный зашифрованный пароль для аккаунта
*/
suspend fun hasEncryptedPassword(publicKey: String): Boolean {
return getEncryptedPassword(publicKey) != null
}
}

View File

@@ -32,7 +32,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.biometric.BiometricAuthManager
import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
@@ -43,7 +47,9 @@ import com.rosetta.messenger.ui.chats.getAvatarColor
import com.rosetta.messenger.ui.chats.getAvatarText
import com.rosetta.messenger.ui.chats.utils.getInitials
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
// Account model for dropdown
data class AccountItem(
@@ -52,6 +58,87 @@ data class AccountItem(
val encryptedAccount: EncryptedAccount
)
/**
* Функция для выполнения разблокировки аккаунта
*/
private suspend fun performUnlock(
selectedAccount: AccountItem?,
password: String,
accountManager: AccountManager,
onUnlocking: (Boolean) -> Unit,
onError: (String) -> Unit,
onSuccess: (DecryptedAccount) -> Unit
) {
if (selectedAccount == null) {
onError("Please select an account")
return
}
onUnlocking(true)
try {
val account = selectedAccount.encryptedAccount
// Try to decrypt
val decryptedPrivateKey = CryptoManager.decryptWithPassword(
account.encryptedPrivateKey,
password
)
if (decryptedPrivateKey == null) {
onError("Incorrect password")
onUnlocking(false)
return
}
val decryptedSeedPhrase = CryptoManager.decryptWithPassword(
account.encryptedSeedPhrase,
password
)?.split(" ") ?: emptyList()
val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey)
val decryptedAccount = DecryptedAccount(
publicKey = account.publicKey,
privateKey = decryptedPrivateKey,
seedPhrase = decryptedSeedPhrase,
privateKeyHash = privateKeyHash,
name = account.name
)
android.util.Log.d("UnlockScreen", "🔐 Starting connection and authentication...")
// Connect to server and authenticate
ProtocolManager.connect()
// Wait for websocket connection
var waitAttempts = 0
while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) {
kotlinx.coroutines.delay(100)
waitAttempts++
}
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
onError("Failed to connect to server")
onUnlocking(false)
return
}
kotlinx.coroutines.delay(300)
android.util.Log.d("UnlockScreen", "🔐 Starting authentication...")
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
accountManager.setCurrentAccount(account.publicKey)
android.util.Log.d("UnlockScreen", "✅ Unlock complete")
onSuccess(decryptedAccount)
} catch (e: Exception) {
onError("Failed to unlock: ${e.message}")
onUnlocking(false)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UnlockScreen(
@@ -84,7 +171,10 @@ fun UnlockScreen(
)
val context = LocalContext.current
val activity = context as? FragmentActivity
val accountManager = remember { AccountManager(context) }
val biometricManager = remember { BiometricAuthManager(context) }
val biometricPrefs = remember { BiometricPreferences(context) }
val scope = rememberCoroutineScope()
var password by remember { mutableStateOf("") }
@@ -92,6 +182,10 @@ fun UnlockScreen(
var isUnlocking by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Биометрия
var biometricAvailable by remember { mutableStateOf<BiometricAvailability>(BiometricAvailability.NotAvailable("Проверка...")) }
var isBiometricEnabled by remember { mutableStateOf(false) }
// Account selection state
var accounts by remember { mutableStateOf<List<AccountItem>>(emptyList()) }
var selectedAccount by remember { mutableStateOf<AccountItem?>(null) }
@@ -125,6 +219,56 @@ fun UnlockScreen(
}
selectedAccount = targetAccount ?: accounts.firstOrNull()
// Проверяем доступность биометрии
biometricAvailable = biometricManager.isBiometricAvailable()
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
android.util.Log.d("UnlockScreen", "🔐 Biometric available: $biometricAvailable")
android.util.Log.d("UnlockScreen", "🔐 Biometric enabled: $isBiometricEnabled")
android.util.Log.d("UnlockScreen", "🔐 Activity: $activity")
}
// Автоматически пытаемся разблокировать через биометрию при выборе аккаунта
LaunchedEffect(selectedAccount, isBiometricEnabled) {
if (selectedAccount != null && isBiometricEnabled && activity != null &&
biometricAvailable is BiometricAvailability.Available) {
val encryptedPassword = biometricPrefs.getEncryptedPassword(selectedAccount!!.publicKey)
if (encryptedPassword != null) {
// Небольшая задержка для анимации UI
delay(500)
// Запускаем биометрическую аутентификацию
biometricManager.decryptPassword(
activity = activity,
encryptedData = encryptedPassword,
onSuccess = { decryptedPassword ->
password = decryptedPassword
// Автоматически разблокируем
scope.launch {
performUnlock(
selectedAccount = selectedAccount,
password = decryptedPassword,
accountManager = accountManager,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->
onUnlocked(decryptedAccount)
}
)
}
},
onError = { errorMessage ->
// Если биометрия не сработала, пользователь может ввести пароль вручную
android.util.Log.e("UnlockScreen", "Biometric error: $errorMessage")
},
onCancel = {
// Пользователь отменил биометрию, покажем поле для ввода пароля
}
)
}
}
}
// Filter accounts by search
@@ -571,126 +715,226 @@ fun UnlockScreen(
visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(400, delayMillis = 500))
) {
Button(
onClick = {
if (selectedAccount == null) {
error = "Please select an account"
return@Button
}
if (password.isEmpty()) {
error = "Please enter your password"
return@Button
}
isUnlocking = true
scope.launch {
try {
val account = selectedAccount!!.encryptedAccount
// Try to decrypt
val decryptedPrivateKey =
CryptoManager.decryptWithPassword(
account.encryptedPrivateKey,
password
)
if (decryptedPrivateKey == null) {
error = "Incorrect password"
isUnlocking = false
return@launch
}
val decryptedSeedPhrase =
CryptoManager.decryptWithPassword(
account.encryptedSeedPhrase,
password
)
?.split(" ")
?: emptyList()
val privateKeyHash =
CryptoManager.generatePrivateKeyHash(
decryptedPrivateKey
)
val decryptedAccount =
DecryptedAccount(
publicKey = account.publicKey,
privateKey = decryptedPrivateKey,
seedPhrase = decryptedSeedPhrase,
privateKeyHash = privateKeyHash,
name = account.name
)
android.util.Log.d("UnlockScreen", "🔐 Starting connection and authentication...")
android.util.Log.d("UnlockScreen", " PublicKey: ${account.publicKey.take(16)}...")
android.util.Log.d("UnlockScreen", " PrivateKeyHash: ${privateKeyHash.take(16)}...")
// Connect to server and authenticate
ProtocolManager.connect()
// 🔥 Ждем пока websocket подключится (CONNECTED state)
var waitAttempts = 0
while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) {
kotlinx.coroutines.delay(100)
waitAttempts++
}
android.util.Log.d("UnlockScreen", "🔌 Connection state after wait: ${ProtocolManager.state.value}")
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
error = "Failed to connect to server"
isUnlocking = false
return@launch
}
// Еще немного ждем для стабильности
kotlinx.coroutines.delay(300)
android.util.Log.d("UnlockScreen", "🔐 Starting authentication...")
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
accountManager.setCurrentAccount(account.publicKey)
android.util.Log.d("UnlockScreen", "✅ Unlock complete, calling onUnlocked callback")
onUnlocked(decryptedAccount)
} catch (e: Exception) {
error = "Failed to unlock: \${e.message}"
isUnlocking = false
Column {
Button(
onClick = {
if (selectedAccount == null) {
error = "Please select an account"
return@Button
}
}
},
enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
) {
if (isUnlocking) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Unlock", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
if (password.isEmpty()) {
error = "Please enter your password"
return@Button
}
scope.launch {
performUnlock(
selectedAccount = selectedAccount,
password = password,
accountManager = accountManager,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->
// Если биометрия доступна и включена, сохраняем пароль
if (biometricAvailable is BiometricAvailability.Available &&
isBiometricEnabled && activity != null) {
scope.launch {
biometricManager.encryptPassword(
activity = activity,
password = password,
onSuccess = { encryptedPassword ->
scope.launch {
biometricPrefs.saveEncryptedPassword(
decryptedAccount.publicKey,
encryptedPassword
)
}
},
onError = { /* Игнорируем ошибки при сохранении */ },
onCancel = { /* Пользователь отменил */ }
)
}
}
onUnlocked(decryptedAccount)
}
)
}
},
enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
) {
if (isUnlocking) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Unlock", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Кнопка для включения/отключения биометрии (вне AnimatedVisibility)
if (biometricAvailable is BiometricAvailability.Available && activity != null && !isDropdownExpanded) {
// Кнопка входа по биометрии (если биометрия включена и пароль сохранен)
var hasSavedPassword by remember { mutableStateOf(false) }
LaunchedEffect(selectedAccount, isBiometricEnabled) {
hasSavedPassword = if (selectedAccount != null && isBiometricEnabled) {
biometricPrefs.hasEncryptedPassword(selectedAccount!!.publicKey)
} else false
}
if (isBiometricEnabled && hasSavedPassword) {
// Кнопка для входа по биометрии
OutlinedButton(
onClick = {
selectedAccount?.let { account ->
scope.launch {
val encryptedPassword = biometricPrefs.getEncryptedPassword(account.publicKey)
if (encryptedPassword != null) {
biometricManager.decryptPassword(
activity = activity,
encryptedData = encryptedPassword,
onSuccess = { decryptedPassword ->
password = decryptedPassword
scope.launch {
performUnlock(
selectedAccount = selectedAccount,
password = decryptedPassword,
accountManager = accountManager,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->
onUnlocked(decryptedAccount)
}
)
}
},
onError = { errorMessage ->
error = "Ошибка биометрии: $errorMessage"
},
onCancel = { }
)
}
}
}
},
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, PrimaryBlue)
) {
Icon(
imageVector = Icons.Default.Fingerprint,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Войти по отпечатку",
color = PrimaryBlue,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(12.dp))
}
// Переключатель биометрии
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable {
scope.launch {
if (isBiometricEnabled) {
// Отключаем биометрию
biometricPrefs.disableBiometric()
selectedAccount?.let {
biometricPrefs.removeEncryptedPassword(it.publicKey)
}
isBiometricEnabled = false
} else {
// Включаем биометрию - нужно сохранить пароль
if (password.isNotEmpty()) {
// Сначала проверим пароль
val account = selectedAccount?.encryptedAccount
if (account != null) {
val decryptedPrivateKey = CryptoManager.decryptWithPassword(
account.encryptedPrivateKey,
password
)
if (decryptedPrivateKey != null) {
// Пароль верный, сохраняем через биометрию
biometricManager.encryptPassword(
activity = activity,
password = password,
onSuccess = { encryptedPassword ->
scope.launch {
biometricPrefs.enableBiometric()
biometricPrefs.saveEncryptedPassword(
account.publicKey,
encryptedPassword
)
isBiometricEnabled = true
}
},
onError = { errorMsg ->
error = "Ошибка сохранения: $errorMsg"
},
onCancel = { }
)
} else {
error = "Неверный пароль"
}
}
} else {
error = "Сначала введите пароль"
}
}
}
}
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Fingerprint,
contentDescription = null,
tint = if (isBiometricEnabled) PrimaryBlue else secondaryTextColor,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (isBiometricEnabled) "Биометрия включена" else "Включить биометрию",
color = if (isBiometricEnabled) PrimaryBlue else secondaryTextColor,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(16.dp))
}
// Create New Account button
AnimatedVisibility(
visible = visible && !isDropdownExpanded,

View File

@@ -335,38 +335,79 @@ fun MessageBubble(
Spacer(modifier = Modifier.height(4.dp))
}
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.wrapContentWidth()
) {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp,
modifier = Modifier.wrapContentWidth()
)
// Если есть reply - текст слева, время справа на одной строке
if (message.replyData != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(bottom = 2.dp)
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp,
modifier = Modifier.weight(1f, fill = false)
)
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
Spacer(modifier = Modifier.width(10.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(bottom = 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
)
}
}
}
} else {
// Без reply - компактно в одну строку
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.wrapContentWidth()
) {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp,
modifier = Modifier.wrapContentWidth()
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(bottom = 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
)
}
}
}
}