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

View File

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

View File

@@ -49,6 +49,7 @@ class MessageRepository private constructor(private val context: Context) {
private val messageDao = database.messageDao() private val messageDao = database.messageDao()
private val dialogDao = database.dialogDao() private val dialogDao = database.dialogDao()
private val avatarDao = database.avatarDao() private val avatarDao = database.avatarDao()
private val syncTimeDao = database.syncTimeDao()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -95,6 +96,10 @@ class MessageRepository private constructor(private val context: Context) {
companion object { companion object {
@Volatile private var INSTANCE: MessageRepository? = null @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 для предотвращения дубликатов // 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов
// LRU кэш с ограничением 1000 элементов - защита от race conditions // LRU кэш с ограничением 1000 элементов - защита от race conditions
private val processedMessageIds = 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) { fun initialize(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
@@ -163,6 +248,20 @@ class MessageRepository private constructor(private val context: Context) {
return currentAccount != null && currentPrivateKey != null 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>> { fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
val dialogKey = getDialogKey(opponentKey) val dialogKey = getDialogKey(opponentKey)
@@ -238,6 +337,11 @@ class MessageRepository private constructor(private val context: Context) {
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey) val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
val encryptedContent = encryptResult.ciphertext val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey val encryptedKey = encryptResult.encryptedKey
val aesChachaKey =
CryptoManager.encryptWithPassword(
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
privateKey
)
// 📝 LOG: Шифрование успешно // 📝 LOG: Шифрование успешно
MessageLogger.logEncryptionSuccess( MessageLogger.logEncryptionSuccess(
@@ -344,6 +448,7 @@ class MessageRepository private constructor(private val context: Context) {
this.toPublicKey = toPublicKey this.toPublicKey = toPublicKey
this.content = encryptedContent this.content = encryptedContent
this.chachaKey = encryptedKey this.chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp this.timestamp = timestamp
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
this.messageId = messageId this.messageId = messageId
@@ -397,12 +502,16 @@ class MessageRepository private constructor(private val context: Context) {
timestamp = packet.timestamp timestamp = packet.timestamp
) )
val isOwnMessage = packet.fromPublicKey == account
// 🔥 Проверяем, не заблокирован ли отправитель // 🔥 Проверяем, не заблокирован ли отправитель
if (!isOwnMessage) {
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account) val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
if (isBlocked) { if (isBlocked) {
MessageLogger.logBlockedSender(packet.fromPublicKey) MessageLogger.logBlockedSender(packet.fromPublicKey)
return return
} }
}
// 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed) // 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed)
val messageId = val messageId =
@@ -435,17 +544,37 @@ class MessageRepository private constructor(private val context: Context) {
return return
} }
val dialogKey = getDialogKey(packet.fromPublicKey) val dialogOpponentKey = if (isOwnMessage) packet.toPublicKey else packet.fromPublicKey
val dialogKey = getDialogKey(dialogOpponentKey)
try { try {
// 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!) val plainKeyAndNonce =
// Desktop: хранит зашифрованный ключ, расшифровывает только при использовании if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
// Buffer.from(await decrypt(message.chacha_key, privatePlain), CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
// "binary").toString('utf-8') ?.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 = val plainText =
if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey) MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
}
// 📝 LOG: Расшифровка успешна // 📝 LOG: Расшифровка успешна
MessageLogger.logDecryptionSuccess( MessageLogger.logDecryptionSuccess(
@@ -459,18 +588,25 @@ class MessageRepository private constructor(private val context: Context) {
serializeAttachmentsWithDecryption( serializeAttachmentsWithDecryption(
packet.attachments, packet.attachments,
packet.chachaKey, packet.chachaKey,
privateKey privateKey,
plainKeyAndNonce
) )
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop) // 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
processImageAttachments(packet.attachments, packet.chachaKey, privateKey) processImageAttachments(
packet.attachments,
packet.chachaKey,
privateKey,
plainKeyAndNonce
)
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя // 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
processAvatarAttachments( processAvatarAttachments(
packet.attachments, packet.attachments,
packet.fromPublicKey, packet.fromPublicKey,
packet.chachaKey, packet.chachaKey,
privateKey privateKey,
plainKeyAndNonce
) )
// 🔒 Шифруем plainMessage с использованием приватного ключа // 🔒 Шифруем plainMessage с использованием приватного ключа
@@ -486,7 +622,7 @@ class MessageRepository private constructor(private val context: Context) {
timestamp = packet.timestamp, timestamp = packet.timestamp,
chachaKey = packet.chachaKey, chachaKey = packet.chachaKey,
read = 0, read = 0,
fromMe = 0, fromMe = if (isOwnMessage) 1 else 0,
delivered = DeliveryStatus.DELIVERED.value, delivered = DeliveryStatus.DELIVERED.value,
messageId = messageId, // 🔥 Используем сгенерированный messageId! messageId = messageId, // 🔥 Используем сгенерированный messageId!
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
@@ -506,10 +642,10 @@ class MessageRepository private constructor(private val context: Context) {
} }
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages // 🔥 КРИТИЧНО: Обновляем диалог через 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( MessageLogger.logDialogUpdate(
dialogKey = dialogKey, dialogKey = dialogKey,
lastMessage = dialog?.lastMessage, lastMessage = dialog?.lastMessage,
@@ -517,7 +653,7 @@ class MessageRepository private constructor(private val context: Context) {
) )
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа // 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
requestUserInfo(packet.fromPublicKey) requestUserInfo(dialogOpponentKey)
// Обновляем кэш только если сообщение новое // Обновляем кэш только если сообщение новое
if (!stillExists) { if (!stillExists) {
@@ -534,6 +670,11 @@ class MessageRepository private constructor(private val context: Context) {
} catch (e: Exception) { } catch (e: Exception) {
// 📝 LOG: Ошибка обработки // 📝 LOG: Ошибка обработки
MessageLogger.logDecryptionError(messageId, e) 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() e.printStackTrace()
} }
} }
@@ -573,17 +714,24 @@ class MessageRepository private constructor(private val context: Context) {
MessageLogger.debug("👁 READ PACKET from: ${packet.fromPublicKey.take(16)}...") 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: Проверяем последнее сообщение ПОСЛЕ обновления // 🔥 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 val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
messageCache[dialogKey]?.let { flow -> messageCache[dialogKey]?.let { flow ->
flow.value = flow.value =
@@ -596,13 +744,13 @@ class MessageRepository private constructor(private val context: Context) {
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ)) _deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
// 📝 LOG: Статус прочтения // 📝 LOG: Статус прочтения
MessageLogger.logReadStatus(fromPublicKey = packet.fromPublicKey, messagesCount = readCount) MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился // 🔥 КРИТИЧНО: Обновляем диалог чтобы 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( private suspend fun updateDialog(
opponentKey: String, opponentKey: String,
lastMessage: String, lastMessage: String,
@@ -919,7 +1074,8 @@ class MessageRepository private constructor(private val context: Context) {
attachments: List<MessageAttachment>, attachments: List<MessageAttachment>,
fromPublicKey: String, fromPublicKey: String,
encryptedKey: String, encryptedKey: String,
privateKey: String privateKey: String,
plainKeyAndNonce: ByteArray? = null
) { ) {
for (attachment in attachments) { for (attachment in attachments) {
@@ -929,7 +1085,10 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения // 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
MessageCrypto.decryptAttachmentBlob( plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob, attachment.blob,
encryptedKey, encryptedKey,
privateKey privateKey
@@ -964,7 +1123,8 @@ class MessageRepository private constructor(private val context: Context) {
private fun processImageAttachments( private fun processImageAttachments(
attachments: List<MessageAttachment>, attachments: List<MessageAttachment>,
encryptedKey: String, encryptedKey: String,
privateKey: String privateKey: String,
plainKeyAndNonce: ByteArray? = null
) { ) {
val publicKey = currentAccount ?: return val publicKey = currentAccount ?: return
@@ -975,7 +1135,10 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения // 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
MessageCrypto.decryptAttachmentBlob( plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob, attachment.blob,
encryptedKey, encryptedKey,
privateKey privateKey
@@ -1008,7 +1171,8 @@ class MessageRepository private constructor(private val context: Context) {
private fun serializeAttachmentsWithDecryption( private fun serializeAttachmentsWithDecryption(
attachments: List<MessageAttachment>, attachments: List<MessageAttachment>,
encryptedKey: String, encryptedKey: String,
privateKey: String privateKey: String,
plainKeyAndNonce: ByteArray? = null
): String { ): String {
if (attachments.isEmpty()) return "[]" if (attachments.isEmpty()) return "[]"
@@ -1022,7 +1186,10 @@ class MessageRepository private constructor(private val context: Context) {
try { try {
// 1. Расшифровываем с ChaCha ключом сообщения // 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
MessageCrypto.decryptAttachmentBlob( plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob, attachment.blob,
encryptedKey, encryptedKey,
privateKey privateKey

View File

@@ -49,8 +49,9 @@ class PreferencesManager(private val context: Context) {
// Language // Language
val APP_LANGUAGE = stringPreferencesKey("app_language") // "en", "ru", etc. val APP_LANGUAGE = stringPreferencesKey("app_language") // "en", "ru", etc.
// Appearance / Customization // Appearance / Customization (legacy global key)
val BACKGROUND_BLUR_COLOR_ID = stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets val BACKGROUND_BLUR_COLOR_ID =
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
// Pinned Chats (max 3) // Pinned Chats (max 3)
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
@@ -219,6 +220,12 @@ class PreferencesManager(private val context: Context) {
// 🎨 APPEARANCE / CUSTOMIZATION // 🎨 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> = val backgroundBlurColorId: Flow<String> =
context.dataStore.data.map { preferences -> context.dataStore.data.map { preferences ->
preferences[BACKGROUND_BLUR_COLOR_ID] ?: "avatar" // Default: use avatar blur 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 } 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 // 📌 PINNED CHATS
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════

View File

@@ -14,8 +14,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
MessageEntity::class, MessageEntity::class,
DialogEntity::class, DialogEntity::class,
BlacklistEntity::class, BlacklistEntity::class,
AvatarCacheEntity::class], AvatarCacheEntity::class,
version = 11, AccountSyncTimeEntity::class],
version = 12,
exportSchema = false exportSchema = false
) )
abstract class RosettaDatabase : RoomDatabase() { abstract class RosettaDatabase : RoomDatabase() {
@@ -24,6 +25,7 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun dialogDao(): DialogDao abstract fun dialogDao(): DialogDao
abstract fun blacklistDao(): BlacklistDao abstract fun blacklistDao(): BlacklistDao
abstract fun avatarDao(): AvatarDao abstract fun avatarDao(): AvatarDao
abstract fun syncTimeDao(): SyncTimeDao
companion object { companion object {
@Volatile private var INSTANCE: RosettaDatabase? = null @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 { fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE return INSTANCE
?: synchronized(this) { ?: synchronized(this) {
@@ -151,7 +167,8 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_7_8, MIGRATION_7_8,
MIGRATION_8_9, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_9_10,
MIGRATION_10_11 MIGRATION_10_11,
MIGRATION_11_12
) )
.fallbackToDestructiveMigration() // Для разработки - только .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 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) * Handshake packet (ID: 0x00)
* First packet sent by client to authenticate with the server * First packet sent by client to authenticate with the server
@@ -9,6 +26,8 @@ class PacketHandshake : Packet() {
var publicKey: String = "" var publicKey: String = ""
var protocolVersion: Int = 1 var protocolVersion: Int = 1
var heartbeatInterval: Int = 15 var heartbeatInterval: Int = 15
var device: HandshakeDevice = HandshakeDevice()
var handshakeState: HandshakeState = HandshakeState.NEED_DEVICE_VERIFICATION
override fun getPacketId(): Int = 0x00 override fun getPacketId(): Int = 0x00
@@ -17,6 +36,12 @@ class PacketHandshake : Packet() {
publicKey = stream.readString() publicKey = stream.readString()
protocolVersion = stream.readInt8() protocolVersion = stream.readInt8()
heartbeatInterval = 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 { override fun send(): Stream {
@@ -26,6 +51,10 @@ class PacketHandshake : Packet() {
stream.writeString(publicKey) stream.writeString(publicKey)
stream.writeInt8(protocolVersion) stream.writeInt8(protocolVersion)
stream.writeInt8(heartbeatInterval) stream.writeInt8(heartbeatInterval)
stream.writeString(device.deviceId)
stream.writeString(device.deviceName)
stream.writeString(device.deviceOs)
stream.writeInt8(handshakeState.value)
return stream return stream
} }
} }

View File

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

View File

@@ -1,15 +1,18 @@
package com.rosetta.messenger.network package com.rosetta.messenger.network
import android.content.Context import android.content.Context
import android.os.Build
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.security.SecureRandom
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.resume import kotlin.coroutines.resume
/** /**
@@ -21,6 +24,9 @@ object ProtocolManager {
// Server address - same as React Native version // Server address - same as React Native version
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000" 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 protocol: Protocol? = null
private var messageRepository: MessageRepository? = null private var messageRepository: MessageRepository? = null
@@ -36,6 +42,13 @@ object ProtocolManager {
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet()) private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow() 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 загружены с сервера) // Сигнал обновления own profile (username/name загружены с сервера)
private val _ownProfileUpdated = MutableStateFlow(0L) private val _ownProfileUpdated = MutableStateFlow(0L)
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow() val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
@@ -50,6 +63,10 @@ object ProtocolManager {
// 🚀 Флаг для включения UI логов (по умолчанию ВЫКЛЮЧЕНО - это вызывало ANR!) // 🚀 Флаг для включения UI логов (по умолчанию ВЫКЛЮЧЕНО - это вызывало ANR!)
private var uiLogsEnabled = false 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) { fun addLog(message: String) {
val timestamp = dateFormat.format(Date()) val timestamp = dateFormat.format(Date())
@@ -85,7 +102,11 @@ object ProtocolManager {
private fun setupStateMonitoring() { private fun setupStateMonitoring() {
scope.launch { scope.launch {
getProtocol().state.collect { newState -> getProtocol().state.collect { newState ->
// State monitoring without logging val previous = lastProtocolState
if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) {
onAuthenticated()
}
lastProtocolState = newState
} }
} }
} }
@@ -95,8 +116,12 @@ object ProtocolManager {
* Должен вызываться после авторизации пользователя * Должен вызываться после авторизации пользователя
*/ */
fun initializeAccount(publicKey: String, privateKey: String) { fun initializeAccount(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis() syncBatchInProgress = false
messageRepository?.initialize(publicKey, privateKey) messageRepository?.initialize(publicKey, privateKey)
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
resyncRequiredAfterAccountInit = false
requestSynchronize()
}
} }
/** /**
@@ -115,9 +140,17 @@ object ProtocolManager {
} }
send(deliveryPacket) 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 { try {
messageRepository?.handleIncomingMessage(messagePacket) repository.handleIncomingMessage(messagePacket)
if (!syncBatchInProgress) {
repository.updateLastSyncTimestamp(messagePacket.timestamp)
}
} catch (e: Exception) { } catch (e: Exception) {
// Silent error handling // Silent error handling
} }
@@ -128,8 +161,13 @@ object ProtocolManager {
waitPacket(0x08) { packet -> waitPacket(0x08) { packet ->
val deliveryPacket = packet as PacketDelivery val deliveryPacket = packet as PacketDelivery
scope.launch { launchInboundPacketTask {
messageRepository?.handleDelivery(deliveryPacket) val repository = messageRepository
if (repository == null || !repository.isInitialized()) {
requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync")
return@launchInboundPacketTask
}
repository.handleDelivery(deliveryPacket)
} }
} }
@@ -138,9 +176,40 @@ object ProtocolManager {
waitPacket(0x07) { packet -> waitPacket(0x07) { packet ->
val readPacket = packet as PacketRead val readPacket = packet as PacketRead
scope.launch { launchInboundPacketTask {
messageRepository?.handleRead(readPacket) 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) // 🟢 Обработчик онлайн-статуса (0x05)
@@ -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) // 🔥 Обработчик поиска/user info (0x03)
// Обновляет информацию о пользователе в диалогах когда приходит ответ от сервера // Обновляет информацию о пользователе в диалогах когда приходит ответ от сервера
// + обновляет own profile (username/name) аналогично Desktop useUserInformation() // + обновляет 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 * Get or create Protocol instance
*/ */
@@ -258,14 +409,8 @@ object ProtocolManager {
* Authenticate with server * Authenticate with server
*/ */
fun authenticate(publicKey: String, privateHash: String) { fun authenticate(publicKey: String, privateHash: String) {
getProtocol().startHandshake(publicKey, privateHash) val device = buildHandshakeDevice()
getProtocol().startHandshake(publicKey, privateHash, device)
// 🚀 Запрашиваем транспортный сервер и own profile после авторизации
scope.launch {
delay(500) // Даём время на завершение handshake
TransportManager.requestTransportServer()
fetchOwnProfile()
}
} }
/** /**
@@ -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) * Send packet (simplified)
*/ */
@@ -409,12 +576,60 @@ object ProtocolManager {
getProtocol().unwaitPacket(packetId, callback) 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 * Disconnect and clear
*/ */
fun disconnect() { fun disconnect() {
protocol?.disconnect() protocol?.disconnect()
protocol?.clearCredentials() protocol?.clearCredentials()
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncBatchInProgress = false
inboundPacketTasks.set(0)
} }
/** /**
@@ -423,6 +638,10 @@ object ProtocolManager {
fun destroy() { fun destroy() {
protocol?.destroy() protocol?.destroy()
protocol = null protocol = null
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncBatchInProgress = false
inboundPacketTasks.set(0)
scope.cancel() 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.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
@@ -438,11 +439,13 @@ fun ChatDetailScreen(
} }
// Динамический subtitle: typing > online > offline // Динамический subtitle: typing > online > offline
val isSystemAccount = user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val chatSubtitle = val chatSubtitle =
when { when {
isSavedMessages -> "Notes" isSavedMessages -> "Notes"
isTyping -> "" // Пустая строка, используем компонент TypingIndicator isTyping -> "" // Пустая строка, используем компонент TypingIndicator
isOnline -> "online" isOnline -> "online"
isSystemAccount -> "official account"
else -> "offline" else -> "offline"
} }
@@ -1041,7 +1044,9 @@ fun ChatDetailScreen(
} }
} }
// Кнопки действий // Кнопки действий
if (!isSavedMessages) { if (!isSavedMessages &&
!isSystemAccount
) {
IconButton( IconButton(
onClick = { /* TODO: Voice call */ onClick = { /* TODO: Voice call */
} }
@@ -1117,6 +1122,8 @@ fun ChatDetailScreen(
isDarkTheme, isDarkTheme,
isSavedMessages = isSavedMessages =
isSavedMessages, isSavedMessages,
isSystemAccount =
isSystemAccount,
isBlocked = isBlocked =
isBlocked, isBlocked,
onBlockClick = { onBlockClick = {
@@ -1878,6 +1885,8 @@ fun ChatDetailScreen(
message, message,
isDarkTheme = isDarkTheme =
isDarkTheme, isDarkTheme,
isSystemSafeChat =
isSystemAccount,
isSelectionMode = isSelectionMode =
isSelectionMode, isSelectionMode,
showTail = showTail =

View File

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

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp 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.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.airbnb.lottie.compose.*
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.BuildConfig import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText 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.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
@@ -77,6 +83,11 @@ data class Chat(
val isPinned: Boolean = false val isPinned: Boolean = false
) )
private enum class DeviceResolveAction {
ACCEPT,
DECLINE
}
// Avatar colors matching React Native app (Mantine inspired) // Avatar colors matching React Native app (Mantine inspired)
// Light theme colors (background lighter, text darker) // Light theme colors (background lighter, text darker)
private val avatarColorsLight = private val avatarColorsLight =
@@ -250,6 +261,8 @@ fun ChatsListScreen(
// Protocol connection state // Protocol connection state
val protocolState by ProtocolManager.state.collectAsState() val protocolState by ProtocolManager.state.collectAsState()
val syncLogs by ProtocolManager.debugLogs.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// 🔥 Пользователи, которые сейчас печатают // 🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState() val typingUsers by ProtocolManager.typingUsers.collectAsState()
@@ -275,6 +288,10 @@ fun ChatsListScreen(
// Status dialog state // Status dialog state
var showStatusDialog by remember { mutableStateOf(false) } var showStatusDialog by remember { mutableStateOf(false) }
var showSyncLogs by remember { mutableStateOf(false) }
// Включаем UI логи только когда открыт bottom sheet, чтобы не перегружать композицию
LaunchedEffect(showSyncLogs) { ProtocolManager.enableUILogs(showSyncLogs) }
// 📬 Requests screen state // 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) } var showRequestsScreen by remember { mutableStateOf(false) }
@@ -298,6 +315,10 @@ fun ChatsListScreen(
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) } var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var deviceResolveRequest by
remember {
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
}
// 🔥 Selection mode state // 🔥 Selection mode state
var selectedChatKeys by remember { mutableStateOf<Set<String>>(emptySet()) } var selectedChatKeys by remember { mutableStateOf<Set<String>>(emptySet()) }
@@ -379,6 +400,11 @@ fun ChatsListScreen(
Color( Color(
0xFF4CAF50 0xFF4CAF50
) )
ProtocolState
.DEVICE_VERIFICATION_REQUIRED ->
Color(
0xFFFF9800
)
ProtocolState ProtocolState
.CONNECTING, .CONNECTING,
ProtocolState ProtocolState
@@ -443,6 +469,15 @@ fun ChatsListScreen(
color = textColor 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 = { actions = {
if (!showRequestsScreen) { if (!showRequestsScreen) {
IconButton(
onClick = {
showSyncLogs = true
}
) {
Icon(
TablerIcons.Bug,
contentDescription = "Sync logs",
tint = Color.White.copy(alpha = 0.92f)
)
}
IconButton( IconButton(
onClick = { onClick = {
if (protocolState == if (protocolState ==
@@ -1508,6 +1555,33 @@ fun ChatsListScreen(
listBackgroundColor 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) { if (requestsCount > 0) {
item( item(
key = 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 } // 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 строк * 🚀 Shimmer skeleton для списка чатов — показывается пока данные грузятся Имитирует 10 строк
* диалогов: аватар + 2 строки текста * диалогов: аватар + 2 строки текста

View File

@@ -33,7 +33,10 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity 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.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -244,6 +247,7 @@ fun TypingIndicator(isDarkTheme: Boolean) {
fun MessageBubble( fun MessageBubble(
message: ChatMessage, message: ChatMessage,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isSystemSafeChat: Boolean = false,
isSelectionMode: Boolean = false, isSelectionMode: Boolean = false,
showTail: Boolean = true, showTail: Boolean = true,
isGroupStart: Boolean = false, isGroupStart: Boolean = false,
@@ -322,9 +326,20 @@ fun MessageBubble(
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) 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 // Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp
val bubbleShape = val bubbleShape =
remember(message.isOutgoing, showTail) { remember(message.isOutgoing, showTail, isSafeSystemMessage) {
if (isSafeSystemMessage) {
RoundedCornerShape(18.dp)
} else {
RoundedCornerShape( RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius, topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius, topEnd = TelegramBubbleSpec.bubbleRadius,
@@ -340,12 +355,14 @@ fun MessageBubble(
else TelegramBubbleSpec.bubbleRadius else TelegramBubbleSpec.bubbleRadius
) )
} }
}
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth().pointerInput(Unit) { Modifier.fillMaxWidth().pointerInput(isSafeSystemMessage) {
if (isSafeSystemMessage) return@pointerInput
// 🔥 Простой горизонтальный свайп для reply // 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со // Используем detectHorizontalDragGestures который лучше работает со
// скроллом // скроллом
@@ -501,6 +518,7 @@ fun MessageBubble(
// Для фото + caption - padding только внизу для текста // Для фото + caption - padding только внизу для текста
val bubblePadding = val bubblePadding =
when { when {
isSafeSystemMessage -> PaddingValues(0.dp)
hasOnlyMedia -> PaddingValues(0.dp) hasOnlyMedia -> PaddingValues(0.dp)
hasImageWithCaption -> PaddingValues(0.dp) hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp) else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
@@ -578,7 +596,9 @@ fun MessageBubble(
} }
val bubbleWidthModifier = val bubbleWidthModifier =
if (hasImageWithCaption || hasOnlyMedia) { if (isSafeSystemMessage) {
Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (hasImageWithCaption || hasOnlyMedia) {
Modifier.width( Modifier.width(
photoWidth photoWidth
) // 🔥 Фиксированная ширина = размер фото (убирает лишний ) // 🔥 Фиксированная ширина = размер фото (убирает лишний
@@ -635,12 +655,24 @@ fun MessageBubble(
}, },
shape = bubbleShape shape = bubbleShape
) )
} else if (isSafeSystemMessage) {
Modifier.background(
if (isDarkTheme) Color(0xFF2A2A2D)
else Color(0xFFF0F0F4)
)
} else { } else {
Modifier.background(bubbleColor) Modifier.background(bubbleColor)
} }
) )
.padding(bubblePadding) .padding(bubblePadding)
) { ) {
if (isSafeSystemMessage) {
SafeSystemMessageCard(
text = message.text,
timestamp = message.timestamp,
isDarkTheme = isDarkTheme
)
} else {
Column { Column {
// 🔥 Forwarded messages (multiple, desktop parity) // 🔥 Forwarded messages (multiple, desktop parity)
if (message.forwardedMessages.isNotEmpty()) { if (message.forwardedMessages.isNotEmpty()) {
@@ -966,6 +998,81 @@ 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 */ /** Animated message status indicator */
@Composable @Composable
@@ -1722,6 +1829,7 @@ fun KebabMenu(
onDismiss: () -> Unit, onDismiss: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isSavedMessages: Boolean, isSavedMessages: Boolean,
isSystemAccount: Boolean = false,
isBlocked: Boolean, isBlocked: Boolean,
onBlockClick: () -> Unit, onBlockClick: () -> Unit,
onUnblockClick: () -> Unit, onUnblockClick: () -> Unit,
@@ -1752,7 +1860,7 @@ fun KebabMenu(
dismissOnClickOutside = true dismissOnClickOutside = true
) )
) { ) {
if (!isSavedMessages) { if (!isSavedMessages && !isSystemAccount) {
KebabMenuItem( KebabMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block, icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
text = if (isBlocked) "Unblock User" else "Block User", 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.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.AvatarColors import com.rosetta.messenger.ui.chats.AvatarColors
import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarColor
@@ -75,6 +78,8 @@ fun AvatarImage(
shape: Shape = CircleShape, shape: Shape = CircleShape,
displayName: String? = null // 🔥 Имя для инициалов (title/username) displayName: String? = null // 🔥 Имя для инициалов (title/username)
) { ) {
val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
// Получаем аватары из репозитория // Получаем аватары из репозитория
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) } ?: remember { mutableStateOf(emptyList()) }
@@ -129,7 +134,14 @@ fun AvatarImage(
LaunchedEffect(bitmap) { 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( Image(
bitmap = bitmap!!.asImageBitmap(), bitmap = bitmap!!.asImageBitmap(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long