feat: implement device verification flow with new UI components and protocol handling

This commit is contained in:
2026-02-18 04:40:22 +05:00
parent edff3b32c3
commit cacd6dc029
24 changed files with 1645 additions and 195 deletions

View File

@@ -37,6 +37,7 @@ import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
import com.rosetta.messenger.ui.chats.ChatDetailScreen
import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.RequestsListScreen
@@ -144,6 +145,7 @@ class MainActivity : FragmentActivity() {
else -> true
}
val isLoggedIn by accountManager.isLoggedIn.collectAsState(initial = null)
val protocolState by ProtocolManager.state.collectAsState()
var showSplash by remember { mutableStateOf(true) }
var showOnboarding by remember { mutableStateOf(true) }
var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) }
@@ -210,6 +212,9 @@ class MainActivity : FragmentActivity() {
"auth_new"
isLoggedIn != true && hasExistingAccount == true ->
"auth_unlock"
protocolState ==
ProtocolState.DEVICE_VERIFICATION_REQUIRED ->
"device_confirm"
else -> "main"
},
transitionSpec = {
@@ -430,6 +435,18 @@ class MainActivity : FragmentActivity() {
}
)
}
"device_confirm" -> {
DeviceConfirmScreen(
isDarkTheme = isDarkTheme,
onExit = {
currentAccount = null
scope.launch {
ProtocolManager.disconnect()
accountManager.logout()
}
}
)
}
}
}
}
@@ -690,7 +707,9 @@ fun MainScreen(
// Appearance: background blur color preference
val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val backgroundBlurColorId by
prefsManager.backgroundBlurColorId.collectAsState(initial = "avatar")
prefsManager
.backgroundBlurColorIdForAccount(accountPublicKey)
.collectAsState(initial = "avatar")
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
// AvatarRepository для работы с аватарами
@@ -920,7 +939,9 @@ fun MainScreen(
currentBlurColorId = backgroundBlurColorId,
onBack = { navStack = navStack.filterNot { it is Screen.Appearance } },
onBlurColorChange = { newId ->
mainScreenScope.launch { prefsManager.setBackgroundBlurColorId(newId) }
mainScreenScope.launch {
prefsManager.setBackgroundBlurColorId(accountPublicKey, newId)
}
},
onToggleTheme = onToggleTheme,
accountPublicKey = accountPublicKey,

View File

@@ -29,6 +29,8 @@ object CryptoManager {
private const val PBKDF2_ITERATIONS = 1000
private const val KEY_SIZE = 256
private const val SALT = "rosetta"
private const val PBKDF2_HMAC_SHA1 = "PBKDF2WithHmacSHA1"
private const val PBKDF2_HMAC_SHA256 = "PBKDF2WithHmacSHA256"
// 🚀 ОПТИМИЗАЦИЯ: Кэш для генерации ключей (seedPhrase -> KeyPair)
private val keyPairCache = mutableMapOf<String, KeyPairData>()
@@ -57,8 +59,13 @@ object CryptoManager {
* (чтобы кэш был горячий к моменту дешифровки)
*/
fun getPbkdf2Key(password: String): SecretKeySpec {
return pbkdf2KeyCache.getOrPut(password) {
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
return getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
}
private fun getPbkdf2Key(password: String, algorithm: String): SecretKeySpec {
val cacheKey = "$algorithm::$password"
return pbkdf2KeyCache.getOrPut(cacheKey) {
val factory = SecretKeyFactory.getInstance(algorithm)
val spec =
PBEKeySpec(
password.toCharArray(),
@@ -207,8 +214,8 @@ object CryptoManager {
/**
* Encrypt data with password using PBKDF2 + AES
*
* ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts):
* - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию
* ⚠️ ВАЖНО: Совместимость с Desktop (crypto-js 4.x):
* - PBKDF2WithHmacSHA256
* - Salt: "rosetta"
* - Iterations: 1000
* - Key size: 256 bit
@@ -231,17 +238,8 @@ object CryptoManager {
val encryptedChunks = mutableListOf<String>()
for (chunk in chunks) {
// Derive key using PBKDF2-HMAC-SHA1
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec =
PBEKeySpec(
password.toCharArray(),
SALT.toByteArray(Charsets.UTF_8),
PBKDF2_ITERATIONS,
KEY_SIZE
)
val secretKey = factory.generateSecret(spec)
val key = SecretKeySpec(secretKey.encoded, "AES")
// Desktop parity: PBKDF2-HMAC-SHA256
val key = getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
// Generate random IV
val iv = ByteArray(16)
@@ -262,19 +260,8 @@ object CryptoManager {
// Return chunked format: "CHNK:" + chunks joined by "::"
return "CHNK:" + encryptedChunks.joinToString("::")
} else {
// Single chunk (original behavior)
// Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!)
// crypto-js по умолчанию использует SHA1 для PBKDF2
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec =
PBEKeySpec(
password.toCharArray(),
SALT.toByteArray(Charsets.UTF_8),
PBKDF2_ITERATIONS,
KEY_SIZE
)
val secretKey = factory.generateSecret(spec)
val key = SecretKeySpec(secretKey.encoded, "AES")
// Single chunk (desktop parity)
val key = getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
// Generate random IV
val iv = ByteArray(16)
@@ -297,8 +284,8 @@ object CryptoManager {
/**
* Decrypt data with password
*
* ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts):
* - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию
* ⚠️ ВАЖНО: Desktop использует PBKDF2-SHA256.
* Для обратной совместимости с legacy Android данными пробуем также SHA1.
* - Salt: "rosetta"
* - Iterations: 1000
* - Key size: 256 bit
@@ -339,71 +326,83 @@ object CryptoManager {
/** 🔐 Внутренняя функция расшифровки (без кэширования результата) */
private fun decryptWithPasswordInternal(encryptedData: String, password: String): String? {
return try {
// 🚀 Получаем кэшированный PBKDF2 ключ
val key = getPbkdf2Key(password)
val keysToTry =
listOf(
getPbkdf2Key(password, PBKDF2_HMAC_SHA256),
getPbkdf2Key(password, PBKDF2_HMAC_SHA1)
)
keysToTry.forEach { key ->
// Check for old format: base64-encoded string containing hex
if (isOldFormat(encryptedData)) {
val decoded = String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
val parts = decoded.split(":")
if (parts.size != 2) return null
try {
val decoded =
String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
val parts = decoded.split(":")
if (parts.size != 2) return@forEach
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Decrypt with AES-256-CBC (используем кэшированный ключ!)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
return String(decrypted, Charsets.UTF_8)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
return String(decrypted, Charsets.UTF_8)
} catch (_: Exception) {
return@forEach
}
}
// Check for chunked format
if (encryptedData.startsWith("CHNK:")) {
val chunkStrings = encryptedData.substring(5).split("::")
val decompressedParts = mutableListOf<ByteArray>()
try {
val chunkStrings = encryptedData.substring(5).split("::")
val decompressedParts = mutableListOf<ByteArray>()
for (chunkString in chunkStrings) {
val parts = chunkString.split(":")
if (parts.size != 2) return null
for (chunkString in chunkStrings) {
val parts = chunkString.split(":")
if (parts.size != 2) return@forEach
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
// Decrypt with AES-256-CBC (используем кэшированный ключ!)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
decompressedParts.add(decrypted)
decompressedParts.add(decrypted)
}
// Concatenate all decrypted chunks
val allBytes = decompressedParts.fold(ByteArray(0)) { acc, arr -> acc + arr }
// Decompress the concatenated data
return String(decompress(allBytes), Charsets.UTF_8)
} catch (_: Exception) {
return@forEach
}
// Concatenate all decrypted chunks
val allBytes = decompressedParts.fold(ByteArray(0)) { acc, arr -> acc + arr }
// Decompress the concatenated data
return String(decompress(allBytes), Charsets.UTF_8)
}
// New format: base64 "iv:ciphertext"
val parts = encryptedData.split(":")
if (parts.size != 2) return null
try {
val parts = encryptedData.split(":")
if (parts.size != 2) return@forEach
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
// Decrypt with AES-256-CBC (используем кэшированный ключ!)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
// Decompress (zlib inflate - совместимо с pako.inflate в JS)
String(decompress(decrypted), Charsets.UTF_8)
} catch (e: Exception) {
null
// Decompress (совместимо с desktop + fallback для legacy)
return String(decompress(decrypted), Charsets.UTF_8)
} catch (_: Exception) {
return@forEach
}
}
return null
}
/** Check if data is in old format (base64-encoded hex with ":") */
@@ -425,16 +424,13 @@ object CryptoManager {
}
/**
* RAW Deflate сжатие (без zlib header)
* Сжатие данных для encodeWithPassword.
*
* ⚠️ ВАЖНО: nowrap=true для совместимости с pako.deflate() в JS!
* - pako.deflate() создаёт RAW deflate поток (без 2-byte zlib header)
* - Java Deflater() по умолчанию создаёт zlib поток (с header 78 9C)
* - Поэтому используем Deflater(level, true) где true = nowrap
* Десктоп использует pako.deflate (zlib wrapper), поэтому тут должен быть обычный
* Deflater без nowrap=true.
*/
private fun compress(data: ByteArray): ByteArray {
// nowrap=true = RAW deflate (совместимо с pako.deflate)
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true)
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION)
deflater.setInput(data)
deflater.finish()
@@ -450,27 +446,38 @@ object CryptoManager {
}
/**
* RAW Inflate декомпрессия (без zlib header)
*
* ⚠️ ВАЖНО: nowrap=true для совместимости с pako.inflate() в JS!
* - pako.inflate() ожидает RAW deflate поток
* - Java Inflater() по умолчанию ожидает zlib поток (с header)
* - Поэтому используем Inflater(true) где true = nowrap
* Декомпрессия с обратной совместимостью:
* 1) сначала zlib (desktop/new android),
* 2) затем raw deflate (legacy android данные).
*/
private fun decompress(data: ByteArray): ByteArray {
// nowrap=true = RAW inflate (совместимо с pako.inflate)
val inflater = Inflater(true)
return try {
inflate(data, nowrap = false)
} catch (_: Exception) {
inflate(data, nowrap = true)
}
}
private fun inflate(data: ByteArray, nowrap: Boolean): ByteArray {
val inflater = Inflater(nowrap)
inflater.setInput(data)
val outputStream = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
if (count == 0) {
if (inflater.needsInput() || inflater.needsDictionary()) {
throw IllegalStateException("Inflate failed: incomplete or unsupported stream")
}
}
outputStream.write(buffer, 0, count)
}
inflater.end() // Освобождаем ресурсы
outputStream.close()
return outputStream.toByteArray()
val result = outputStream.toByteArray()
if (result.isEmpty()) throw IllegalStateException("Decompression produced empty output")
return result
}
/**

View File

@@ -512,6 +512,23 @@ object MessageCrypto {
myPrivateKey: String
): String = decryptIncomingFull(ciphertext, encryptedKey, myPrivateKey).plaintext
fun decryptIncomingFullWithPlainKey(
ciphertext: String,
plainKeyAndNonce: ByteArray
): DecryptedIncoming {
require(plainKeyAndNonce.size >= 56) { "Invalid plainKeyAndNonce size: ${plainKeyAndNonce.size}" }
val key = plainKeyAndNonce.copyOfRange(0, 32)
val nonce = plainKeyAndNonce.copyOfRange(32, 56)
val plaintext = decryptMessage(ciphertext, key.toHex(), nonce.toHex())
return DecryptedIncoming(plaintext, plainKeyAndNonce)
}
fun decryptIncomingWithPlainKey(
ciphertext: String,
plainKeyAndNonce: ByteArray
): String = decryptIncomingFullWithPlainKey(ciphertext, plainKeyAndNonce).plaintext
/**
* Расшифровка MESSAGES attachment blob
* Формат: ivBase64:ciphertextBase64

View File

@@ -49,6 +49,7 @@ class MessageRepository private constructor(private val context: Context) {
private val messageDao = database.messageDao()
private val dialogDao = database.dialogDao()
private val avatarDao = database.avatarDao()
private val syncTimeDao = database.syncTimeDao()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -95,6 +96,10 @@ class MessageRepository private constructor(private val context: Context) {
companion object {
@Volatile private var INSTANCE: MessageRepository? = null
const val SYSTEM_SAFE_PUBLIC_KEY = "0x000000000000000000000000000000000000000002"
const val SYSTEM_SAFE_TITLE = "Safe"
const val SYSTEM_SAFE_USERNAME = "safe"
// 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов
// LRU кэш с ограничением 1000 элементов - защита от race conditions
private val processedMessageIds =
@@ -136,6 +141,86 @@ class MessageRepository private constructor(private val context: Context) {
}
}
suspend fun addDeviceLoginSystemMessage(
ipAddress: String,
deviceId: String,
deviceName: String,
deviceOs: String
) {
val account = currentAccount ?: return
val privateKey = currentPrivateKey ?: return
val safeIp = ipAddress.ifBlank { "unknown" }
val safeDeviceName = deviceName.ifBlank { "Unknown device" }
val safeDeviceOs = deviceOs.ifBlank { "unknown" }
val safeDeviceId = dotCenterIfNeeded(deviceId.ifBlank { "unknown" }, maxLength = 12, side = 4)
val messageText =
"""
Attempt to login from a new device
We detected a login to your account from $safeIp a new device by seed phrase. If this was you, you can safely ignore this message.
Arch: $safeDeviceOs
IP: $safeIp
Device: $safeDeviceName
ID: $safeDeviceId
"""
.trimIndent()
val encryptedPlainMessage =
try {
CryptoManager.encryptWithPassword(messageText, privateKey)
} catch (_: Exception) {
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val dialogKey = getDialogKey(SYSTEM_SAFE_PUBLIC_KEY)
val inserted =
messageDao.insertMessage(
MessageEntity(
account = account,
fromPublicKey = SYSTEM_SAFE_PUBLIC_KEY,
toPublicKey = account,
content = "",
timestamp = timestamp,
chachaKey = "",
read = 0,
fromMe = 0,
delivered = DeliveryStatus.DELIVERED.value,
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = "[]",
dialogKey = dialogKey
)
)
if (inserted == -1L) return
val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY)
dialogDao.insertDialog(
DialogEntity(
id = existing?.id ?: 0,
account = account,
opponentKey = SYSTEM_SAFE_PUBLIC_KEY,
opponentTitle = existing?.opponentTitle?.ifBlank { SYSTEM_SAFE_TITLE } ?: SYSTEM_SAFE_TITLE,
opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
?: SYSTEM_SAFE_USERNAME,
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1),
iHaveSent = 1
)
)
dialogDao.updateDialogFromMessages(account, SYSTEM_SAFE_PUBLIC_KEY)
_newMessageEvents.tryEmit(dialogKey)
}
/** Инициализация с текущим аккаунтом */
fun initialize(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis()
@@ -163,6 +248,20 @@ class MessageRepository private constructor(private val context: Context) {
return currentAccount != null && currentPrivateKey != null
}
suspend fun getLastSyncTimestamp(): Long {
val account = currentAccount ?: return 0L
return syncTimeDao.getLastSync(account) ?: 0L
}
suspend fun updateLastSyncTimestamp(timestamp: Long) {
if (timestamp <= 0) return
val account = currentAccount ?: return
val existing = syncTimeDao.getLastSync(account) ?: 0L
if (timestamp > existing) {
syncTimeDao.upsert(AccountSyncTimeEntity(account = account, lastSync = timestamp))
}
}
/** Получить поток сообщений для диалога */
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
val dialogKey = getDialogKey(opponentKey)
@@ -238,6 +337,11 @@ class MessageRepository private constructor(private val context: Context) {
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val aesChachaKey =
CryptoManager.encryptWithPassword(
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
privateKey
)
// 📝 LOG: Шифрование успешно
MessageLogger.logEncryptionSuccess(
@@ -344,6 +448,7 @@ class MessageRepository private constructor(private val context: Context) {
this.toPublicKey = toPublicKey
this.content = encryptedContent
this.chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
this.messageId = messageId
@@ -397,11 +502,15 @@ class MessageRepository private constructor(private val context: Context) {
timestamp = packet.timestamp
)
val isOwnMessage = packet.fromPublicKey == account
// 🔥 Проверяем, не заблокирован ли отправитель
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
if (isBlocked) {
MessageLogger.logBlockedSender(packet.fromPublicKey)
return
if (!isOwnMessage) {
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
if (isBlocked) {
MessageLogger.logBlockedSender(packet.fromPublicKey)
return
}
}
// 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed)
@@ -435,17 +544,37 @@ class MessageRepository private constructor(private val context: Context) {
return
}
val dialogKey = getDialogKey(packet.fromPublicKey)
val dialogOpponentKey = if (isOwnMessage) packet.toPublicKey else packet.fromPublicKey
val dialogKey = getDialogKey(dialogOpponentKey)
try {
// 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!)
// Desktop: хранит зашифрованный ключ, расшифровывает только при использовании
// Buffer.from(await decrypt(message.chacha_key, privatePlain),
// "binary").toString('utf-8')
val plainKeyAndNonce =
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
?.toByteArray(Charsets.ISO_8859_1)
} else {
null
}
if (isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
ProtocolManager.addLog(
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
)
}
if (isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
MessageLogger.debug(
"📥 OWN SYNC fallback: aesChachaKey is missing, trying chachaKey decrypt"
)
}
// Расшифровываем
val plainText =
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
}
// 📝 LOG: Расшифровка успешна
MessageLogger.logDecryptionSuccess(
@@ -459,18 +588,25 @@ class MessageRepository private constructor(private val context: Context) {
serializeAttachmentsWithDecryption(
packet.attachments,
packet.chachaKey,
privateKey
privateKey,
plainKeyAndNonce
)
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
processImageAttachments(packet.attachments, packet.chachaKey, privateKey)
processImageAttachments(
packet.attachments,
packet.chachaKey,
privateKey,
plainKeyAndNonce
)
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
processAvatarAttachments(
packet.attachments,
packet.fromPublicKey,
packet.chachaKey,
privateKey
privateKey,
plainKeyAndNonce
)
// 🔒 Шифруем plainMessage с использованием приватного ключа
@@ -486,7 +622,7 @@ class MessageRepository private constructor(private val context: Context) {
timestamp = packet.timestamp,
chachaKey = packet.chachaKey,
read = 0,
fromMe = 0,
fromMe = if (isOwnMessage) 1 else 0,
delivered = DeliveryStatus.DELIVERED.value,
messageId = messageId, // 🔥 Используем сгенерированный messageId!
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
@@ -506,10 +642,10 @@ class MessageRepository private constructor(private val context: Context) {
}
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
dialogDao.updateDialogFromMessages(account, dialogOpponentKey)
// 🔥 Логируем что записалось в диалог
val dialog = dialogDao.getDialog(account, packet.fromPublicKey)
val dialog = dialogDao.getDialog(account, dialogOpponentKey)
MessageLogger.logDialogUpdate(
dialogKey = dialogKey,
lastMessage = dialog?.lastMessage,
@@ -517,7 +653,7 @@ class MessageRepository private constructor(private val context: Context) {
)
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
requestUserInfo(packet.fromPublicKey)
requestUserInfo(dialogOpponentKey)
// Обновляем кэш только если сообщение новое
if (!stillExists) {
@@ -534,6 +670,11 @@ class MessageRepository private constructor(private val context: Context) {
} catch (e: Exception) {
// 📝 LOG: Ошибка обработки
MessageLogger.logDecryptionError(messageId, e)
ProtocolManager.addLog(
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
)
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
processedMessageIds.remove(messageId)
e.printStackTrace()
}
}
@@ -573,17 +714,24 @@ class MessageRepository private constructor(private val context: Context) {
MessageLogger.debug("👁 READ PACKET from: ${packet.fromPublicKey.take(16)}...")
// Синхронизация read может прийти как:
// 1) from=opponent, to=account (обычный read от собеседника)
// 2) from=account, to=opponent (read c другого устройства этого же аккаунта)
val opponentKey =
if (packet.fromPublicKey == account) packet.toPublicKey else packet.fromPublicKey
if (opponentKey.isBlank()) return
// Проверяем последнее сообщение ДО обновления
val lastMsgBefore = messageDao.getLastMessageDebug(account, packet.fromPublicKey)
val lastMsgBefore = messageDao.getLastMessageDebug(account, opponentKey)
// Отмечаем все наши исходящие сообщения к этому собеседнику как прочитанные
messageDao.markAllAsRead(account, packet.fromPublicKey)
messageDao.markAllAsRead(account, opponentKey)
// 🔥 DEBUG: Проверяем последнее сообщение ПОСЛЕ обновления
val lastMsgAfter = messageDao.getLastMessageDebug(account, packet.fromPublicKey)
val lastMsgAfter = messageDao.getLastMessageDebug(account, opponentKey)
// Обновляем кэш - все исходящие сообщения помечаем как прочитанные
val dialogKey = getDialogKey(packet.fromPublicKey)
val dialogKey = getDialogKey(opponentKey)
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
messageCache[dialogKey]?.let { flow ->
flow.value =
@@ -596,13 +744,13 @@ class MessageRepository private constructor(private val context: Context) {
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
// 📝 LOG: Статус прочтения
MessageLogger.logReadStatus(fromPublicKey = packet.fromPublicKey, messagesCount = readCount)
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
dialogDao.updateDialogFromMessages(account, opponentKey)
// Логируем что записалось в диалог
val dialog = dialogDao.getDialog(account, packet.fromPublicKey)
val dialog = dialogDao.getDialog(account, opponentKey)
}
/**
@@ -719,6 +867,13 @@ class MessageRepository private constructor(private val context: Context) {
}
}
private fun dotCenterIfNeeded(value: String, maxLength: Int, side: Int): String {
if (value.length <= maxLength) return value
val safeSide = side.coerceAtLeast(1)
if (safeSide * 2 >= value.length) return value
return value.take(safeSide) + "..." + value.takeLast(safeSide)
}
private suspend fun updateDialog(
opponentKey: String,
lastMessage: String,
@@ -919,7 +1074,8 @@ class MessageRepository private constructor(private val context: Context) {
attachments: List<MessageAttachment>,
fromPublicKey: String,
encryptedKey: String,
privateKey: String
privateKey: String,
plainKeyAndNonce: ByteArray? = null
) {
for (attachment in attachments) {
@@ -929,11 +1085,14 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
if (decryptedBlob != null) {
// 2. Сохраняем аватар в кэш
@@ -964,7 +1123,8 @@ class MessageRepository private constructor(private val context: Context) {
private fun processImageAttachments(
attachments: List<MessageAttachment>,
encryptedKey: String,
privateKey: String
privateKey: String,
plainKeyAndNonce: ByteArray? = null
) {
val publicKey = currentAccount ?: return
@@ -975,11 +1135,14 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
if (decryptedBlob != null) {
// 2. Сохраняем в файл (как в desktop)
@@ -1008,7 +1171,8 @@ class MessageRepository private constructor(private val context: Context) {
private fun serializeAttachmentsWithDecryption(
attachments: List<MessageAttachment>,
encryptedKey: String,
privateKey: String
privateKey: String,
plainKeyAndNonce: ByteArray? = null
): String {
if (attachments.isEmpty()) return "[]"
@@ -1022,11 +1186,14 @@ class MessageRepository private constructor(private val context: Context) {
try {
// 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob =
MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
if (decryptedBlob != null) {
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)

View File

@@ -49,8 +49,9 @@ class PreferencesManager(private val context: Context) {
// Language
val APP_LANGUAGE = stringPreferencesKey("app_language") // "en", "ru", etc.
// Appearance / Customization
val BACKGROUND_BLUR_COLOR_ID = stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
// Appearance / Customization (legacy global key)
val BACKGROUND_BLUR_COLOR_ID =
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
// Pinned Chats (max 3)
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
@@ -219,6 +220,12 @@ class PreferencesManager(private val context: Context) {
// 🎨 APPEARANCE / CUSTOMIZATION
// ═════════════════════════════════════════════════════════════
private fun buildBackgroundBlurColorKey(account: String): Preferences.Key<String>? {
val trimmedAccount = account.trim()
if (trimmedAccount.isBlank()) return null
return stringPreferencesKey("background_blur_color_id::$trimmedAccount")
}
val backgroundBlurColorId: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[BACKGROUND_BLUR_COLOR_ID] ?: "avatar" // Default: use avatar blur
@@ -228,6 +235,21 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[BACKGROUND_BLUR_COLOR_ID] = value }
}
fun backgroundBlurColorIdForAccount(account: String): Flow<String> =
context.dataStore.data.map { preferences ->
val scopedKey = buildBackgroundBlurColorKey(account)
if (scopedKey != null) preferences[scopedKey] ?: "avatar" else "avatar"
}
suspend fun setBackgroundBlurColorId(account: String, value: String) {
val scopedKey = buildBackgroundBlurColorKey(account)
context.dataStore.edit { preferences ->
if (scopedKey != null) {
preferences[scopedKey] = value
}
}
}
// ═════════════════════════════════════════════════════════════
// 📌 PINNED CHATS
// ═════════════════════════════════════════════════════════════

View File

@@ -14,8 +14,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
MessageEntity::class,
DialogEntity::class,
BlacklistEntity::class,
AvatarCacheEntity::class],
version = 11,
AvatarCacheEntity::class,
AccountSyncTimeEntity::class],
version = 12,
exportSchema = false
)
abstract class RosettaDatabase : RoomDatabase() {
@@ -24,6 +25,7 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun dialogDao(): DialogDao
abstract fun blacklistDao(): BlacklistDao
abstract fun avatarDao(): AvatarDao
abstract fun syncTimeDao(): SyncTimeDao
companion object {
@Volatile private var INSTANCE: RosettaDatabase? = null
@@ -132,6 +134,20 @@ abstract class RosettaDatabase : RoomDatabase() {
}
}
private val MIGRATION_11_12 =
object : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS accounts_sync_times (
account TEXT NOT NULL PRIMARY KEY,
last_sync INTEGER NOT NULL
)
"""
)
}
}
fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE
?: synchronized(this) {
@@ -151,7 +167,8 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11
MIGRATION_10_11,
MIGRATION_11_12
)
.fallbackToDestructiveMigration() // Для разработки - только
// если миграция не

View File

@@ -0,0 +1,24 @@
package com.rosetta.messenger.database
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
@Entity(tableName = "accounts_sync_times")
data class AccountSyncTimeEntity(
@PrimaryKey @ColumnInfo(name = "account") val account: String,
@ColumnInfo(name = "last_sync") val lastSync: Long
)
@Dao
interface SyncTimeDao {
@Query("SELECT last_sync FROM accounts_sync_times WHERE account = :account LIMIT 1")
suspend fun getLastSync(account: String): Long?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: AccountSyncTimeEntity)
}

View File

@@ -0,0 +1,66 @@
package com.rosetta.messenger.network
enum class DeviceState(val value: Int) {
ONLINE(0),
OFFLINE(1);
companion object {
fun fromValue(value: Int): DeviceState {
return entries.firstOrNull { it.value == value } ?: OFFLINE
}
}
}
enum class DeviceVerifyState(val value: Int) {
VERIFIED(0),
NOT_VERIFIED(1);
companion object {
fun fromValue(value: Int): DeviceVerifyState {
return entries.firstOrNull { it.value == value } ?: VERIFIED
}
}
}
data class DeviceEntry(
val deviceId: String,
val deviceName: String,
val deviceOs: String,
val deviceStatus: DeviceState,
val deviceVerify: DeviceVerifyState
)
class PacketDeviceList : Packet() {
var devices: List<DeviceEntry> = emptyList()
override fun getPacketId(): Int = 0x17
override fun receive(stream: Stream) {
val deviceCount = stream.readInt16()
val parsedDevices = mutableListOf<DeviceEntry>()
repeat(deviceCount) {
parsedDevices += DeviceEntry(
deviceId = stream.readString(),
deviceName = stream.readString(),
deviceOs = stream.readString(),
deviceStatus = DeviceState.fromValue(stream.readInt8()),
deviceVerify = DeviceVerifyState.fromValue(stream.readInt8())
)
}
devices = parsedDevices
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeInt16(devices.size)
devices.forEach { device ->
stream.writeString(device.deviceId)
stream.writeString(device.deviceName)
stream.writeString(device.deviceOs)
stream.writeInt8(device.deviceStatus.value)
stream.writeInt8(device.deviceVerify.value)
}
return stream
}
}

View File

@@ -0,0 +1,33 @@
package com.rosetta.messenger.network
data class DeviceInfo(
var deviceId: String = "",
var deviceName: String = "",
var deviceOs: String = ""
)
class PacketDeviceNew : Packet() {
var ipAddress: String = ""
var device: DeviceInfo = DeviceInfo()
override fun getPacketId(): Int = 0x09
override fun receive(stream: Stream) {
ipAddress = stream.readString()
device = DeviceInfo(
deviceId = stream.readString(),
deviceName = stream.readString(),
deviceOs = stream.readString()
)
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(ipAddress)
stream.writeString(device.deviceId)
stream.writeString(device.deviceName)
stream.writeString(device.deviceOs)
return stream
}
}

View File

@@ -0,0 +1,32 @@
package com.rosetta.messenger.network
enum class DeviceResolveSolution(val value: Int) {
ACCEPT(0),
DECLINE(1);
companion object {
fun fromValue(value: Int): DeviceResolveSolution {
return entries.firstOrNull { it.value == value } ?: DECLINE
}
}
}
class PacketDeviceResolve : Packet() {
var deviceId: String = ""
var solution: DeviceResolveSolution = DeviceResolveSolution.DECLINE
override fun getPacketId(): Int = 0x18
override fun receive(stream: Stream) {
deviceId = stream.readString()
solution = DeviceResolveSolution.fromValue(stream.readInt8())
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(deviceId)
stream.writeInt8(solution.value)
return stream
}
}

View File

@@ -1,5 +1,22 @@
package com.rosetta.messenger.network
enum class HandshakeState(val value: Int) {
COMPLETED(0),
NEED_DEVICE_VERIFICATION(1);
companion object {
fun fromValue(value: Int): HandshakeState {
return entries.firstOrNull { it.value == value } ?: COMPLETED
}
}
}
data class HandshakeDevice(
var deviceId: String = "",
var deviceName: String = "",
var deviceOs: String = ""
)
/**
* Handshake packet (ID: 0x00)
* First packet sent by client to authenticate with the server
@@ -9,6 +26,8 @@ class PacketHandshake : Packet() {
var publicKey: String = ""
var protocolVersion: Int = 1
var heartbeatInterval: Int = 15
var device: HandshakeDevice = HandshakeDevice()
var handshakeState: HandshakeState = HandshakeState.NEED_DEVICE_VERIFICATION
override fun getPacketId(): Int = 0x00
@@ -17,6 +36,12 @@ class PacketHandshake : Packet() {
publicKey = stream.readString()
protocolVersion = stream.readInt8()
heartbeatInterval = stream.readInt8()
device = HandshakeDevice(
deviceId = stream.readString(),
deviceName = stream.readString(),
deviceOs = stream.readString()
)
handshakeState = HandshakeState.fromValue(stream.readInt8())
}
override fun send(): Stream {
@@ -26,6 +51,10 @@ class PacketHandshake : Packet() {
stream.writeString(publicKey)
stream.writeInt8(protocolVersion)
stream.writeInt8(heartbeatInterval)
stream.writeString(device.deviceId)
stream.writeString(device.deviceName)
stream.writeString(device.deviceOs)
stream.writeInt8(handshakeState.value)
return stream
}
}

View File

@@ -12,6 +12,7 @@ class PacketMessage : Packet() {
var timestamp: Long = 0
var privateKey: String = "" // Hash приватного ключа (для авторизации)
var messageId: String = ""
var aesChachaKey: String = "" // ChaCha key+nonce зашифрованный приватным ключом отправителя
var attachments: List<MessageAttachment> = emptyList()
override fun getPacketId(): Int = 0x06
@@ -36,6 +37,7 @@ class PacketMessage : Packet() {
))
}
attachments = attachmentsList
aesChachaKey = stream.readString()
}
override fun send(): Stream {
@@ -56,6 +58,7 @@ class PacketMessage : Packet() {
stream.writeString(attachment.blob)
stream.writeInt8(attachment.type.value)
}
stream.writeString(aesChachaKey)
return stream
}

View File

@@ -0,0 +1,37 @@
package com.rosetta.messenger.network
enum class SyncStatus(val value: Int) {
NOT_NEEDED(0),
BATCH_START(1),
BATCH_END(2);
companion object {
fun fromValue(value: Int): SyncStatus {
return entries.firstOrNull { it.value == value } ?: NOT_NEEDED
}
}
}
/**
* Sync packet (ID: 0x19)
* Используется для батчевой синхронизации сообщений и read-статусов
*/
class PacketSync : Packet() {
var status: SyncStatus = SyncStatus.NOT_NEEDED
var timestamp: Long = 0
override fun getPacketId(): Int = 0x19
override fun receive(stream: Stream) {
status = SyncStatus.fromValue(stream.readInt8())
timestamp = stream.readInt64()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeInt8(status.value)
stream.writeInt64(timestamp)
return stream
}
}

View File

@@ -16,6 +16,7 @@ enum class ProtocolState {
CONNECTING,
CONNECTED,
HANDSHAKING,
DEVICE_VERIFICATION_REQUIRED,
AUTHENTICATED
}
@@ -102,6 +103,7 @@ class Protocol(
// Last used credentials for reconnection
private var lastPublicKey: String? = null
private var lastPrivateHash: String? = null
private var lastDevice: HandshakeDevice = HandshakeDevice()
// Getters for ProtocolManager to fetch own profile
fun getPublicKey(): String? = lastPublicKey
@@ -121,21 +123,40 @@ class Protocol(
0x06 to { PacketMessage() },
0x07 to { PacketRead() },
0x08 to { PacketDelivery() },
0x09 to { PacketChunk() },
0x0B to { PacketTyping() }
0x09 to { PacketDeviceNew() },
0x0B to { PacketTyping() },
0x0F to { PacketRequestTransport() },
0x17 to { PacketDeviceList() },
0x18 to { PacketDeviceResolve() },
0x19 to { PacketSync() }
)
init {
// Register handshake response handler
waitPacket(0x00) { packet ->
if (packet is PacketHandshake) {
log("✅ HANDSHAKE SUCCESS: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s")
handshakeJob?.cancel()
handshakeComplete = true
setState(ProtocolState.AUTHENTICATED, "Handshake response received")
flushPacketQueue()
// Start heartbeat with interval from server
when (packet.handshakeState) {
HandshakeState.COMPLETED -> {
log("✅ HANDSHAKE COMPLETE: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s")
handshakeComplete = true
setState(ProtocolState.AUTHENTICATED, "Handshake completed")
flushPacketQueue()
}
HandshakeState.NEED_DEVICE_VERIFICATION -> {
log("🔐 HANDSHAKE NEEDS DEVICE VERIFICATION")
handshakeComplete = false
setState(
ProtocolState.DEVICE_VERIFICATION_REQUIRED,
"Handshake requires device verification"
)
packetQueue.clear()
}
}
// Keep heartbeat in both handshake states to maintain server session.
startHeartbeat(packet.heartbeatInterval)
}
}
@@ -171,7 +192,10 @@ class Protocol(
val currentState = _state.value
val socketAlive = webSocket != null
if (currentState == ProtocolState.AUTHENTICATED) {
if (
currentState == ProtocolState.AUTHENTICATED ||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED
) {
val sent = webSocket?.send("heartbeat") ?: false
if (sent) {
log("💓 Heartbeat OK (socket=$socketAlive, state=$currentState)")
@@ -184,7 +208,9 @@ class Protocol(
}
}
} else {
log("💔 HEARTBEAT SKIPPED: state=$currentState (not AUTHENTICATED), socket=$socketAlive")
log(
"💔 HEARTBEAT SKIPPED: state=$currentState (not ready), socket=$socketAlive"
)
}
} catch (e: Exception) {
log("💔 HEARTBEAT EXCEPTION: ${e.message}")
@@ -200,7 +226,11 @@ class Protocol(
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
if (currentState == ProtocolState.AUTHENTICATED || currentState == ProtocolState.HANDSHAKING) {
if (
currentState == ProtocolState.AUTHENTICATED ||
currentState == ProtocolState.HANDSHAKING ||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED
) {
log("✅ Already authenticated or handshaking, skipping connect()")
return
}
@@ -264,7 +294,7 @@ class Protocol(
lastPublicKey?.let { publicKey ->
lastPrivateHash?.let { privateHash ->
log("🤝 Auto-starting handshake with saved credentials")
startHandshake(publicKey, privateHash)
startHandshake(publicKey, privateHash, lastDevice)
}
} ?: log("⚠️ No saved credentials, waiting for manual handshake")
} else {
@@ -308,19 +338,24 @@ class Protocol(
/**
* Start handshake with server
*/
fun startHandshake(publicKey: String, privateHash: String) {
fun startHandshake(publicKey: String, privateHash: String, device: HandshakeDevice) {
log("🤝 Starting handshake...")
log(" Public key: ${publicKey.take(20)}...")
log(" Private hash: ${privateHash.take(20)}...")
log(" Current state: ${_state.value}")
// Detect account switch: already authenticated but with different credentials
val switchingAccount = (_state.value == ProtocolState.AUTHENTICATED || _state.value == ProtocolState.HANDSHAKING) &&
val switchingAccount = (
_state.value == ProtocolState.AUTHENTICATED ||
_state.value == ProtocolState.HANDSHAKING ||
_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED
) &&
lastPublicKey != null && lastPublicKey != publicKey
// Save credentials for reconnection
lastPublicKey = publicKey
lastPrivateHash = privateHash
lastDevice = device
// If switching accounts, force disconnect and reconnect with new credentials
if (switchingAccount) {
@@ -341,6 +376,11 @@ class Protocol(
return
}
if (_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED) {
log("⚠️ HANDSHAKE IGNORED: Waiting for device verification")
return
}
if (_state.value != ProtocolState.CONNECTED) {
log("⚠️ HANDSHAKE DEFERRED: Not connected (state=${_state.value}), will handshake after connection")
connect()
@@ -353,6 +393,7 @@ class Protocol(
val handshake = PacketHandshake().apply {
this.publicKey = publicKey
this.privateKey = privateHash
this.device = device
}
sendPacketDirect(handshake)
@@ -411,10 +452,12 @@ class Protocol(
}
try {
// 📦 Используем Chunker для отправки (как в Desktop)
// Если пакет большой, он будет разбит на части автоматически
val chunker = Chunker(socket) { msg -> log(msg) }
chunker.send(stream)
val sent = socket.send(ByteString.of(*data))
if (!sent) {
log("❌ WebSocket rejected packet ${packet.getPacketId()}, re-queueing")
packetQueue.add(packet)
return
}
log("✅ Packet ${packet.getPacketId()} sent successfully")
} catch (e: Exception) {
log("❌ Exception sending packet ${packet.getPacketId()}: ${e.message}")
@@ -554,6 +597,7 @@ class Protocol(
*/
fun isConnected(): Boolean = _state.value == ProtocolState.CONNECTED ||
_state.value == ProtocolState.HANDSHAKING ||
_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
_state.value == ProtocolState.AUTHENTICATED
/**

View File

@@ -1,15 +1,18 @@
package com.rosetta.messenger.network
import android.content.Context
import android.os.Build
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.resume
/**
@@ -21,6 +24,9 @@ object ProtocolManager {
// Server address - same as React Native version
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
private const val DEVICE_PREFS = "rosetta_protocol"
private const val DEVICE_ID_KEY = "device_id"
private const val DEVICE_ID_LENGTH = 128
private var protocol: Protocol? = null
private var messageRepository: MessageRepository? = null
@@ -36,6 +42,13 @@ object ProtocolManager {
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
// Connected devices and pending verification requests
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
val devices: StateFlow<List<DeviceEntry>> = _devices.asStateFlow()
private val _pendingDeviceVerification = MutableStateFlow<DeviceEntry?>(null)
val pendingDeviceVerification: StateFlow<DeviceEntry?> = _pendingDeviceVerification.asStateFlow()
// Сигнал обновления own profile (username/name загружены с сервера)
private val _ownProfileUpdated = MutableStateFlow(0L)
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
@@ -50,6 +63,10 @@ object ProtocolManager {
// 🚀 Флаг для включения UI логов (по умолчанию ВЫКЛЮЧЕНО - это вызывало ANR!)
private var uiLogsEnabled = false
private var lastProtocolState: ProtocolState? = null
@Volatile private var syncBatchInProgress = false
@Volatile private var resyncRequiredAfterAccountInit = false
private val inboundPacketTasks = AtomicInteger(0)
fun addLog(message: String) {
val timestamp = dateFormat.format(Date())
@@ -85,7 +102,11 @@ object ProtocolManager {
private fun setupStateMonitoring() {
scope.launch {
getProtocol().state.collect { newState ->
// State monitoring without logging
val previous = lastProtocolState
if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) {
onAuthenticated()
}
lastProtocolState = newState
}
}
}
@@ -95,9 +116,13 @@ object ProtocolManager {
* Должен вызываться после авторизации пользователя
*/
fun initializeAccount(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis()
syncBatchInProgress = false
messageRepository?.initialize(publicKey, privateKey)
}
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
resyncRequiredAfterAccountInit = false
requestSynchronize()
}
}
/**
* Настройка обработчиков пакетов
@@ -115,9 +140,17 @@ object ProtocolManager {
}
send(deliveryPacket)
scope.launch {
launchInboundPacketTask {
val repository = messageRepository
if (repository == null || !repository.isInitialized()) {
requireResyncAfterAccountInit("⏳ Incoming message before account init, scheduling re-sync")
return@launchInboundPacketTask
}
try {
messageRepository?.handleIncomingMessage(messagePacket)
repository.handleIncomingMessage(messagePacket)
if (!syncBatchInProgress) {
repository.updateLastSyncTimestamp(messagePacket.timestamp)
}
} catch (e: Exception) {
// Silent error handling
}
@@ -128,8 +161,13 @@ object ProtocolManager {
waitPacket(0x08) { packet ->
val deliveryPacket = packet as PacketDelivery
scope.launch {
messageRepository?.handleDelivery(deliveryPacket)
launchInboundPacketTask {
val repository = messageRepository
if (repository == null || !repository.isInitialized()) {
requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync")
return@launchInboundPacketTask
}
repository.handleDelivery(deliveryPacket)
}
}
@@ -138,11 +176,42 @@ object ProtocolManager {
waitPacket(0x07) { packet ->
val readPacket = packet as PacketRead
scope.launch {
messageRepository?.handleRead(readPacket)
launchInboundPacketTask {
val repository = messageRepository
if (repository == null || !repository.isInitialized()) {
requireResyncAfterAccountInit("⏳ Read status before account init, scheduling re-sync")
return@launchInboundPacketTask
}
repository.handleRead(readPacket)
if (!syncBatchInProgress) {
repository.updateLastSyncTimestamp(System.currentTimeMillis())
}
}
}
// 🔐 New device login attempt (0x09) — desktop parity (system Safe message)
waitPacket(0x09) { packet ->
val devicePacket = packet as PacketDeviceNew
addLog(
"🔐 DEVICE LOGIN ATTEMPT: ip=${devicePacket.ipAddress}, device=${devicePacket.device.deviceName}, os=${devicePacket.device.deviceOs}"
)
launchInboundPacketTask {
messageRepository?.addDeviceLoginSystemMessage(
ipAddress = devicePacket.ipAddress,
deviceId = devicePacket.device.deviceId,
deviceName = devicePacket.device.deviceName,
deviceOs = devicePacket.device.deviceOs
)
}
}
// 🔄 Обработчик батчевой синхронизации (0x19)
waitPacket(0x19) { packet ->
handleSyncPacket(packet as PacketSync)
}
// 🟢 Обработчик онлайн-статуса (0x05)
waitPacket(0x05) { packet ->
val onlinePacket = packet as PacketOnlineState
@@ -169,6 +238,16 @@ object ProtocolManager {
}
}
// 📱 Обработчик списка устройств (0x17)
waitPacket(0x17) { packet ->
val devicesPacket = packet as PacketDeviceList
val parsedDevices = devicesPacket.devices
_devices.value = parsedDevices
_pendingDeviceVerification.value = parsedDevices.firstOrNull { device ->
device.deviceVerify == DeviceVerifyState.NOT_VERIFIED
}
}
// 🔥 Обработчик поиска/user info (0x03)
// Обновляет информацию о пользователе в диалогах когда приходит ответ от сервера
// + обновляет own profile (username/name) аналогично Desktop useUserInformation()
@@ -225,6 +304,78 @@ object ProtocolManager {
}
}
private fun launchInboundPacketTask(block: suspend () -> Unit) {
inboundPacketTasks.incrementAndGet()
scope.launch {
try {
block()
} finally {
inboundPacketTasks.decrementAndGet()
}
}
}
private fun requireResyncAfterAccountInit(reason: String) {
if (!resyncRequiredAfterAccountInit) {
addLog(reason)
}
resyncRequiredAfterAccountInit = true
}
private suspend fun waitInboundPacketTasks(timeoutMs: Long = 15_000L) {
val deadline = System.currentTimeMillis() + timeoutMs
while (inboundPacketTasks.get() > 0 && System.currentTimeMillis() < deadline) {
delay(25)
}
}
private fun onAuthenticated() {
TransportManager.requestTransportServer()
fetchOwnProfile()
requestSynchronize()
}
private fun requestSynchronize() {
scope.launch {
val repository = messageRepository
if (repository == null || !repository.isInitialized()) {
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
return@launch
}
val lastSync = repository.getLastSyncTimestamp()
sendSynchronize(lastSync)
}
}
private fun sendSynchronize(timestamp: Long) {
val packet = PacketSync().apply {
status = SyncStatus.NOT_NEEDED
this.timestamp = timestamp
}
send(packet)
}
private fun handleSyncPacket(packet: PacketSync) {
scope.launch {
when (packet.status) {
SyncStatus.BATCH_START -> {
syncBatchInProgress = true
}
SyncStatus.BATCH_END -> {
syncBatchInProgress = true
waitInboundPacketTasks()
messageRepository?.updateLastSyncTimestamp(packet.timestamp)
syncBatchInProgress = false
sendSynchronize(packet.timestamp)
}
SyncStatus.NOT_NEEDED -> {
syncBatchInProgress = false
messageRepository?.updateLastSyncTimestamp(packet.timestamp)
}
}
}
}
/**
* Get or create Protocol instance
*/
@@ -258,14 +409,8 @@ object ProtocolManager {
* Authenticate with server
*/
fun authenticate(publicKey: String, privateHash: String) {
getProtocol().startHandshake(publicKey, privateHash)
// 🚀 Запрашиваем транспортный сервер и own profile после авторизации
scope.launch {
delay(500) // Даём время на завершение handshake
TransportManager.requestTransportServer()
fetchOwnProfile()
}
val device = buildHandshakeDevice()
getProtocol().startHandshake(publicKey, privateHash, device)
}
/**
@@ -381,6 +526,28 @@ object ProtocolManager {
}
}
/**
* Accept a pending device login request.
*/
fun acceptDevice(deviceId: String) {
val packet = PacketDeviceResolve().apply {
this.deviceId = deviceId
this.solution = DeviceResolveSolution.ACCEPT
}
send(packet)
}
/**
* Decline a pending device login request.
*/
fun declineDevice(deviceId: String) {
val packet = PacketDeviceResolve().apply {
this.deviceId = deviceId
this.solution = DeviceResolveSolution.DECLINE
}
send(packet)
}
/**
* Send packet (simplified)
*/
@@ -409,12 +576,60 @@ object ProtocolManager {
getProtocol().unwaitPacket(packetId, callback)
}
private fun buildHandshakeDevice(): HandshakeDevice {
val context = appContext
val deviceId = if (context != null) {
getOrCreateDeviceId(context)
} else {
generateDeviceId()
}
val manufacturer = Build.MANUFACTURER.orEmpty().trim()
val model = Build.MODEL.orEmpty().trim()
val name = listOf(manufacturer, model)
.filter { it.isNotBlank() }
.distinct()
.joinToString(" ")
.ifBlank { "Android Device" }
val os = "Android ${Build.VERSION.RELEASE ?: "Unknown"}"
return HandshakeDevice(
deviceId = deviceId,
deviceName = name,
deviceOs = os
)
}
private fun getOrCreateDeviceId(context: Context): String {
val prefs = context.getSharedPreferences(DEVICE_PREFS, Context.MODE_PRIVATE)
val cached = prefs.getString(DEVICE_ID_KEY, null)
if (!cached.isNullOrBlank()) {
return cached
}
val newId = generateDeviceId()
prefs.edit().putString(DEVICE_ID_KEY, newId).apply()
return newId
}
private fun generateDeviceId(): String {
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
val random = SecureRandom()
return buildString(DEVICE_ID_LENGTH) {
repeat(DEVICE_ID_LENGTH) {
append(chars[random.nextInt(chars.length)])
}
}
}
/**
* Disconnect and clear
*/
fun disconnect() {
protocol?.disconnect()
protocol?.clearCredentials()
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncBatchInProgress = false
inboundPacketTasks.set(0)
}
/**
@@ -423,6 +638,10 @@ object ProtocolManager {
fun destroy() {
protocol?.destroy()
protocol = null
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncBatchInProgress = false
inboundPacketTasks.set(0)
scope.cancel()
}

View File

@@ -0,0 +1,199 @@
package com.rosetta.messenger.ui.auth
import android.os.Build
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.network.DeviceResolveSolution
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketDeviceResolve
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons
import compose.icons.tablericons.DeviceMobile
import kotlinx.coroutines.launch
@Composable
fun DeviceConfirmScreen(
isDarkTheme: Boolean,
onExit: () -> Unit
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val cardBorderColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE8E8ED)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val onExitState by rememberUpdatedState(onExit)
val scope = rememberCoroutineScope()
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_confirm))
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
)
val localDeviceName = remember {
listOf(Build.MANUFACTURER.orEmpty(), Build.MODEL.orEmpty())
.filter { it.isNotBlank() }
.distinct()
.joinToString(" ")
.ifBlank { "this device" }
}
DisposableEffect(Unit) {
val callback: (Packet) -> Unit = callback@{ packet ->
val resolve = packet as? PacketDeviceResolve ?: return@callback
if (resolve.solution == DeviceResolveSolution.DECLINE) {
scope.launch { onExitState() }
}
}
ProtocolManager.waitPacket(0x18, callback)
onDispose {
ProtocolManager.unwaitPacket(0x18, callback)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.navigationBarsPadding()
.padding(horizontal = 22.dp),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 400.dp),
color = cardColor,
shape = RoundedCornerShape(24.dp),
border = BorderStroke(1.dp, cardBorderColor)
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(128.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = TablerIcons.DeviceMobile,
contentDescription = null,
tint = PrimaryBlue
)
Spacer(modifier = Modifier.size(6.dp))
Text(
text = "NEW DEVICE REQUEST",
color = PrimaryBlue,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Waiting for approval",
color = textColor,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Open Rosetta on your first device and approve this login request.",
color = secondaryTextColor,
fontSize = 14.sp,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(14.dp))
Text(
text = "\"$localDeviceName\" is waiting for approval",
color = textColor.copy(alpha = 0.9f),
fontSize = 13.sp,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "If you didn't request this login, tap Exit.",
color = secondaryTextColor,
fontSize = 12.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onExitState,
modifier = Modifier.height(42.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF3B30),
contentColor = Color.White
),
shape = RoundedCornerShape(12.dp)
) {
Text("Exit")
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Waiting for confirmation...",
color = secondaryTextColor,
fontSize = 11.sp
)
}
}
}
}

View File

@@ -66,6 +66,7 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager
@@ -438,11 +439,13 @@ fun ChatDetailScreen(
}
// Динамический subtitle: typing > online > offline
val isSystemAccount = user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val chatSubtitle =
when {
isSavedMessages -> "Notes"
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
isOnline -> "online"
isSystemAccount -> "official account"
else -> "offline"
}
@@ -1041,7 +1044,9 @@ fun ChatDetailScreen(
}
}
// Кнопки действий
if (!isSavedMessages) {
if (!isSavedMessages &&
!isSystemAccount
) {
IconButton(
onClick = { /* TODO: Voice call */
}
@@ -1117,6 +1122,8 @@ fun ChatDetailScreen(
isDarkTheme,
isSavedMessages =
isSavedMessages,
isSystemAccount =
isSystemAccount,
isBlocked =
isBlocked,
onBlockClick = {
@@ -1878,6 +1885,8 @@ fun ChatDetailScreen(
message,
isDarkTheme =
isDarkTheme,
isSystemSafeChat =
isSystemAccount,
isSelectionMode =
isSelectionMode,
showTail =

View File

@@ -1695,6 +1695,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* - Сохранение в БД в IO потоке
* - Поддержка Reply/Forward через attachments (как в React Native)
*/
private fun encryptAesChachaKey(plainKeyAndNonce: ByteArray, privateKey: String): String {
return CryptoManager.encryptWithPassword(
String(plainKeyAndNonce, Charsets.ISO_8859_1),
privateKey
)
}
fun sendMessage() {
val text = _inputText.value.trim()
val recipient = opponentKey
@@ -1793,6 +1800,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -1916,6 +1924,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2022,6 +2031,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val messageAttachments = mutableListOf<MessageAttachment>()
@@ -2118,6 +2128,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipientPublicKey
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2369,6 +2380,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -2404,6 +2416,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2530,6 +2543,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -2568,6 +2582,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2765,6 +2780,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -2829,6 +2845,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -2934,6 +2951,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -2968,6 +2986,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
@@ -3135,6 +3154,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, userPrivateKey)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey)
@@ -3179,6 +3199,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
@@ -37,16 +38,21 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
import com.rosetta.messenger.ui.chats.components.DebugLogsBottomSheet
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
@@ -77,6 +83,11 @@ data class Chat(
val isPinned: Boolean = false
)
private enum class DeviceResolveAction {
ACCEPT,
DECLINE
}
// Avatar colors matching React Native app (Mantine inspired)
// Light theme colors (background lighter, text darker)
private val avatarColorsLight =
@@ -250,6 +261,8 @@ fun ChatsListScreen(
// Protocol connection state
val protocolState by ProtocolManager.state.collectAsState()
val syncLogs by ProtocolManager.debugLogs.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// 🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState()
@@ -275,6 +288,10 @@ fun ChatsListScreen(
// Status dialog state
var showStatusDialog by remember { mutableStateOf(false) }
var showSyncLogs by remember { mutableStateOf(false) }
// Включаем UI логи только когда открыт bottom sheet, чтобы не перегружать композицию
LaunchedEffect(showSyncLogs) { ProtocolManager.enableUILogs(showSyncLogs) }
// 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) }
@@ -298,6 +315,10 @@ fun ChatsListScreen(
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var deviceResolveRequest by
remember {
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
}
// 🔥 Selection mode state
var selectedChatKeys by remember { mutableStateOf<Set<String>>(emptySet()) }
@@ -379,6 +400,11 @@ fun ChatsListScreen(
Color(
0xFF4CAF50
)
ProtocolState
.DEVICE_VERIFICATION_REQUIRED ->
Color(
0xFFFF9800
)
ProtocolState
.CONNECTING,
ProtocolState
@@ -443,6 +469,15 @@ fun ChatsListScreen(
color = textColor
)
}
ProtocolState.DEVICE_VERIFICATION_REQUIRED -> {
Text(
text = "Device verification required",
fontSize = 16.sp,
fontWeight =
FontWeight.Medium,
color = textColor
)
}
}
}
}
@@ -1161,6 +1196,18 @@ fun ChatsListScreen(
},
actions = {
if (!showRequestsScreen) {
IconButton(
onClick = {
showSyncLogs = true
}
) {
Icon(
TablerIcons.Bug,
contentDescription = "Sync logs",
tint = Color.White.copy(alpha = 0.92f)
)
}
IconButton(
onClick = {
if (protocolState ==
@@ -1508,6 +1555,33 @@ fun ChatsListScreen(
listBackgroundColor
)
) {
pendingDeviceVerification?.let { pendingDevice ->
item(key = "device_verification_banner_${pendingDevice.deviceId}") {
Column {
DeviceVerificationBanner(
device = pendingDevice,
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
avatarRepository = avatarRepository,
onAccept = {
deviceResolveRequest =
pendingDevice to
DeviceResolveAction.ACCEPT
},
onDecline = {
deviceResolveRequest =
pendingDevice to
DeviceResolveAction.DECLINE
}
)
Divider(
color = dividerColor,
thickness = 0.5.dp
)
}
}
}
if (requestsCount > 0) {
item(
key =
@@ -1819,9 +1893,173 @@ fun ChatsListScreen(
)
}
deviceResolveRequest?.let { (device, action) ->
DeviceResolveDialog(
isDarkTheme = isDarkTheme,
device = device,
action = action,
onDismiss = { deviceResolveRequest = null },
onConfirm = {
val request = deviceResolveRequest
deviceResolveRequest = null
if (request != null) {
when (request.second) {
DeviceResolveAction.ACCEPT -> {
ProtocolManager.acceptDevice(
request.first.deviceId
)
}
DeviceResolveAction.DECLINE -> {
ProtocolManager.declineDevice(
request.first.deviceId
)
}
}
}
}
)
}
if (showSyncLogs) {
DebugLogsBottomSheet(
logs = syncLogs,
isDarkTheme = isDarkTheme,
onDismiss = { showSyncLogs = false },
onClearLogs = { ProtocolManager.clearLogs() }
)
}
} // Close Box
}
@Composable
private fun DeviceResolveDialog(
isDarkTheme: Boolean,
device: DeviceEntry,
action: DeviceResolveAction,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
val containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val borderColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE8E8ED)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val isAccept = action == DeviceResolveAction.ACCEPT
val confirmColor = if (isAccept) PrimaryBlue else Color(0xFFFF3B30)
val accentBg =
if (isDarkTheme) confirmColor.copy(alpha = 0.18f)
else confirmColor.copy(alpha = 0.12f)
val composition by rememberLottieComposition(
LottieCompositionSpec.RawRes(
if (isAccept) R.raw.saved else R.raw.device_confirm
)
)
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
)
val deviceLabel = buildString {
append(device.deviceName.ifBlank { "Unknown device" })
if (device.deviceOs.isNotBlank()) {
append("")
append(device.deviceOs)
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 24.dp)
.widthIn(max = 380.dp),
color = containerColor,
shape = RoundedCornerShape(22.dp),
border = BorderStroke(1.dp, borderColor)
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier =
Modifier.size(96.dp)
.clip(RoundedCornerShape(20.dp))
.background(accentBg),
contentAlignment = Alignment.Center
) {
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(78.dp)
)
}
Spacer(modifier = Modifier.height(14.dp))
Text(
text = if (isAccept) "Approve new device?" else "Decline this login?",
fontWeight = FontWeight.Bold,
color = textColor,
fontSize = 19.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
if (isAccept) {
"Allow \"$deviceLabel\" to access your account?"
} else {
"Block login from \"$deviceLabel\"?"
},
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 19.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(14.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(
width = 1.dp,
color = if (isDarkTheme) Color(0xFF4A4F60) else Color(0xFFD9D9DE)
)
) {
Text("Cancel", color = secondaryTextColor)
}
Button(
onClick = onConfirm,
modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = confirmColor,
contentColor = Color.White
)
) {
Text(if (isAccept) "Approve" else "Decline login")
}
}
}
}
}
}
/**
* 🚀 Shimmer skeleton для списка чатов — показывается пока данные грузятся Имитирует 10 строк
* диалогов: аватар + 2 строки текста

View File

@@ -33,7 +33,10 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -244,6 +247,7 @@ fun TypingIndicator(isDarkTheme: Boolean) {
fun MessageBubble(
message: ChatMessage,
isDarkTheme: Boolean,
isSystemSafeChat: Boolean = false,
isSelectionMode: Boolean = false,
showTail: Boolean = true,
isGroupStart: Boolean = false,
@@ -322,30 +326,43 @@ fun MessageBubble(
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
}
val isSafeSystemMessage =
isSystemSafeChat &&
!message.isOutgoing &&
message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.attachments.isEmpty() &&
message.text.isNotBlank()
// Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp
val bubbleShape =
remember(message.isOutgoing, showTail) {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart =
if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius
else
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius),
bottomEnd =
if (message.isOutgoing)
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius)
else TelegramBubbleSpec.bubbleRadius
)
remember(message.isOutgoing, showTail, isSafeSystemMessage) {
if (isSafeSystemMessage) {
RoundedCornerShape(18.dp)
} else {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart =
if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius
else
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius),
bottomEnd =
if (message.isOutgoing)
(if (showTail) TelegramBubbleSpec.nearRadius
else TelegramBubbleSpec.bubbleRadius)
else TelegramBubbleSpec.bubbleRadius
)
}
}
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Box(
modifier =
Modifier.fillMaxWidth().pointerInput(Unit) {
Modifier.fillMaxWidth().pointerInput(isSafeSystemMessage) {
if (isSafeSystemMessage) return@pointerInput
// 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со
// скроллом
@@ -501,6 +518,7 @@ fun MessageBubble(
// Для фото + caption - padding только внизу для текста
val bubblePadding =
when {
isSafeSystemMessage -> PaddingValues(0.dp)
hasOnlyMedia -> PaddingValues(0.dp)
hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
@@ -578,7 +596,9 @@ fun MessageBubble(
}
val bubbleWidthModifier =
if (hasImageWithCaption || hasOnlyMedia) {
if (isSafeSystemMessage) {
Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (hasImageWithCaption || hasOnlyMedia) {
Modifier.width(
photoWidth
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
@@ -635,13 +655,25 @@ fun MessageBubble(
},
shape = bubbleShape
)
} else if (isSafeSystemMessage) {
Modifier.background(
if (isDarkTheme) Color(0xFF2A2A2D)
else Color(0xFFF0F0F4)
)
} else {
Modifier.background(bubbleColor)
}
)
.padding(bubblePadding)
) {
Column {
if (isSafeSystemMessage) {
SafeSystemMessageCard(
text = message.text,
timestamp = message.timestamp,
isDarkTheme = isDarkTheme
)
} else {
Column {
// 🔥 Forwarded messages (multiple, desktop parity)
if (message.forwardedMessages.isNotEmpty()) {
ForwardedMessagesBubble(
@@ -962,11 +994,86 @@ fun MessageBubble(
)
}
}
}
}
}
}
}
@Composable
private fun SafeSystemMessageCard(text: String, timestamp: Date, isDarkTheme: Boolean) {
val contentColor = if (isDarkTheme) Color(0xFFE8E9EE) else Color(0xFF1E1F23)
val timeColor = if (isDarkTheme) Color(0xFFB3B7C0) else Color(0xFF737983)
val annotatedText = remember(text) { buildSafeSystemAnnotatedText(text) }
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Column(
modifier =
Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, top = 12.dp, bottom = 8.dp)
) {
Text(
text = annotatedText,
color = contentColor,
fontSize = 16.sp,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = timeFormat.format(timestamp),
color = timeColor,
fontSize = 11.sp,
modifier = Modifier.align(Alignment.End)
)
}
}
private fun buildSafeSystemAnnotatedText(text: String) = buildAnnotatedString {
val boldStyle = SpanStyle(fontWeight = FontWeight.SemiBold)
val lines = text.lines()
lines.forEachIndexed { index, line ->
when {
index == 0 && line.isNotBlank() -> {
withStyle(boldStyle) { append(line) }
}
line.startsWith("We detected a login to your account from ") -> {
val prefix = "We detected a login to your account from "
val marker = " a new device by seed phrase"
val tail = line.removePrefix(prefix)
val markerIndex = tail.indexOf(marker)
if (markerIndex > 0) {
val ip = tail.substring(0, markerIndex)
append(prefix)
withStyle(boldStyle) { append(ip) }
append(" a new device ")
withStyle(boldStyle) { append("by seed phrase") }
append(tail.substring(markerIndex + marker.length))
} else {
append(line)
}
}
line.startsWith("Arch:") ||
line.startsWith("IP:") ||
line.startsWith("Device:") ||
line.startsWith("ID:") -> {
val separatorIndex = line.indexOf(':')
if (separatorIndex > 0) {
withStyle(boldStyle) { append(line.substring(0, separatorIndex + 1)) }
if (separatorIndex + 1 < line.length) {
append(line.substring(separatorIndex + 1))
}
} else {
append(line)
}
}
else -> append(line)
}
if (index < lines.lastIndex) append('\n')
}
}
/** Animated message status indicator */
@Composable
fun AnimatedMessageStatus(
@@ -1722,6 +1829,7 @@ fun KebabMenu(
onDismiss: () -> Unit,
isDarkTheme: Boolean,
isSavedMessages: Boolean,
isSystemAccount: Boolean = false,
isBlocked: Boolean,
onBlockClick: () -> Unit,
onUnblockClick: () -> Unit,
@@ -1752,7 +1860,7 @@ fun KebabMenu(
dismissOnClickOutside = true
)
) {
if (!isSavedMessages) {
if (!isSavedMessages && !isSystemAccount) {
KebabMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
text = if (isBlocked) "Unblock User" else "Block User",

View File

@@ -0,0 +1,124 @@
package com.rosetta.messenger.ui.chats.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@Composable
fun DeviceVerificationBanner(
device: DeviceEntry,
isDarkTheme: Boolean,
accountPublicKey: String,
avatarRepository: AvatarRepository?,
onAccept: () -> Unit,
onDecline: () -> Unit
) {
val itemBackground = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val titleColor = if (isDarkTheme) Color.White else Color.Black
val subtitleColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val acceptColor = PrimaryBlue
val declineColor = Color(0xFFFF3B30)
val loginText =
buildString {
append("New login from ")
append(device.deviceName)
if (device.deviceOs.isNotBlank()) {
append(" (")
append(device.deviceOs)
append(")")
}
append(". Is it you?")
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(itemBackground)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row {
AvatarImage(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
size = 56.dp,
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Someone just got access to your messages!",
color = titleColor,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(3.dp))
Text(
text = loginText,
color = subtitleColor,
fontSize = 13.sp,
lineHeight = 17.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(6.dp))
Row {
TextButton(
onClick = onAccept,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text(
text = "Yes, it's me",
color = acceptColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.width(12.dp))
TextButton(
onClick = onDecline,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text(
text = "No, it's not me!",
color = declineColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
}

View File

@@ -18,11 +18,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.R
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.AvatarColors
import com.rosetta.messenger.ui.chats.getAvatarColor
@@ -75,6 +78,8 @@ fun AvatarImage(
shape: Shape = CircleShape,
displayName: String? = null // 🔥 Имя для инициалов (title/username)
) {
val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
// Получаем аватары из репозитория
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
@@ -129,7 +134,14 @@ fun AvatarImage(
LaunchedEffect(bitmap) {
}
if (bitmap != null) {
if (isSystemSafeAccount) {
Image(
painter = painterResource(id = R.drawable.safe_account),
contentDescription = "Safe avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else if (bitmap != null) {
// Отображаем реальный аватар
Image(
bitmap = bitmap!!.asImageBitmap(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long