feat: Implement BiometricAuthManager for biometric authentication and password encryption/decryption
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user