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

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