diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f50626c..fdcf930 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
+ 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()
+}
diff --git a/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt b/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt
new file mode 100644
index 0000000..ae716f8
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt
@@ -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 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 = 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
+ }
+}
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 4e1aff8..a761213 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
@@ -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(null) }
+ // Биометрия
+ var biometricAvailable by remember { mutableStateOf(BiometricAvailability.NotAvailable("Проверка...")) }
+ var isBiometricEnabled by remember { mutableStateOf(false) }
+
// Account selection state
var accounts by remember { mutableStateOf>(emptyList()) }
var selectedAccount by remember { mutableStateOf(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,
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 8af04a4..b8e6c01 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
@@ -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
+ )
+ }
}
}
}
diff --git a/docs/BIOMETRIC_AUTHENTICATION.md b/docs/BIOMETRIC_AUTHENTICATION.md
new file mode 100644
index 0000000..e850aec
--- /dev/null
+++ b/docs/BIOMETRIC_AUTHENTICATION.md
@@ -0,0 +1,206 @@
+# Биометрическая аутентификация в Rosetta Messenger
+
+## Обзор
+
+В Android-приложение добавлена поддержка биометрической аутентификации (отпечаток пальца / Face ID), что позволяет пользователям разблокировать приложение без ввода пароля каждый раз.
+
+## Особенности реализации
+
+### 1. Безопасность
+
+- **Android Keystore**: Пароль шифруется с использованием ключа, хранящегося в Android Keystore
+- **Биометрическая защита**: Ключ требует биометрической аутентификации для расшифровки
+- **GCM шифрование**: Используется AES-GCM для шифрования пароля
+- **Автоматическая инвалидация**: При изменении биометрических данных ключ становится недействительным
+
+### 2. Компоненты
+
+#### BiometricAuthManager
+
+Основной менеджер для работы с биометрией:
+
+- `isBiometricAvailable()` - проверка доступности биометрии на устройстве
+- `encryptPassword()` - шифрование пароля с биометрической защитой
+- `decryptPassword()` - расшифровка пароля через биометрию
+- `removeBiometricData()` - удаление сохраненных данных
+
+#### BiometricPreferences
+
+Управление настройками биометрии:
+
+- Сохранение зашифрованных паролей для каждого аккаунта
+- Включение/отключение биометрии
+- Проверка наличия сохраненных данных
+
+#### Интеграция в UnlockScreen
+
+- Автоматическая попытка разблокировки при открытии экрана
+- Кнопка для включения/отключения биометрии
+- Автоматическое сохранение пароля после успешного входа
+
+### 3. Пользовательский опыт
+
+#### Первое использование
+
+1. Пользователь вводит пароль для разблокировки
+2. Если биометрия доступна, появляется опция "Включить биометрию"
+3. При включении пароль сохраняется зашифрованным
+4. При следующем входе автоматически появляется диалог биометрии
+
+#### Автоматическая разблокировка
+
+- При открытии экрана разблокировки автоматически запускается биометрическая аутентификация
+- Пользователь может отменить и ввести пароль вручную
+- При неудаче биометрии доступен ввод пароля
+
+#### Управление
+
+- Переключатель "Биометрия включена / Включить биометрию" под кнопкой Unlock
+- При отключении зашифрованный пароль удаляется
+- Можно включить/отключить в любой момент
+
+### 4. Поддерживаемые методы
+
+- **Отпечаток пальца** (Fingerprint)
+- **Face ID** (на устройствах с поддержкой)
+- **Iris** (на поддерживаемых устройствах)
+- Любые другие биометрические методы, поддерживаемые Android BiometricPrompt
+
+### 5. Требования
+
+- Android API 24+ (Android 7.0)
+- Устройство с биометрическим оборудованием
+- Настроенные биометрические данные в системе
+- Разрешение `USE_BIOMETRIC` (добавлено в AndroidManifest.xml)
+
+## Технические детали
+
+### Зависимости
+
+```kotlin
+implementation("androidx.biometric:biometric:1.1.0")
+```
+
+### Разрешения
+
+```xml
+
+```
+
+### Хранение данных
+
+- Зашифрованные пароли хранятся в DataStore
+- Отдельный пароль для каждого аккаунта
+- Формат: `encrypted_password_{publicKey}`
+
+### Шифрование
+
+- **Алгоритм**: AES/GCM/NoPadding
+- **Размер ключа**: 256 бит
+- **Хранилище**: Android Keystore
+- **IV**: Генерируется для каждого шифрования
+
+## Использование
+
+### Для разработчиков
+
+#### Проверка доступности биометрии
+
+```kotlin
+val biometricManager = BiometricAuthManager(context)
+when (biometricManager.isBiometricAvailable()) {
+ BiometricAvailability.Available -> {
+ // Биометрия доступна
+ }
+ is BiometricAvailability.NotAvailable -> {
+ // Недоступна (причина в reason)
+ }
+ is BiometricAvailability.NotEnrolled -> {
+ // Биометрия не настроена
+ }
+}
+```
+
+#### Шифрование пароля
+
+```kotlin
+biometricManager.encryptPassword(
+ activity = activity,
+ password = userPassword,
+ onSuccess = { encryptedPassword ->
+ // Сохранить зашифрованный пароль
+ },
+ onError = { errorMessage ->
+ // Обработка ошибки
+ },
+ onCancel = {
+ // Пользователь отменил
+ }
+)
+```
+
+#### Расшифровка пароля
+
+```kotlin
+biometricManager.decryptPassword(
+ activity = activity,
+ encryptedData = savedEncryptedPassword,
+ onSuccess = { decryptedPassword ->
+ // Использовать пароль для входа
+ },
+ onError = { errorMessage ->
+ // Показать ошибку
+ },
+ onCancel = {
+ // Показать ввод пароля
+ }
+)
+```
+
+## Безопасность
+
+### Что защищено
+
+✅ Пароль никогда не хранится в открытом виде
+✅ Ключ шифрования защищен биометрией
+✅ Ключ недействителен после смены биометрических данных
+✅ Требуется подтверждение для каждой расшифровки
+
+### Что НЕ защищено
+
+❌ Root-доступ может скомпрометировать Keystore
+❌ Вредоносное ПО с правами администратора
+
+## Тестирование
+
+### Эмулятор
+
+1. Settings → Security → Fingerprint
+2. Добавить отпечаток
+3. В терминале эмулятора: `adb -e emu finger touch 1`
+
+### Реальное устройство
+
+1. Настроить биометрию в системных настройках
+2. Запустить приложение
+3. Войти с паролем
+4. Включить биометрию
+5. Перезапустить приложение
+
+## Известные ограничения
+
+1. **Множественные аккаунты**: Биометрия настраивается отдельно для каждого аккаунта
+2. **Смена биометрии**: При изменении отпечатков/Face ID нужно заново включить биометрию
+3. **Фоновая работа**: Биометрия работает только когда приложение активно
+
+## Будущие улучшения
+
+- [ ] Настройка в профиле пользователя
+- [ ] Опция "Всегда спрашивать биометрию"
+- [ ] Статистика использования биометрии
+- [ ] Поддержка PIN-кода как альтернативы
+- [ ] Экспорт/импорт настроек биометрии
+
+## Заключение
+
+Биометрическая аутентификация значительно улучшает пользовательский опыт, позволяя быстро и безопасно разблокировать приложение. Реализация следует лучшим практикам Android Security и использует надежные криптографические методы.
diff --git a/docs/BIOMETRIC_USER_GUIDE_RU.md b/docs/BIOMETRIC_USER_GUIDE_RU.md
new file mode 100644
index 0000000..ee6e447
--- /dev/null
+++ b/docs/BIOMETRIC_USER_GUIDE_RU.md
@@ -0,0 +1,79 @@
+# Как использовать биометрическую аутентификацию
+
+## Первичная настройка
+
+1. **Откройте приложение и войдите с паролем**
+ - Выберите свой аккаунт
+ - Введите пароль
+ - Нажмите "Unlock"
+
+2. **Включите биометрию**
+ - Под кнопкой "Unlock" появится опция "Включить биометрию"
+ - Нажмите на неё
+ - Биометрия будет включена
+
+3. **Готово!**
+ - При следующем входе приложение автоматически предложит использовать отпечаток пальца / Face ID
+ - Больше не нужно вводить пароль
+
+## Вход с биометрией
+
+1. **Откройте приложение**
+ - Автоматически появится диалог биометрической аутентификации
+2. **Подтвердите биометрию**
+ - Приложите палец к сканеру или посмотрите в камеру
+ - Приложение разблокируется автоматически
+
+3. **Альтернатива**
+ - Можно нажать "Использовать пароль" в диалоге биометрии
+ - Или отключить биометрию и ввести пароль вручную
+
+## Отключение биометрии
+
+1. **На экране входа**
+ - Нажмите на переключатель "Биометрия включена"
+ - Биометрия будет отключена
+ - Зашифрованный пароль будет удален
+
+2. **При следующем входе**
+ - Нужно будет ввести пароль вручную
+
+## Требования
+
+- Устройство должно поддерживать биометрическую аутентификацию
+- В настройках системы должны быть настроены отпечаток пальца или Face ID
+- Android 7.0 или новее
+
+## Безопасность
+
+✅ **Ваш пароль надежно защищен**
+
+- Пароль шифруется специальным ключом в Android Keystore
+- Расшифровка возможна только через биометрию
+- При смене отпечатков/Face ID нужно заново включить биометрию
+
+✅ **Конфиденциальность**
+
+- Биометрические данные хранятся только в системе Android
+- Приложение не имеет доступа к вашим отпечаткам
+- Используется только для подтверждения личности
+
+## Troubleshooting
+
+### Биометрия не работает
+
+- Убедитесь, что биометрия настроена в системных настройках
+- Попробуйте отключить и снова включить биометрию в приложении
+- Проверьте, что сканер отпечатков чистый
+
+### Приложение не предлагает биометрию
+
+- Возможно, устройство не поддерживает биометрию
+- Убедитесь, что добавлен хотя бы один отпечаток/Face ID в системных настройках
+- Попробуйте перезапустить приложение
+
+### После обновления системы биометрия не работает
+
+- Это нормально при смене биометрических данных
+- Войдите с паролем
+- Заново включите биометрию