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