feat: implement device verification flow with new UI components and protocol handling
This commit is contained in:
@@ -37,6 +37,7 @@ import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.auth.AccountInfo
|
||||
import com.rosetta.messenger.ui.auth.AuthFlow
|
||||
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
|
||||
import com.rosetta.messenger.ui.chats.ChatDetailScreen
|
||||
import com.rosetta.messenger.ui.chats.ChatsListScreen
|
||||
import com.rosetta.messenger.ui.chats.RequestsListScreen
|
||||
@@ -144,6 +145,7 @@ class MainActivity : FragmentActivity() {
|
||||
else -> true
|
||||
}
|
||||
val isLoggedIn by accountManager.isLoggedIn.collectAsState(initial = null)
|
||||
val protocolState by ProtocolManager.state.collectAsState()
|
||||
var showSplash by remember { mutableStateOf(true) }
|
||||
var showOnboarding by remember { mutableStateOf(true) }
|
||||
var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) }
|
||||
@@ -210,6 +212,9 @@ class MainActivity : FragmentActivity() {
|
||||
"auth_new"
|
||||
isLoggedIn != true && hasExistingAccount == true ->
|
||||
"auth_unlock"
|
||||
protocolState ==
|
||||
ProtocolState.DEVICE_VERIFICATION_REQUIRED ->
|
||||
"device_confirm"
|
||||
else -> "main"
|
||||
},
|
||||
transitionSpec = {
|
||||
@@ -430,6 +435,18 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
)
|
||||
}
|
||||
"device_confirm" -> {
|
||||
DeviceConfirmScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
onExit = {
|
||||
currentAccount = null
|
||||
scope.launch {
|
||||
ProtocolManager.disconnect()
|
||||
accountManager.logout()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -690,7 +707,9 @@ fun MainScreen(
|
||||
// Appearance: background blur color preference
|
||||
val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
|
||||
val backgroundBlurColorId by
|
||||
prefsManager.backgroundBlurColorId.collectAsState(initial = "avatar")
|
||||
prefsManager
|
||||
.backgroundBlurColorIdForAccount(accountPublicKey)
|
||||
.collectAsState(initial = "avatar")
|
||||
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
|
||||
|
||||
// AvatarRepository для работы с аватарами
|
||||
@@ -920,7 +939,9 @@ fun MainScreen(
|
||||
currentBlurColorId = backgroundBlurColorId,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Appearance } },
|
||||
onBlurColorChange = { newId ->
|
||||
mainScreenScope.launch { prefsManager.setBackgroundBlurColorId(newId) }
|
||||
mainScreenScope.launch {
|
||||
prefsManager.setBackgroundBlurColorId(accountPublicKey, newId)
|
||||
}
|
||||
},
|
||||
onToggleTheme = onToggleTheme,
|
||||
accountPublicKey = accountPublicKey,
|
||||
|
||||
@@ -29,6 +29,8 @@ object CryptoManager {
|
||||
private const val PBKDF2_ITERATIONS = 1000
|
||||
private const val KEY_SIZE = 256
|
||||
private const val SALT = "rosetta"
|
||||
private const val PBKDF2_HMAC_SHA1 = "PBKDF2WithHmacSHA1"
|
||||
private const val PBKDF2_HMAC_SHA256 = "PBKDF2WithHmacSHA256"
|
||||
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Кэш для генерации ключей (seedPhrase -> KeyPair)
|
||||
private val keyPairCache = mutableMapOf<String, KeyPairData>()
|
||||
@@ -57,8 +59,13 @@ object CryptoManager {
|
||||
* (чтобы кэш был горячий к моменту дешифровки)
|
||||
*/
|
||||
fun getPbkdf2Key(password: String): SecretKeySpec {
|
||||
return pbkdf2KeyCache.getOrPut(password) {
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
||||
return getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
|
||||
}
|
||||
|
||||
private fun getPbkdf2Key(password: String, algorithm: String): SecretKeySpec {
|
||||
val cacheKey = "$algorithm::$password"
|
||||
return pbkdf2KeyCache.getOrPut(cacheKey) {
|
||||
val factory = SecretKeyFactory.getInstance(algorithm)
|
||||
val spec =
|
||||
PBEKeySpec(
|
||||
password.toCharArray(),
|
||||
@@ -207,8 +214,8 @@ object CryptoManager {
|
||||
/**
|
||||
* Encrypt data with password using PBKDF2 + AES
|
||||
*
|
||||
* ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts):
|
||||
* - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию
|
||||
* ⚠️ ВАЖНО: Совместимость с Desktop (crypto-js 4.x):
|
||||
* - PBKDF2WithHmacSHA256
|
||||
* - Salt: "rosetta"
|
||||
* - Iterations: 1000
|
||||
* - Key size: 256 bit
|
||||
@@ -231,17 +238,8 @@ object CryptoManager {
|
||||
val encryptedChunks = mutableListOf<String>()
|
||||
|
||||
for (chunk in chunks) {
|
||||
// Derive key using PBKDF2-HMAC-SHA1
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
||||
val spec =
|
||||
PBEKeySpec(
|
||||
password.toCharArray(),
|
||||
SALT.toByteArray(Charsets.UTF_8),
|
||||
PBKDF2_ITERATIONS,
|
||||
KEY_SIZE
|
||||
)
|
||||
val secretKey = factory.generateSecret(spec)
|
||||
val key = SecretKeySpec(secretKey.encoded, "AES")
|
||||
// Desktop parity: PBKDF2-HMAC-SHA256
|
||||
val key = getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
|
||||
|
||||
// Generate random IV
|
||||
val iv = ByteArray(16)
|
||||
@@ -262,19 +260,8 @@ object CryptoManager {
|
||||
// Return chunked format: "CHNK:" + chunks joined by "::"
|
||||
return "CHNK:" + encryptedChunks.joinToString("::")
|
||||
} else {
|
||||
// Single chunk (original behavior)
|
||||
// Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!)
|
||||
// crypto-js по умолчанию использует SHA1 для PBKDF2
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
||||
val spec =
|
||||
PBEKeySpec(
|
||||
password.toCharArray(),
|
||||
SALT.toByteArray(Charsets.UTF_8),
|
||||
PBKDF2_ITERATIONS,
|
||||
KEY_SIZE
|
||||
)
|
||||
val secretKey = factory.generateSecret(spec)
|
||||
val key = SecretKeySpec(secretKey.encoded, "AES")
|
||||
// Single chunk (desktop parity)
|
||||
val key = getPbkdf2Key(password, PBKDF2_HMAC_SHA256)
|
||||
|
||||
// Generate random IV
|
||||
val iv = ByteArray(16)
|
||||
@@ -297,8 +284,8 @@ object CryptoManager {
|
||||
/**
|
||||
* Decrypt data with password
|
||||
*
|
||||
* ⚠️ ВАЖНО: Совместимость с JS (crypto-js) и React Native (cryptoJSI.ts):
|
||||
* - PBKDF2WithHmacSHA1 (не SHA256!) - crypto-js использует SHA1 по умолчанию
|
||||
* ⚠️ ВАЖНО: Desktop использует PBKDF2-SHA256.
|
||||
* Для обратной совместимости с legacy Android данными пробуем также SHA1.
|
||||
* - Salt: "rosetta"
|
||||
* - Iterations: 1000
|
||||
* - Key size: 256 bit
|
||||
@@ -339,71 +326,83 @@ object CryptoManager {
|
||||
|
||||
/** 🔐 Внутренняя функция расшифровки (без кэширования результата) */
|
||||
private fun decryptWithPasswordInternal(encryptedData: String, password: String): String? {
|
||||
return try {
|
||||
// 🚀 Получаем кэшированный PBKDF2 ключ
|
||||
val key = getPbkdf2Key(password)
|
||||
val keysToTry =
|
||||
listOf(
|
||||
getPbkdf2Key(password, PBKDF2_HMAC_SHA256),
|
||||
getPbkdf2Key(password, PBKDF2_HMAC_SHA1)
|
||||
)
|
||||
|
||||
keysToTry.forEach { key ->
|
||||
// Check for old format: base64-encoded string containing hex
|
||||
if (isOldFormat(encryptedData)) {
|
||||
val decoded = String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
|
||||
val parts = decoded.split(":")
|
||||
if (parts.size != 2) return null
|
||||
try {
|
||||
val decoded =
|
||||
String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
|
||||
val parts = decoded.split(":")
|
||||
if (parts.size != 2) return@forEach
|
||||
|
||||
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
// Decrypt with AES-256-CBC (используем кэшированный ключ!)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
val decrypted = cipher.doFinal(ciphertext)
|
||||
|
||||
return String(decrypted, Charsets.UTF_8)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
val decrypted = cipher.doFinal(ciphertext)
|
||||
return String(decrypted, Charsets.UTF_8)
|
||||
} catch (_: Exception) {
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
|
||||
// Check for chunked format
|
||||
if (encryptedData.startsWith("CHNK:")) {
|
||||
val chunkStrings = encryptedData.substring(5).split("::")
|
||||
val decompressedParts = mutableListOf<ByteArray>()
|
||||
try {
|
||||
val chunkStrings = encryptedData.substring(5).split("::")
|
||||
val decompressedParts = mutableListOf<ByteArray>()
|
||||
|
||||
for (chunkString in chunkStrings) {
|
||||
val parts = chunkString.split(":")
|
||||
if (parts.size != 2) return null
|
||||
for (chunkString in chunkStrings) {
|
||||
val parts = chunkString.split(":")
|
||||
if (parts.size != 2) return@forEach
|
||||
|
||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
|
||||
// Decrypt with AES-256-CBC (используем кэшированный ключ!)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
val decrypted = cipher.doFinal(ciphertext)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
val decrypted = cipher.doFinal(ciphertext)
|
||||
|
||||
decompressedParts.add(decrypted)
|
||||
decompressedParts.add(decrypted)
|
||||
}
|
||||
|
||||
// Concatenate all decrypted chunks
|
||||
val allBytes = decompressedParts.fold(ByteArray(0)) { acc, arr -> acc + arr }
|
||||
|
||||
// Decompress the concatenated data
|
||||
return String(decompress(allBytes), Charsets.UTF_8)
|
||||
} catch (_: Exception) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
// Concatenate all decrypted chunks
|
||||
val allBytes = decompressedParts.fold(ByteArray(0)) { acc, arr -> acc + arr }
|
||||
|
||||
// Decompress the concatenated data
|
||||
return String(decompress(allBytes), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
// New format: base64 "iv:ciphertext"
|
||||
val parts = encryptedData.split(":")
|
||||
if (parts.size != 2) return null
|
||||
try {
|
||||
val parts = encryptedData.split(":")
|
||||
if (parts.size != 2) return@forEach
|
||||
|
||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
|
||||
// Decrypt with AES-256-CBC (используем кэшированный ключ!)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
val decrypted = cipher.doFinal(ciphertext)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
val decrypted = cipher.doFinal(ciphertext)
|
||||
|
||||
// Decompress (zlib inflate - совместимо с pako.inflate в JS)
|
||||
String(decompress(decrypted), Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
// Decompress (совместимо с desktop + fallback для legacy)
|
||||
return String(decompress(decrypted), Charsets.UTF_8)
|
||||
} catch (_: Exception) {
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check if data is in old format (base64-encoded hex with ":") */
|
||||
@@ -425,16 +424,13 @@ object CryptoManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* RAW Deflate сжатие (без zlib header)
|
||||
* Сжатие данных для encodeWithPassword.
|
||||
*
|
||||
* ⚠️ ВАЖНО: nowrap=true для совместимости с pako.deflate() в JS!
|
||||
* - pako.deflate() создаёт RAW deflate поток (без 2-byte zlib header)
|
||||
* - Java Deflater() по умолчанию создаёт zlib поток (с header 78 9C)
|
||||
* - Поэтому используем Deflater(level, true) где true = nowrap
|
||||
* Десктоп использует pako.deflate (zlib wrapper), поэтому тут должен быть обычный
|
||||
* Deflater без nowrap=true.
|
||||
*/
|
||||
private fun compress(data: ByteArray): ByteArray {
|
||||
// nowrap=true = RAW deflate (совместимо с pako.deflate)
|
||||
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true)
|
||||
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION)
|
||||
deflater.setInput(data)
|
||||
deflater.finish()
|
||||
|
||||
@@ -450,27 +446,38 @@ object CryptoManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* RAW Inflate декомпрессия (без zlib header)
|
||||
*
|
||||
* ⚠️ ВАЖНО: nowrap=true для совместимости с pako.inflate() в JS!
|
||||
* - pako.inflate() ожидает RAW deflate поток
|
||||
* - Java Inflater() по умолчанию ожидает zlib поток (с header)
|
||||
* - Поэтому используем Inflater(true) где true = nowrap
|
||||
* Декомпрессия с обратной совместимостью:
|
||||
* 1) сначала zlib (desktop/new android),
|
||||
* 2) затем raw deflate (legacy android данные).
|
||||
*/
|
||||
private fun decompress(data: ByteArray): ByteArray {
|
||||
// nowrap=true = RAW inflate (совместимо с pako.inflate)
|
||||
val inflater = Inflater(true)
|
||||
return try {
|
||||
inflate(data, nowrap = false)
|
||||
} catch (_: Exception) {
|
||||
inflate(data, nowrap = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun inflate(data: ByteArray, nowrap: Boolean): ByteArray {
|
||||
val inflater = Inflater(nowrap)
|
||||
inflater.setInput(data)
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(1024)
|
||||
while (!inflater.finished()) {
|
||||
val count = inflater.inflate(buffer)
|
||||
if (count == 0) {
|
||||
if (inflater.needsInput() || inflater.needsDictionary()) {
|
||||
throw IllegalStateException("Inflate failed: incomplete or unsupported stream")
|
||||
}
|
||||
}
|
||||
outputStream.write(buffer, 0, count)
|
||||
}
|
||||
inflater.end() // Освобождаем ресурсы
|
||||
outputStream.close()
|
||||
return outputStream.toByteArray()
|
||||
val result = outputStream.toByteArray()
|
||||
if (result.isEmpty()) throw IllegalStateException("Decompression produced empty output")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -512,6 +512,23 @@ object MessageCrypto {
|
||||
myPrivateKey: String
|
||||
): String = decryptIncomingFull(ciphertext, encryptedKey, myPrivateKey).plaintext
|
||||
|
||||
fun decryptIncomingFullWithPlainKey(
|
||||
ciphertext: String,
|
||||
plainKeyAndNonce: ByteArray
|
||||
): DecryptedIncoming {
|
||||
require(plainKeyAndNonce.size >= 56) { "Invalid plainKeyAndNonce size: ${plainKeyAndNonce.size}" }
|
||||
|
||||
val key = plainKeyAndNonce.copyOfRange(0, 32)
|
||||
val nonce = plainKeyAndNonce.copyOfRange(32, 56)
|
||||
val plaintext = decryptMessage(ciphertext, key.toHex(), nonce.toHex())
|
||||
return DecryptedIncoming(plaintext, plainKeyAndNonce)
|
||||
}
|
||||
|
||||
fun decryptIncomingWithPlainKey(
|
||||
ciphertext: String,
|
||||
plainKeyAndNonce: ByteArray
|
||||
): String = decryptIncomingFullWithPlainKey(ciphertext, plainKeyAndNonce).plaintext
|
||||
|
||||
/**
|
||||
* Расшифровка MESSAGES attachment blob
|
||||
* Формат: ivBase64:ciphertextBase64
|
||||
|
||||
@@ -49,6 +49,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private val messageDao = database.messageDao()
|
||||
private val dialogDao = database.dialogDao()
|
||||
private val avatarDao = database.avatarDao()
|
||||
private val syncTimeDao = database.syncTimeDao()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -95,6 +96,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: MessageRepository? = null
|
||||
|
||||
const val SYSTEM_SAFE_PUBLIC_KEY = "0x000000000000000000000000000000000000000002"
|
||||
const val SYSTEM_SAFE_TITLE = "Safe"
|
||||
const val SYSTEM_SAFE_USERNAME = "safe"
|
||||
|
||||
// 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов
|
||||
// LRU кэш с ограничением 1000 элементов - защита от race conditions
|
||||
private val processedMessageIds =
|
||||
@@ -136,6 +141,86 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addDeviceLoginSystemMessage(
|
||||
ipAddress: String,
|
||||
deviceId: String,
|
||||
deviceName: String,
|
||||
deviceOs: String
|
||||
) {
|
||||
val account = currentAccount ?: return
|
||||
val privateKey = currentPrivateKey ?: return
|
||||
|
||||
val safeIp = ipAddress.ifBlank { "unknown" }
|
||||
val safeDeviceName = deviceName.ifBlank { "Unknown device" }
|
||||
val safeDeviceOs = deviceOs.ifBlank { "unknown" }
|
||||
val safeDeviceId = dotCenterIfNeeded(deviceId.ifBlank { "unknown" }, maxLength = 12, side = 4)
|
||||
|
||||
val messageText =
|
||||
"""
|
||||
Attempt to login from a new device
|
||||
|
||||
We detected a login to your account from $safeIp a new device by seed phrase. If this was you, you can safely ignore this message.
|
||||
|
||||
Arch: $safeDeviceOs
|
||||
IP: $safeIp
|
||||
Device: $safeDeviceName
|
||||
ID: $safeDeviceId
|
||||
"""
|
||||
.trimIndent()
|
||||
|
||||
val encryptedPlainMessage =
|
||||
try {
|
||||
CryptoManager.encryptWithPassword(messageText, privateKey)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val dialogKey = getDialogKey(SYSTEM_SAFE_PUBLIC_KEY)
|
||||
|
||||
val inserted =
|
||||
messageDao.insertMessage(
|
||||
MessageEntity(
|
||||
account = account,
|
||||
fromPublicKey = SYSTEM_SAFE_PUBLIC_KEY,
|
||||
toPublicKey = account,
|
||||
content = "",
|
||||
timestamp = timestamp,
|
||||
chachaKey = "",
|
||||
read = 0,
|
||||
fromMe = 0,
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
)
|
||||
|
||||
if (inserted == -1L) return
|
||||
|
||||
val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY)
|
||||
dialogDao.insertDialog(
|
||||
DialogEntity(
|
||||
id = existing?.id ?: 0,
|
||||
account = account,
|
||||
opponentKey = SYSTEM_SAFE_PUBLIC_KEY,
|
||||
opponentTitle = existing?.opponentTitle?.ifBlank { SYSTEM_SAFE_TITLE } ?: SYSTEM_SAFE_TITLE,
|
||||
opponentUsername =
|
||||
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
|
||||
?: SYSTEM_SAFE_USERNAME,
|
||||
isOnline = existing?.isOnline ?: 0,
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = maxOf(existing?.verified ?: 0, 1),
|
||||
iHaveSent = 1
|
||||
)
|
||||
)
|
||||
|
||||
dialogDao.updateDialogFromMessages(account, SYSTEM_SAFE_PUBLIC_KEY)
|
||||
_newMessageEvents.tryEmit(dialogKey)
|
||||
}
|
||||
|
||||
/** Инициализация с текущим аккаунтом */
|
||||
fun initialize(publicKey: String, privateKey: String) {
|
||||
val start = System.currentTimeMillis()
|
||||
@@ -163,6 +248,20 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return currentAccount != null && currentPrivateKey != null
|
||||
}
|
||||
|
||||
suspend fun getLastSyncTimestamp(): Long {
|
||||
val account = currentAccount ?: return 0L
|
||||
return syncTimeDao.getLastSync(account) ?: 0L
|
||||
}
|
||||
|
||||
suspend fun updateLastSyncTimestamp(timestamp: Long) {
|
||||
if (timestamp <= 0) return
|
||||
val account = currentAccount ?: return
|
||||
val existing = syncTimeDao.getLastSync(account) ?: 0L
|
||||
if (timestamp > existing) {
|
||||
syncTimeDao.upsert(AccountSyncTimeEntity(account = account, lastSync = timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Получить поток сообщений для диалога */
|
||||
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
|
||||
val dialogKey = getDialogKey(opponentKey)
|
||||
@@ -238,6 +337,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val aesChachaKey =
|
||||
CryptoManager.encryptWithPassword(
|
||||
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
privateKey
|
||||
)
|
||||
|
||||
// 📝 LOG: Шифрование успешно
|
||||
MessageLogger.logEncryptionSuccess(
|
||||
@@ -344,6 +448,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.toPublicKey = toPublicKey
|
||||
this.content = encryptedContent
|
||||
this.chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
this.messageId = messageId
|
||||
@@ -397,11 +502,15 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
timestamp = packet.timestamp
|
||||
)
|
||||
|
||||
val isOwnMessage = packet.fromPublicKey == account
|
||||
|
||||
// 🔥 Проверяем, не заблокирован ли отправитель
|
||||
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
|
||||
if (isBlocked) {
|
||||
MessageLogger.logBlockedSender(packet.fromPublicKey)
|
||||
return
|
||||
if (!isOwnMessage) {
|
||||
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
|
||||
if (isBlocked) {
|
||||
MessageLogger.logBlockedSender(packet.fromPublicKey)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed)
|
||||
@@ -435,17 +544,37 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
val dialogKey = getDialogKey(packet.fromPublicKey)
|
||||
val dialogOpponentKey = if (isOwnMessage) packet.toPublicKey else packet.fromPublicKey
|
||||
val dialogKey = getDialogKey(dialogOpponentKey)
|
||||
|
||||
try {
|
||||
// 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!)
|
||||
// Desktop: хранит зашифрованный ключ, расшифровывает только при использовании
|
||||
// Buffer.from(await decrypt(message.chacha_key, privatePlain),
|
||||
// "binary").toString('utf-8')
|
||||
val plainKeyAndNonce =
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
|
||||
?.toByteArray(Charsets.ISO_8859_1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
||||
ProtocolManager.addLog(
|
||||
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
|
||||
)
|
||||
}
|
||||
|
||||
if (isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
|
||||
MessageLogger.debug(
|
||||
"📥 OWN SYNC fallback: aesChachaKey is missing, trying chachaKey decrypt"
|
||||
)
|
||||
}
|
||||
|
||||
// Расшифровываем
|
||||
val plainText =
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
if (plainKeyAndNonce != null) {
|
||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||
} else {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
}
|
||||
|
||||
// 📝 LOG: Расшифровка успешна
|
||||
MessageLogger.logDecryptionSuccess(
|
||||
@@ -459,18 +588,25 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
serializeAttachmentsWithDecryption(
|
||||
packet.attachments,
|
||||
packet.chachaKey,
|
||||
privateKey
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
)
|
||||
|
||||
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
|
||||
processImageAttachments(packet.attachments, packet.chachaKey, privateKey)
|
||||
processImageAttachments(
|
||||
packet.attachments,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
)
|
||||
|
||||
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||
processAvatarAttachments(
|
||||
packet.attachments,
|
||||
packet.fromPublicKey,
|
||||
packet.chachaKey,
|
||||
privateKey
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
)
|
||||
|
||||
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||
@@ -486,7 +622,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
timestamp = packet.timestamp,
|
||||
chachaKey = packet.chachaKey,
|
||||
read = 0,
|
||||
fromMe = 0,
|
||||
fromMe = if (isOwnMessage) 1 else 0,
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||
@@ -506,10 +642,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
|
||||
dialogDao.updateDialogFromMessages(account, dialogOpponentKey)
|
||||
|
||||
// 🔥 Логируем что записалось в диалог
|
||||
val dialog = dialogDao.getDialog(account, packet.fromPublicKey)
|
||||
val dialog = dialogDao.getDialog(account, dialogOpponentKey)
|
||||
MessageLogger.logDialogUpdate(
|
||||
dialogKey = dialogKey,
|
||||
lastMessage = dialog?.lastMessage,
|
||||
@@ -517,7 +653,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
)
|
||||
|
||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
||||
requestUserInfo(packet.fromPublicKey)
|
||||
requestUserInfo(dialogOpponentKey)
|
||||
|
||||
// Обновляем кэш только если сообщение новое
|
||||
if (!stillExists) {
|
||||
@@ -534,6 +670,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
} catch (e: Exception) {
|
||||
// 📝 LOG: Ошибка обработки
|
||||
MessageLogger.logDecryptionError(messageId, e)
|
||||
ProtocolManager.addLog(
|
||||
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
|
||||
)
|
||||
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
|
||||
processedMessageIds.remove(messageId)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
@@ -573,17 +714,24 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
MessageLogger.debug("👁 READ PACKET from: ${packet.fromPublicKey.take(16)}...")
|
||||
|
||||
// Синхронизация read может прийти как:
|
||||
// 1) from=opponent, to=account (обычный read от собеседника)
|
||||
// 2) from=account, to=opponent (read c другого устройства этого же аккаунта)
|
||||
val opponentKey =
|
||||
if (packet.fromPublicKey == account) packet.toPublicKey else packet.fromPublicKey
|
||||
if (opponentKey.isBlank()) return
|
||||
|
||||
// Проверяем последнее сообщение ДО обновления
|
||||
val lastMsgBefore = messageDao.getLastMessageDebug(account, packet.fromPublicKey)
|
||||
val lastMsgBefore = messageDao.getLastMessageDebug(account, opponentKey)
|
||||
|
||||
// Отмечаем все наши исходящие сообщения к этому собеседнику как прочитанные
|
||||
messageDao.markAllAsRead(account, packet.fromPublicKey)
|
||||
messageDao.markAllAsRead(account, opponentKey)
|
||||
|
||||
// 🔥 DEBUG: Проверяем последнее сообщение ПОСЛЕ обновления
|
||||
val lastMsgAfter = messageDao.getLastMessageDebug(account, packet.fromPublicKey)
|
||||
val lastMsgAfter = messageDao.getLastMessageDebug(account, opponentKey)
|
||||
|
||||
// Обновляем кэш - все исходящие сообщения помечаем как прочитанные
|
||||
val dialogKey = getDialogKey(packet.fromPublicKey)
|
||||
val dialogKey = getDialogKey(opponentKey)
|
||||
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
flow.value =
|
||||
@@ -596,13 +744,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
||||
|
||||
// 📝 LOG: Статус прочтения
|
||||
MessageLogger.logReadStatus(fromPublicKey = packet.fromPublicKey, messagesCount = readCount)
|
||||
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
|
||||
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
|
||||
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
|
||||
dialogDao.updateDialogFromMessages(account, opponentKey)
|
||||
|
||||
// Логируем что записалось в диалог
|
||||
val dialog = dialogDao.getDialog(account, packet.fromPublicKey)
|
||||
val dialog = dialogDao.getDialog(account, opponentKey)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -719,6 +867,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun dotCenterIfNeeded(value: String, maxLength: Int, side: Int): String {
|
||||
if (value.length <= maxLength) return value
|
||||
val safeSide = side.coerceAtLeast(1)
|
||||
if (safeSide * 2 >= value.length) return value
|
||||
return value.take(safeSide) + "..." + value.takeLast(safeSide)
|
||||
}
|
||||
|
||||
private suspend fun updateDialog(
|
||||
opponentKey: String,
|
||||
lastMessage: String,
|
||||
@@ -919,7 +1074,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
attachments: List<MessageAttachment>,
|
||||
fromPublicKey: String,
|
||||
encryptedKey: String,
|
||||
privateKey: String
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
) {
|
||||
|
||||
for (attachment in attachments) {
|
||||
@@ -929,11 +1085,14 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем аватар в кэш
|
||||
@@ -964,7 +1123,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private fun processImageAttachments(
|
||||
attachments: List<MessageAttachment>,
|
||||
encryptedKey: String,
|
||||
privateKey: String
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
) {
|
||||
val publicKey = currentAccount ?: return
|
||||
|
||||
@@ -975,11 +1135,14 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем в файл (как в desktop)
|
||||
@@ -1008,7 +1171,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private fun serializeAttachmentsWithDecryption(
|
||||
attachments: List<MessageAttachment>,
|
||||
encryptedKey: String,
|
||||
privateKey: String
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
): String {
|
||||
if (attachments.isEmpty()) return "[]"
|
||||
|
||||
@@ -1022,11 +1186,14 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
try {
|
||||
// 1. Расшифровываем с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)
|
||||
|
||||
@@ -49,8 +49,9 @@ class PreferencesManager(private val context: Context) {
|
||||
// Language
|
||||
val APP_LANGUAGE = stringPreferencesKey("app_language") // "en", "ru", etc.
|
||||
|
||||
// Appearance / Customization
|
||||
val BACKGROUND_BLUR_COLOR_ID = stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
|
||||
// Appearance / Customization (legacy global key)
|
||||
val BACKGROUND_BLUR_COLOR_ID =
|
||||
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
|
||||
|
||||
// Pinned Chats (max 3)
|
||||
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
|
||||
@@ -219,6 +220,12 @@ class PreferencesManager(private val context: Context) {
|
||||
// 🎨 APPEARANCE / CUSTOMIZATION
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
private fun buildBackgroundBlurColorKey(account: String): Preferences.Key<String>? {
|
||||
val trimmedAccount = account.trim()
|
||||
if (trimmedAccount.isBlank()) return null
|
||||
return stringPreferencesKey("background_blur_color_id::$trimmedAccount")
|
||||
}
|
||||
|
||||
val backgroundBlurColorId: Flow<String> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
preferences[BACKGROUND_BLUR_COLOR_ID] ?: "avatar" // Default: use avatar blur
|
||||
@@ -228,6 +235,21 @@ class PreferencesManager(private val context: Context) {
|
||||
context.dataStore.edit { preferences -> preferences[BACKGROUND_BLUR_COLOR_ID] = value }
|
||||
}
|
||||
|
||||
fun backgroundBlurColorIdForAccount(account: String): Flow<String> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
val scopedKey = buildBackgroundBlurColorKey(account)
|
||||
if (scopedKey != null) preferences[scopedKey] ?: "avatar" else "avatar"
|
||||
}
|
||||
|
||||
suspend fun setBackgroundBlurColorId(account: String, value: String) {
|
||||
val scopedKey = buildBackgroundBlurColorKey(account)
|
||||
context.dataStore.edit { preferences ->
|
||||
if (scopedKey != null) {
|
||||
preferences[scopedKey] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 📌 PINNED CHATS
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -14,8 +14,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
MessageEntity::class,
|
||||
DialogEntity::class,
|
||||
BlacklistEntity::class,
|
||||
AvatarCacheEntity::class],
|
||||
version = 11,
|
||||
AvatarCacheEntity::class,
|
||||
AccountSyncTimeEntity::class],
|
||||
version = 12,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class RosettaDatabase : RoomDatabase() {
|
||||
@@ -24,6 +25,7 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
abstract fun dialogDao(): DialogDao
|
||||
abstract fun blacklistDao(): BlacklistDao
|
||||
abstract fun avatarDao(): AvatarDao
|
||||
abstract fun syncTimeDao(): SyncTimeDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: RosettaDatabase? = null
|
||||
@@ -132,6 +134,20 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_11_12 =
|
||||
object : Migration(11, 12) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS accounts_sync_times (
|
||||
account TEXT NOT NULL PRIMARY KEY,
|
||||
last_sync INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(context: Context): RosettaDatabase {
|
||||
return INSTANCE
|
||||
?: synchronized(this) {
|
||||
@@ -151,7 +167,8 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9,
|
||||
MIGRATION_9_10,
|
||||
MIGRATION_10_11
|
||||
MIGRATION_10_11,
|
||||
MIGRATION_11_12
|
||||
)
|
||||
.fallbackToDestructiveMigration() // Для разработки - только
|
||||
// если миграция не
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
enum class HandshakeState(val value: Int) {
|
||||
COMPLETED(0),
|
||||
NEED_DEVICE_VERIFICATION(1);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int): HandshakeState {
|
||||
return entries.firstOrNull { it.value == value } ?: COMPLETED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class HandshakeDevice(
|
||||
var deviceId: String = "",
|
||||
var deviceName: String = "",
|
||||
var deviceOs: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* Handshake packet (ID: 0x00)
|
||||
* First packet sent by client to authenticate with the server
|
||||
@@ -9,6 +26,8 @@ class PacketHandshake : Packet() {
|
||||
var publicKey: String = ""
|
||||
var protocolVersion: Int = 1
|
||||
var heartbeatInterval: Int = 15
|
||||
var device: HandshakeDevice = HandshakeDevice()
|
||||
var handshakeState: HandshakeState = HandshakeState.NEED_DEVICE_VERIFICATION
|
||||
|
||||
override fun getPacketId(): Int = 0x00
|
||||
|
||||
@@ -17,6 +36,12 @@ class PacketHandshake : Packet() {
|
||||
publicKey = stream.readString()
|
||||
protocolVersion = stream.readInt8()
|
||||
heartbeatInterval = stream.readInt8()
|
||||
device = HandshakeDevice(
|
||||
deviceId = stream.readString(),
|
||||
deviceName = stream.readString(),
|
||||
deviceOs = stream.readString()
|
||||
)
|
||||
handshakeState = HandshakeState.fromValue(stream.readInt8())
|
||||
}
|
||||
|
||||
override fun send(): Stream {
|
||||
@@ -26,6 +51,10 @@ class PacketHandshake : Packet() {
|
||||
stream.writeString(publicKey)
|
||||
stream.writeInt8(protocolVersion)
|
||||
stream.writeInt8(heartbeatInterval)
|
||||
stream.writeString(device.deviceId)
|
||||
stream.writeString(device.deviceName)
|
||||
stream.writeString(device.deviceOs)
|
||||
stream.writeInt8(handshakeState.value)
|
||||
return stream
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class PacketMessage : Packet() {
|
||||
var timestamp: Long = 0
|
||||
var privateKey: String = "" // Hash приватного ключа (для авторизации)
|
||||
var messageId: String = ""
|
||||
var aesChachaKey: String = "" // ChaCha key+nonce зашифрованный приватным ключом отправителя
|
||||
var attachments: List<MessageAttachment> = emptyList()
|
||||
|
||||
override fun getPacketId(): Int = 0x06
|
||||
@@ -36,6 +37,7 @@ class PacketMessage : Packet() {
|
||||
))
|
||||
}
|
||||
attachments = attachmentsList
|
||||
aesChachaKey = stream.readString()
|
||||
}
|
||||
|
||||
override fun send(): Stream {
|
||||
@@ -56,6 +58,7 @@ class PacketMessage : Packet() {
|
||||
stream.writeString(attachment.blob)
|
||||
stream.writeInt8(attachment.type.value)
|
||||
}
|
||||
stream.writeString(aesChachaKey)
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ enum class ProtocolState {
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
HANDSHAKING,
|
||||
DEVICE_VERIFICATION_REQUIRED,
|
||||
AUTHENTICATED
|
||||
}
|
||||
|
||||
@@ -102,6 +103,7 @@ class Protocol(
|
||||
// Last used credentials for reconnection
|
||||
private var lastPublicKey: String? = null
|
||||
private var lastPrivateHash: String? = null
|
||||
private var lastDevice: HandshakeDevice = HandshakeDevice()
|
||||
|
||||
// Getters for ProtocolManager to fetch own profile
|
||||
fun getPublicKey(): String? = lastPublicKey
|
||||
@@ -121,21 +123,40 @@ class Protocol(
|
||||
0x06 to { PacketMessage() },
|
||||
0x07 to { PacketRead() },
|
||||
0x08 to { PacketDelivery() },
|
||||
0x09 to { PacketChunk() },
|
||||
0x0B to { PacketTyping() }
|
||||
0x09 to { PacketDeviceNew() },
|
||||
0x0B to { PacketTyping() },
|
||||
0x0F to { PacketRequestTransport() },
|
||||
0x17 to { PacketDeviceList() },
|
||||
0x18 to { PacketDeviceResolve() },
|
||||
0x19 to { PacketSync() }
|
||||
)
|
||||
|
||||
init {
|
||||
// Register handshake response handler
|
||||
waitPacket(0x00) { packet ->
|
||||
if (packet is PacketHandshake) {
|
||||
log("✅ HANDSHAKE SUCCESS: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s")
|
||||
handshakeJob?.cancel()
|
||||
handshakeComplete = true
|
||||
setState(ProtocolState.AUTHENTICATED, "Handshake response received")
|
||||
flushPacketQueue()
|
||||
|
||||
// Start heartbeat with interval from server
|
||||
when (packet.handshakeState) {
|
||||
HandshakeState.COMPLETED -> {
|
||||
log("✅ HANDSHAKE COMPLETE: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s")
|
||||
handshakeComplete = true
|
||||
setState(ProtocolState.AUTHENTICATED, "Handshake completed")
|
||||
flushPacketQueue()
|
||||
}
|
||||
|
||||
HandshakeState.NEED_DEVICE_VERIFICATION -> {
|
||||
log("🔐 HANDSHAKE NEEDS DEVICE VERIFICATION")
|
||||
handshakeComplete = false
|
||||
setState(
|
||||
ProtocolState.DEVICE_VERIFICATION_REQUIRED,
|
||||
"Handshake requires device verification"
|
||||
)
|
||||
packetQueue.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Keep heartbeat in both handshake states to maintain server session.
|
||||
startHeartbeat(packet.heartbeatInterval)
|
||||
}
|
||||
}
|
||||
@@ -171,7 +192,10 @@ class Protocol(
|
||||
val currentState = _state.value
|
||||
val socketAlive = webSocket != null
|
||||
|
||||
if (currentState == ProtocolState.AUTHENTICATED) {
|
||||
if (
|
||||
currentState == ProtocolState.AUTHENTICATED ||
|
||||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED
|
||||
) {
|
||||
val sent = webSocket?.send("heartbeat") ?: false
|
||||
if (sent) {
|
||||
log("💓 Heartbeat OK (socket=$socketAlive, state=$currentState)")
|
||||
@@ -184,7 +208,9 @@ class Protocol(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log("💔 HEARTBEAT SKIPPED: state=$currentState (not AUTHENTICATED), socket=$socketAlive")
|
||||
log(
|
||||
"💔 HEARTBEAT SKIPPED: state=$currentState (not ready), socket=$socketAlive"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("💔 HEARTBEAT EXCEPTION: ${e.message}")
|
||||
@@ -200,7 +226,11 @@ class Protocol(
|
||||
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
|
||||
|
||||
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
|
||||
if (currentState == ProtocolState.AUTHENTICATED || currentState == ProtocolState.HANDSHAKING) {
|
||||
if (
|
||||
currentState == ProtocolState.AUTHENTICATED ||
|
||||
currentState == ProtocolState.HANDSHAKING ||
|
||||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED
|
||||
) {
|
||||
log("✅ Already authenticated or handshaking, skipping connect()")
|
||||
return
|
||||
}
|
||||
@@ -264,7 +294,7 @@ class Protocol(
|
||||
lastPublicKey?.let { publicKey ->
|
||||
lastPrivateHash?.let { privateHash ->
|
||||
log("🤝 Auto-starting handshake with saved credentials")
|
||||
startHandshake(publicKey, privateHash)
|
||||
startHandshake(publicKey, privateHash, lastDevice)
|
||||
}
|
||||
} ?: log("⚠️ No saved credentials, waiting for manual handshake")
|
||||
} else {
|
||||
@@ -308,19 +338,24 @@ class Protocol(
|
||||
/**
|
||||
* Start handshake with server
|
||||
*/
|
||||
fun startHandshake(publicKey: String, privateHash: String) {
|
||||
fun startHandshake(publicKey: String, privateHash: String, device: HandshakeDevice) {
|
||||
log("🤝 Starting handshake...")
|
||||
log(" Public key: ${publicKey.take(20)}...")
|
||||
log(" Private hash: ${privateHash.take(20)}...")
|
||||
log(" Current state: ${_state.value}")
|
||||
|
||||
// Detect account switch: already authenticated but with different credentials
|
||||
val switchingAccount = (_state.value == ProtocolState.AUTHENTICATED || _state.value == ProtocolState.HANDSHAKING) &&
|
||||
val switchingAccount = (
|
||||
_state.value == ProtocolState.AUTHENTICATED ||
|
||||
_state.value == ProtocolState.HANDSHAKING ||
|
||||
_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED
|
||||
) &&
|
||||
lastPublicKey != null && lastPublicKey != publicKey
|
||||
|
||||
// Save credentials for reconnection
|
||||
lastPublicKey = publicKey
|
||||
lastPrivateHash = privateHash
|
||||
lastDevice = device
|
||||
|
||||
// If switching accounts, force disconnect and reconnect with new credentials
|
||||
if (switchingAccount) {
|
||||
@@ -341,6 +376,11 @@ class Protocol(
|
||||
return
|
||||
}
|
||||
|
||||
if (_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED) {
|
||||
log("⚠️ HANDSHAKE IGNORED: Waiting for device verification")
|
||||
return
|
||||
}
|
||||
|
||||
if (_state.value != ProtocolState.CONNECTED) {
|
||||
log("⚠️ HANDSHAKE DEFERRED: Not connected (state=${_state.value}), will handshake after connection")
|
||||
connect()
|
||||
@@ -353,6 +393,7 @@ class Protocol(
|
||||
val handshake = PacketHandshake().apply {
|
||||
this.publicKey = publicKey
|
||||
this.privateKey = privateHash
|
||||
this.device = device
|
||||
}
|
||||
|
||||
sendPacketDirect(handshake)
|
||||
@@ -411,10 +452,12 @@ class Protocol(
|
||||
}
|
||||
|
||||
try {
|
||||
// 📦 Используем Chunker для отправки (как в Desktop)
|
||||
// Если пакет большой, он будет разбит на части автоматически
|
||||
val chunker = Chunker(socket) { msg -> log(msg) }
|
||||
chunker.send(stream)
|
||||
val sent = socket.send(ByteString.of(*data))
|
||||
if (!sent) {
|
||||
log("❌ WebSocket rejected packet ${packet.getPacketId()}, re-queueing")
|
||||
packetQueue.add(packet)
|
||||
return
|
||||
}
|
||||
log("✅ Packet ${packet.getPacketId()} sent successfully")
|
||||
} catch (e: Exception) {
|
||||
log("❌ Exception sending packet ${packet.getPacketId()}: ${e.message}")
|
||||
@@ -554,6 +597,7 @@ class Protocol(
|
||||
*/
|
||||
fun isConnected(): Boolean = _state.value == ProtocolState.CONNECTED ||
|
||||
_state.value == ProtocolState.HANDSHAKING ||
|
||||
_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
||||
_state.value == ProtocolState.AUTHENTICATED
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.security.SecureRandom
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
@@ -21,6 +24,9 @@ object ProtocolManager {
|
||||
|
||||
// Server address - same as React Native version
|
||||
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
|
||||
private const val DEVICE_PREFS = "rosetta_protocol"
|
||||
private const val DEVICE_ID_KEY = "device_id"
|
||||
private const val DEVICE_ID_LENGTH = 128
|
||||
|
||||
private var protocol: Protocol? = null
|
||||
private var messageRepository: MessageRepository? = null
|
||||
@@ -36,6 +42,13 @@ object ProtocolManager {
|
||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
||||
|
||||
// Connected devices and pending verification requests
|
||||
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
|
||||
val devices: StateFlow<List<DeviceEntry>> = _devices.asStateFlow()
|
||||
|
||||
private val _pendingDeviceVerification = MutableStateFlow<DeviceEntry?>(null)
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?> = _pendingDeviceVerification.asStateFlow()
|
||||
|
||||
// Сигнал обновления own profile (username/name загружены с сервера)
|
||||
private val _ownProfileUpdated = MutableStateFlow(0L)
|
||||
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
|
||||
@@ -50,6 +63,10 @@ object ProtocolManager {
|
||||
|
||||
// 🚀 Флаг для включения UI логов (по умолчанию ВЫКЛЮЧЕНО - это вызывало ANR!)
|
||||
private var uiLogsEnabled = false
|
||||
private var lastProtocolState: ProtocolState? = null
|
||||
@Volatile private var syncBatchInProgress = false
|
||||
@Volatile private var resyncRequiredAfterAccountInit = false
|
||||
private val inboundPacketTasks = AtomicInteger(0)
|
||||
|
||||
fun addLog(message: String) {
|
||||
val timestamp = dateFormat.format(Date())
|
||||
@@ -85,7 +102,11 @@ object ProtocolManager {
|
||||
private fun setupStateMonitoring() {
|
||||
scope.launch {
|
||||
getProtocol().state.collect { newState ->
|
||||
// State monitoring without logging
|
||||
val previous = lastProtocolState
|
||||
if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) {
|
||||
onAuthenticated()
|
||||
}
|
||||
lastProtocolState = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,9 +116,13 @@ object ProtocolManager {
|
||||
* Должен вызываться после авторизации пользователя
|
||||
*/
|
||||
fun initializeAccount(publicKey: String, privateKey: String) {
|
||||
val start = System.currentTimeMillis()
|
||||
syncBatchInProgress = false
|
||||
messageRepository?.initialize(publicKey, privateKey)
|
||||
}
|
||||
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
|
||||
resyncRequiredAfterAccountInit = false
|
||||
requestSynchronize()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Настройка обработчиков пакетов
|
||||
@@ -115,9 +140,17 @@ object ProtocolManager {
|
||||
}
|
||||
send(deliveryPacket)
|
||||
|
||||
scope.launch {
|
||||
launchInboundPacketTask {
|
||||
val repository = messageRepository
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
requireResyncAfterAccountInit("⏳ Incoming message before account init, scheduling re-sync")
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
try {
|
||||
messageRepository?.handleIncomingMessage(messagePacket)
|
||||
repository.handleIncomingMessage(messagePacket)
|
||||
if (!syncBatchInProgress) {
|
||||
repository.updateLastSyncTimestamp(messagePacket.timestamp)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Silent error handling
|
||||
}
|
||||
@@ -128,8 +161,13 @@ object ProtocolManager {
|
||||
waitPacket(0x08) { packet ->
|
||||
val deliveryPacket = packet as PacketDelivery
|
||||
|
||||
scope.launch {
|
||||
messageRepository?.handleDelivery(deliveryPacket)
|
||||
launchInboundPacketTask {
|
||||
val repository = messageRepository
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync")
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
repository.handleDelivery(deliveryPacket)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,11 +176,42 @@ object ProtocolManager {
|
||||
waitPacket(0x07) { packet ->
|
||||
val readPacket = packet as PacketRead
|
||||
|
||||
scope.launch {
|
||||
messageRepository?.handleRead(readPacket)
|
||||
launchInboundPacketTask {
|
||||
val repository = messageRepository
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
requireResyncAfterAccountInit("⏳ Read status before account init, scheduling re-sync")
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
repository.handleRead(readPacket)
|
||||
if (!syncBatchInProgress) {
|
||||
repository.updateLastSyncTimestamp(System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 New device login attempt (0x09) — desktop parity (system Safe message)
|
||||
waitPacket(0x09) { packet ->
|
||||
val devicePacket = packet as PacketDeviceNew
|
||||
|
||||
addLog(
|
||||
"🔐 DEVICE LOGIN ATTEMPT: ip=${devicePacket.ipAddress}, device=${devicePacket.device.deviceName}, os=${devicePacket.device.deviceOs}"
|
||||
)
|
||||
|
||||
launchInboundPacketTask {
|
||||
messageRepository?.addDeviceLoginSystemMessage(
|
||||
ipAddress = devicePacket.ipAddress,
|
||||
deviceId = devicePacket.device.deviceId,
|
||||
deviceName = devicePacket.device.deviceName,
|
||||
deviceOs = devicePacket.device.deviceOs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 Обработчик батчевой синхронизации (0x19)
|
||||
waitPacket(0x19) { packet ->
|
||||
handleSyncPacket(packet as PacketSync)
|
||||
}
|
||||
|
||||
// 🟢 Обработчик онлайн-статуса (0x05)
|
||||
waitPacket(0x05) { packet ->
|
||||
val onlinePacket = packet as PacketOnlineState
|
||||
@@ -169,6 +238,16 @@ object ProtocolManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 📱 Обработчик списка устройств (0x17)
|
||||
waitPacket(0x17) { packet ->
|
||||
val devicesPacket = packet as PacketDeviceList
|
||||
val parsedDevices = devicesPacket.devices
|
||||
_devices.value = parsedDevices
|
||||
_pendingDeviceVerification.value = parsedDevices.firstOrNull { device ->
|
||||
device.deviceVerify == DeviceVerifyState.NOT_VERIFIED
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Обработчик поиска/user info (0x03)
|
||||
// Обновляет информацию о пользователе в диалогах когда приходит ответ от сервера
|
||||
// + обновляет own profile (username/name) аналогично Desktop useUserInformation()
|
||||
@@ -225,6 +304,78 @@ object ProtocolManager {
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchInboundPacketTask(block: suspend () -> Unit) {
|
||||
inboundPacketTasks.incrementAndGet()
|
||||
scope.launch {
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
inboundPacketTasks.decrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireResyncAfterAccountInit(reason: String) {
|
||||
if (!resyncRequiredAfterAccountInit) {
|
||||
addLog(reason)
|
||||
}
|
||||
resyncRequiredAfterAccountInit = true
|
||||
}
|
||||
|
||||
private suspend fun waitInboundPacketTasks(timeoutMs: Long = 15_000L) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (inboundPacketTasks.get() > 0 && System.currentTimeMillis() < deadline) {
|
||||
delay(25)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAuthenticated() {
|
||||
TransportManager.requestTransportServer()
|
||||
fetchOwnProfile()
|
||||
requestSynchronize()
|
||||
}
|
||||
|
||||
private fun requestSynchronize() {
|
||||
scope.launch {
|
||||
val repository = messageRepository
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
|
||||
return@launch
|
||||
}
|
||||
val lastSync = repository.getLastSyncTimestamp()
|
||||
sendSynchronize(lastSync)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendSynchronize(timestamp: Long) {
|
||||
val packet = PacketSync().apply {
|
||||
status = SyncStatus.NOT_NEEDED
|
||||
this.timestamp = timestamp
|
||||
}
|
||||
send(packet)
|
||||
}
|
||||
|
||||
private fun handleSyncPacket(packet: PacketSync) {
|
||||
scope.launch {
|
||||
when (packet.status) {
|
||||
SyncStatus.BATCH_START -> {
|
||||
syncBatchInProgress = true
|
||||
}
|
||||
SyncStatus.BATCH_END -> {
|
||||
syncBatchInProgress = true
|
||||
waitInboundPacketTasks()
|
||||
messageRepository?.updateLastSyncTimestamp(packet.timestamp)
|
||||
syncBatchInProgress = false
|
||||
sendSynchronize(packet.timestamp)
|
||||
}
|
||||
SyncStatus.NOT_NEEDED -> {
|
||||
syncBatchInProgress = false
|
||||
messageRepository?.updateLastSyncTimestamp(packet.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create Protocol instance
|
||||
*/
|
||||
@@ -258,14 +409,8 @@ object ProtocolManager {
|
||||
* Authenticate with server
|
||||
*/
|
||||
fun authenticate(publicKey: String, privateHash: String) {
|
||||
getProtocol().startHandshake(publicKey, privateHash)
|
||||
|
||||
// 🚀 Запрашиваем транспортный сервер и own profile после авторизации
|
||||
scope.launch {
|
||||
delay(500) // Даём время на завершение handshake
|
||||
TransportManager.requestTransportServer()
|
||||
fetchOwnProfile()
|
||||
}
|
||||
val device = buildHandshakeDevice()
|
||||
getProtocol().startHandshake(publicKey, privateHash, device)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,6 +526,28 @@ object ProtocolManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending device login request.
|
||||
*/
|
||||
fun acceptDevice(deviceId: String) {
|
||||
val packet = PacketDeviceResolve().apply {
|
||||
this.deviceId = deviceId
|
||||
this.solution = DeviceResolveSolution.ACCEPT
|
||||
}
|
||||
send(packet)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a pending device login request.
|
||||
*/
|
||||
fun declineDevice(deviceId: String) {
|
||||
val packet = PacketDeviceResolve().apply {
|
||||
this.deviceId = deviceId
|
||||
this.solution = DeviceResolveSolution.DECLINE
|
||||
}
|
||||
send(packet)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send packet (simplified)
|
||||
*/
|
||||
@@ -409,12 +576,60 @@ object ProtocolManager {
|
||||
getProtocol().unwaitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
private fun buildHandshakeDevice(): HandshakeDevice {
|
||||
val context = appContext
|
||||
val deviceId = if (context != null) {
|
||||
getOrCreateDeviceId(context)
|
||||
} else {
|
||||
generateDeviceId()
|
||||
}
|
||||
val manufacturer = Build.MANUFACTURER.orEmpty().trim()
|
||||
val model = Build.MODEL.orEmpty().trim()
|
||||
val name = listOf(manufacturer, model)
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.joinToString(" ")
|
||||
.ifBlank { "Android Device" }
|
||||
val os = "Android ${Build.VERSION.RELEASE ?: "Unknown"}"
|
||||
|
||||
return HandshakeDevice(
|
||||
deviceId = deviceId,
|
||||
deviceName = name,
|
||||
deviceOs = os
|
||||
)
|
||||
}
|
||||
|
||||
private fun getOrCreateDeviceId(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(DEVICE_PREFS, Context.MODE_PRIVATE)
|
||||
val cached = prefs.getString(DEVICE_ID_KEY, null)
|
||||
if (!cached.isNullOrBlank()) {
|
||||
return cached
|
||||
}
|
||||
val newId = generateDeviceId()
|
||||
prefs.edit().putString(DEVICE_ID_KEY, newId).apply()
|
||||
return newId
|
||||
}
|
||||
|
||||
private fun generateDeviceId(): String {
|
||||
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
val random = SecureRandom()
|
||||
return buildString(DEVICE_ID_LENGTH) {
|
||||
repeat(DEVICE_ID_LENGTH) {
|
||||
append(chars[random.nextInt(chars.length)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and clear
|
||||
*/
|
||||
fun disconnect() {
|
||||
protocol?.disconnect()
|
||||
protocol?.clearCredentials()
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
syncBatchInProgress = false
|
||||
inboundPacketTasks.set(0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,6 +638,10 @@ object ProtocolManager {
|
||||
fun destroy() {
|
||||
protocol?.destroy()
|
||||
protocol = null
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
syncBatchInProgress = false
|
||||
inboundPacketTasks.set(0)
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.ForwardManager
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
@@ -438,11 +439,13 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
// Динамический subtitle: typing > online > offline
|
||||
val isSystemAccount = user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
|
||||
val chatSubtitle =
|
||||
when {
|
||||
isSavedMessages -> "Notes"
|
||||
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
|
||||
isOnline -> "online"
|
||||
isSystemAccount -> "official account"
|
||||
else -> "offline"
|
||||
}
|
||||
|
||||
@@ -1041,7 +1044,9 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
// Кнопки действий
|
||||
if (!isSavedMessages) {
|
||||
if (!isSavedMessages &&
|
||||
!isSystemAccount
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { /* TODO: Voice call */
|
||||
}
|
||||
@@ -1117,6 +1122,8 @@ fun ChatDetailScreen(
|
||||
isDarkTheme,
|
||||
isSavedMessages =
|
||||
isSavedMessages,
|
||||
isSystemAccount =
|
||||
isSystemAccount,
|
||||
isBlocked =
|
||||
isBlocked,
|
||||
onBlockClick = {
|
||||
@@ -1878,6 +1885,8 @@ fun ChatDetailScreen(
|
||||
message,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
isSystemSafeChat =
|
||||
isSystemAccount,
|
||||
isSelectionMode =
|
||||
isSelectionMode,
|
||||
showTail =
|
||||
|
||||
@@ -1695,6 +1695,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* - Сохранение в БД в IO потоке
|
||||
* - Поддержка Reply/Forward через attachments (как в React Native)
|
||||
*/
|
||||
private fun encryptAesChachaKey(plainKeyAndNonce: ByteArray, privateKey: String): String {
|
||||
return CryptoManager.encryptWithPassword(
|
||||
String(plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
privateKey
|
||||
)
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
val text = _inputText.value.trim()
|
||||
val recipient = opponentKey
|
||||
@@ -1793,6 +1800,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
@@ -1916,6 +1924,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
toPublicKey = recipient
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
@@ -2022,6 +2031,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||
@@ -2118,6 +2128,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
toPublicKey = recipientPublicKey
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
@@ -2369,6 +2380,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
@@ -2404,6 +2416,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
toPublicKey = recipient
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
@@ -2530,6 +2543,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
@@ -2568,6 +2582,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
toPublicKey = recipient
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
@@ -2765,6 +2780,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
@@ -2829,6 +2845,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
toPublicKey = recipient
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
@@ -2934,6 +2951,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
@@ -2968,6 +2986,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
toPublicKey = recipient
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
@@ -3135,6 +3154,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, userPrivateKey)
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey)
|
||||
|
||||
@@ -3179,6 +3199,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
toPublicKey = recipient
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
@@ -37,16 +38,21 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.airbnb.lottie.compose.*
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.BuildConfig
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||
import com.rosetta.messenger.ui.chats.components.DebugLogsBottomSheet
|
||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
@@ -77,6 +83,11 @@ data class Chat(
|
||||
val isPinned: Boolean = false
|
||||
)
|
||||
|
||||
private enum class DeviceResolveAction {
|
||||
ACCEPT,
|
||||
DECLINE
|
||||
}
|
||||
|
||||
// Avatar colors matching React Native app (Mantine inspired)
|
||||
// Light theme colors (background lighter, text darker)
|
||||
private val avatarColorsLight =
|
||||
@@ -250,6 +261,8 @@ fun ChatsListScreen(
|
||||
|
||||
// Protocol connection state
|
||||
val protocolState by ProtocolManager.state.collectAsState()
|
||||
val syncLogs by ProtocolManager.debugLogs.collectAsState()
|
||||
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
||||
|
||||
// 🔥 Пользователи, которые сейчас печатают
|
||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||
@@ -275,6 +288,10 @@ fun ChatsListScreen(
|
||||
|
||||
// Status dialog state
|
||||
var showStatusDialog by remember { mutableStateOf(false) }
|
||||
var showSyncLogs by remember { mutableStateOf(false) }
|
||||
|
||||
// Включаем UI логи только когда открыт bottom sheet, чтобы не перегружать композицию
|
||||
LaunchedEffect(showSyncLogs) { ProtocolManager.enableUILogs(showSyncLogs) }
|
||||
|
||||
// 📬 Requests screen state
|
||||
var showRequestsScreen by remember { mutableStateOf(false) }
|
||||
@@ -298,6 +315,10 @@ fun ChatsListScreen(
|
||||
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
||||
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var deviceResolveRequest by
|
||||
remember {
|
||||
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
|
||||
}
|
||||
|
||||
// 🔥 Selection mode state
|
||||
var selectedChatKeys by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
@@ -379,6 +400,11 @@ fun ChatsListScreen(
|
||||
Color(
|
||||
0xFF4CAF50
|
||||
)
|
||||
ProtocolState
|
||||
.DEVICE_VERIFICATION_REQUIRED ->
|
||||
Color(
|
||||
0xFFFF9800
|
||||
)
|
||||
ProtocolState
|
||||
.CONNECTING,
|
||||
ProtocolState
|
||||
@@ -443,6 +469,15 @@ fun ChatsListScreen(
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
ProtocolState.DEVICE_VERIFICATION_REQUIRED -> {
|
||||
Text(
|
||||
text = "Device verification required",
|
||||
fontSize = 16.sp,
|
||||
fontWeight =
|
||||
FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1161,6 +1196,18 @@ fun ChatsListScreen(
|
||||
},
|
||||
actions = {
|
||||
if (!showRequestsScreen) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
showSyncLogs = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.Bug,
|
||||
contentDescription = "Sync logs",
|
||||
tint = Color.White.copy(alpha = 0.92f)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (protocolState ==
|
||||
@@ -1508,6 +1555,33 @@ fun ChatsListScreen(
|
||||
listBackgroundColor
|
||||
)
|
||||
) {
|
||||
pendingDeviceVerification?.let { pendingDevice ->
|
||||
item(key = "device_verification_banner_${pendingDevice.deviceId}") {
|
||||
Column {
|
||||
DeviceVerificationBanner(
|
||||
device = pendingDevice,
|
||||
isDarkTheme = isDarkTheme,
|
||||
accountPublicKey = accountPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
onAccept = {
|
||||
deviceResolveRequest =
|
||||
pendingDevice to
|
||||
DeviceResolveAction.ACCEPT
|
||||
},
|
||||
onDecline = {
|
||||
deviceResolveRequest =
|
||||
pendingDevice to
|
||||
DeviceResolveAction.DECLINE
|
||||
}
|
||||
)
|
||||
Divider(
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requestsCount > 0) {
|
||||
item(
|
||||
key =
|
||||
@@ -1819,9 +1893,173 @@ fun ChatsListScreen(
|
||||
)
|
||||
}
|
||||
|
||||
deviceResolveRequest?.let { (device, action) ->
|
||||
DeviceResolveDialog(
|
||||
isDarkTheme = isDarkTheme,
|
||||
device = device,
|
||||
action = action,
|
||||
onDismiss = { deviceResolveRequest = null },
|
||||
onConfirm = {
|
||||
val request = deviceResolveRequest
|
||||
deviceResolveRequest = null
|
||||
if (request != null) {
|
||||
when (request.second) {
|
||||
DeviceResolveAction.ACCEPT -> {
|
||||
ProtocolManager.acceptDevice(
|
||||
request.first.deviceId
|
||||
)
|
||||
}
|
||||
DeviceResolveAction.DECLINE -> {
|
||||
ProtocolManager.declineDevice(
|
||||
request.first.deviceId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSyncLogs) {
|
||||
DebugLogsBottomSheet(
|
||||
logs = syncLogs,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onDismiss = { showSyncLogs = false },
|
||||
onClearLogs = { ProtocolManager.clearLogs() }
|
||||
)
|
||||
}
|
||||
|
||||
} // Close Box
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceResolveDialog(
|
||||
isDarkTheme: Boolean,
|
||||
device: DeviceEntry,
|
||||
action: DeviceResolveAction,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
val containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
||||
val borderColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE8E8ED)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val isAccept = action == DeviceResolveAction.ACCEPT
|
||||
val confirmColor = if (isAccept) PrimaryBlue else Color(0xFFFF3B30)
|
||||
val accentBg =
|
||||
if (isDarkTheme) confirmColor.copy(alpha = 0.18f)
|
||||
else confirmColor.copy(alpha = 0.12f)
|
||||
|
||||
val composition by rememberLottieComposition(
|
||||
LottieCompositionSpec.RawRes(
|
||||
if (isAccept) R.raw.saved else R.raw.device_confirm
|
||||
)
|
||||
)
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
iterations = LottieConstants.IterateForever
|
||||
)
|
||||
val deviceLabel = buildString {
|
||||
append(device.deviceName.ifBlank { "Unknown device" })
|
||||
if (device.deviceOs.isNotBlank()) {
|
||||
append(" • ")
|
||||
append(device.deviceOs)
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.widthIn(max = 380.dp),
|
||||
color = containerColor,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
border = BorderStroke(1.dp, borderColor)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(96.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(accentBg),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(78.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Text(
|
||||
text = if (isAccept) "Approve new device?" else "Decline this login?",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
fontSize = 19.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text =
|
||||
if (isAccept) {
|
||||
"Allow \"$deviceLabel\" to access your account?"
|
||||
} else {
|
||||
"Block login from \"$deviceLabel\"?"
|
||||
},
|
||||
color = secondaryTextColor,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 19.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = if (isDarkTheme) Color(0xFF4A4F60) else Color(0xFFD9D9DE)
|
||||
)
|
||||
) {
|
||||
Text("Cancel", color = secondaryTextColor)
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = confirmColor,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(if (isAccept) "Approve" else "Decline login")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Shimmer skeleton для списка чатов — показывается пока данные грузятся Имитирует 10 строк
|
||||
* диалогов: аватар + 2 строки текста
|
||||
|
||||
@@ -33,7 +33,10 @@ import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -244,6 +247,7 @@ fun TypingIndicator(isDarkTheme: Boolean) {
|
||||
fun MessageBubble(
|
||||
message: ChatMessage,
|
||||
isDarkTheme: Boolean,
|
||||
isSystemSafeChat: Boolean = false,
|
||||
isSelectionMode: Boolean = false,
|
||||
showTail: Boolean = true,
|
||||
isGroupStart: Boolean = false,
|
||||
@@ -322,30 +326,43 @@ fun MessageBubble(
|
||||
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
}
|
||||
|
||||
val isSafeSystemMessage =
|
||||
isSystemSafeChat &&
|
||||
!message.isOutgoing &&
|
||||
message.replyData == null &&
|
||||
message.forwardedMessages.isEmpty() &&
|
||||
message.attachments.isEmpty() &&
|
||||
message.text.isNotBlank()
|
||||
|
||||
// Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp
|
||||
val bubbleShape =
|
||||
remember(message.isOutgoing, showTail) {
|
||||
RoundedCornerShape(
|
||||
topStart = TelegramBubbleSpec.bubbleRadius,
|
||||
topEnd = TelegramBubbleSpec.bubbleRadius,
|
||||
bottomStart =
|
||||
if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius
|
||||
else
|
||||
(if (showTail) TelegramBubbleSpec.nearRadius
|
||||
else TelegramBubbleSpec.bubbleRadius),
|
||||
bottomEnd =
|
||||
if (message.isOutgoing)
|
||||
(if (showTail) TelegramBubbleSpec.nearRadius
|
||||
else TelegramBubbleSpec.bubbleRadius)
|
||||
else TelegramBubbleSpec.bubbleRadius
|
||||
)
|
||||
remember(message.isOutgoing, showTail, isSafeSystemMessage) {
|
||||
if (isSafeSystemMessage) {
|
||||
RoundedCornerShape(18.dp)
|
||||
} else {
|
||||
RoundedCornerShape(
|
||||
topStart = TelegramBubbleSpec.bubbleRadius,
|
||||
topEnd = TelegramBubbleSpec.bubbleRadius,
|
||||
bottomStart =
|
||||
if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius
|
||||
else
|
||||
(if (showTail) TelegramBubbleSpec.nearRadius
|
||||
else TelegramBubbleSpec.bubbleRadius),
|
||||
bottomEnd =
|
||||
if (message.isOutgoing)
|
||||
(if (showTail) TelegramBubbleSpec.nearRadius
|
||||
else TelegramBubbleSpec.bubbleRadius)
|
||||
else TelegramBubbleSpec.bubbleRadius
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().pointerInput(Unit) {
|
||||
Modifier.fillMaxWidth().pointerInput(isSafeSystemMessage) {
|
||||
if (isSafeSystemMessage) return@pointerInput
|
||||
// 🔥 Простой горизонтальный свайп для reply
|
||||
// Используем detectHorizontalDragGestures который лучше работает со
|
||||
// скроллом
|
||||
@@ -501,6 +518,7 @@ fun MessageBubble(
|
||||
// Для фото + caption - padding только внизу для текста
|
||||
val bubblePadding =
|
||||
when {
|
||||
isSafeSystemMessage -> PaddingValues(0.dp)
|
||||
hasOnlyMedia -> PaddingValues(0.dp)
|
||||
hasImageWithCaption -> PaddingValues(0.dp)
|
||||
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||
@@ -578,7 +596,9 @@ fun MessageBubble(
|
||||
}
|
||||
|
||||
val bubbleWidthModifier =
|
||||
if (hasImageWithCaption || hasOnlyMedia) {
|
||||
if (isSafeSystemMessage) {
|
||||
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
||||
} else if (hasImageWithCaption || hasOnlyMedia) {
|
||||
Modifier.width(
|
||||
photoWidth
|
||||
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
|
||||
@@ -635,13 +655,25 @@ fun MessageBubble(
|
||||
},
|
||||
shape = bubbleShape
|
||||
)
|
||||
} else if (isSafeSystemMessage) {
|
||||
Modifier.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2D)
|
||||
else Color(0xFFF0F0F4)
|
||||
)
|
||||
} else {
|
||||
Modifier.background(bubbleColor)
|
||||
}
|
||||
)
|
||||
.padding(bubblePadding)
|
||||
) {
|
||||
Column {
|
||||
if (isSafeSystemMessage) {
|
||||
SafeSystemMessageCard(
|
||||
text = message.text,
|
||||
timestamp = message.timestamp,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
// 🔥 Forwarded messages (multiple, desktop parity)
|
||||
if (message.forwardedMessages.isNotEmpty()) {
|
||||
ForwardedMessagesBubble(
|
||||
@@ -962,11 +994,86 @@ fun MessageBubble(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SafeSystemMessageCard(text: String, timestamp: Date, isDarkTheme: Boolean) {
|
||||
val contentColor = if (isDarkTheme) Color(0xFFE8E9EE) else Color(0xFF1E1F23)
|
||||
val timeColor = if (isDarkTheme) Color(0xFFB3B7C0) else Color(0xFF737983)
|
||||
val annotatedText = remember(text) { buildSafeSystemAnnotatedText(text) }
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, top = 12.dp, bottom = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = annotatedText,
|
||||
color = contentColor,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = timeFormat.format(timestamp),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSafeSystemAnnotatedText(text: String) = buildAnnotatedString {
|
||||
val boldStyle = SpanStyle(fontWeight = FontWeight.SemiBold)
|
||||
val lines = text.lines()
|
||||
|
||||
lines.forEachIndexed { index, line ->
|
||||
when {
|
||||
index == 0 && line.isNotBlank() -> {
|
||||
withStyle(boldStyle) { append(line) }
|
||||
}
|
||||
line.startsWith("We detected a login to your account from ") -> {
|
||||
val prefix = "We detected a login to your account from "
|
||||
val marker = " a new device by seed phrase"
|
||||
val tail = line.removePrefix(prefix)
|
||||
val markerIndex = tail.indexOf(marker)
|
||||
|
||||
if (markerIndex > 0) {
|
||||
val ip = tail.substring(0, markerIndex)
|
||||
append(prefix)
|
||||
withStyle(boldStyle) { append(ip) }
|
||||
append(" a new device ")
|
||||
withStyle(boldStyle) { append("by seed phrase") }
|
||||
append(tail.substring(markerIndex + marker.length))
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
}
|
||||
line.startsWith("Arch:") ||
|
||||
line.startsWith("IP:") ||
|
||||
line.startsWith("Device:") ||
|
||||
line.startsWith("ID:") -> {
|
||||
val separatorIndex = line.indexOf(':')
|
||||
if (separatorIndex > 0) {
|
||||
withStyle(boldStyle) { append(line.substring(0, separatorIndex + 1)) }
|
||||
if (separatorIndex + 1 < line.length) {
|
||||
append(line.substring(separatorIndex + 1))
|
||||
}
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
}
|
||||
else -> append(line)
|
||||
}
|
||||
|
||||
if (index < lines.lastIndex) append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
/** Animated message status indicator */
|
||||
@Composable
|
||||
fun AnimatedMessageStatus(
|
||||
@@ -1722,6 +1829,7 @@ fun KebabMenu(
|
||||
onDismiss: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
isSavedMessages: Boolean,
|
||||
isSystemAccount: Boolean = false,
|
||||
isBlocked: Boolean,
|
||||
onBlockClick: () -> Unit,
|
||||
onUnblockClick: () -> Unit,
|
||||
@@ -1752,7 +1860,7 @@ fun KebabMenu(
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
if (!isSavedMessages) {
|
||||
if (!isSavedMessages && !isSystemAccount) {
|
||||
KebabMenuItem(
|
||||
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
|
||||
text = if (isBlocked) "Unblock User" else "Block User",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,14 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.AvatarColors
|
||||
import com.rosetta.messenger.ui.chats.getAvatarColor
|
||||
@@ -75,6 +78,8 @@ fun AvatarImage(
|
||||
shape: Shape = CircleShape,
|
||||
displayName: String? = null // 🔥 Имя для инициалов (title/username)
|
||||
) {
|
||||
val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
|
||||
|
||||
// Получаем аватары из репозитория
|
||||
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
|
||||
?: remember { mutableStateOf(emptyList()) }
|
||||
@@ -129,7 +134,14 @@ fun AvatarImage(
|
||||
LaunchedEffect(bitmap) {
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
if (isSystemSafeAccount) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.safe_account),
|
||||
contentDescription = "Safe avatar",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else if (bitmap != null) {
|
||||
// Отображаем реальный аватар
|
||||
Image(
|
||||
bitmap = bitmap!!.asImageBitmap(),
|
||||
|
||||
BIN
app/src/main/res/drawable/safe_account.png
Normal file
BIN
app/src/main/res/drawable/safe_account.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
1
app/src/main/res/raw/device_confirm.json
Normal file
1
app/src/main/res/raw/device_confirm.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user