feat: Enhance chat functionality by updating dialog handling and adding ChatsListViewModel for database integration
This commit is contained in:
@@ -274,83 +274,89 @@ object MessageCrypto {
|
|||||||
/**
|
/**
|
||||||
* ECDH шифрование ключа для получателя
|
* ECDH шифрование ключа для получателя
|
||||||
* Использует secp256k1 + AES как в RN версии
|
* Использует secp256k1 + AES как в RN версии
|
||||||
|
* Формат: Base64(iv:ciphertext:ephemeralPrivateKeyHex)
|
||||||
*/
|
*/
|
||||||
fun encryptKeyForRecipient(keyAndNonce: ByteArray, recipientPublicKeyHex: String): String {
|
fun encryptKeyForRecipient(keyAndNonce: ByteArray, recipientPublicKeyHex: String): String {
|
||||||
val secureRandom = SecureRandom()
|
val secureRandom = SecureRandom()
|
||||||
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||||||
|
|
||||||
// Генерируем эфемерный приватный ключ
|
// Генерируем эфемерный приватный ключ (32 байта)
|
||||||
val ephemeralPrivateKeyBytes = ByteArray(32)
|
val ephemeralPrivateKeyBytes = ByteArray(32)
|
||||||
secureRandom.nextBytes(ephemeralPrivateKeyBytes)
|
secureRandom.nextBytes(ephemeralPrivateKeyBytes)
|
||||||
val ephemeralPrivateKey = BigInteger(1, ephemeralPrivateKeyBytes)
|
val ephemeralPrivateKey = BigInteger(1, ephemeralPrivateKeyBytes)
|
||||||
|
val ephemeralPrivateKeyHex = ephemeralPrivateKeyBytes.toHex()
|
||||||
|
|
||||||
// Получаем эфемерный публичный ключ
|
// Получаем эфемерный публичный ключ
|
||||||
val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey)
|
val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey)
|
||||||
val ephemeralPublicKeyHex = ephemeralPublicKey.getEncoded(false).toHex()
|
|
||||||
|
|
||||||
// Парсим публичный ключ получателя
|
// Парсим публичный ключ получателя
|
||||||
val recipientPublicKeyBytes = recipientPublicKeyHex.hexToBytes()
|
val recipientPublicKeyBytes = recipientPublicKeyHex.hexToBytes()
|
||||||
val recipientPublicKey = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
|
val recipientPublicKey = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
|
||||||
|
|
||||||
// ECDH: получаем общий секрет
|
// ECDH: ephemeralPrivate * recipientPublic = sharedSecret
|
||||||
val sharedPoint = recipientPublicKey.multiply(ephemeralPrivateKey)
|
val sharedPoint = recipientPublicKey.multiply(ephemeralPrivateKey)
|
||||||
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
||||||
|
val sharedSecretHex = sharedSecret.toHex()
|
||||||
|
|
||||||
// Derive AES key from shared secret
|
// Генерируем IV для AES (16 байт)
|
||||||
val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret)
|
|
||||||
|
|
||||||
// Генерируем IV для AES
|
|
||||||
val iv = ByteArray(16)
|
val iv = ByteArray(16)
|
||||||
secureRandom.nextBytes(iv)
|
secureRandom.nextBytes(iv)
|
||||||
|
val ivHex = iv.toHex()
|
||||||
|
|
||||||
// Шифруем keyAndNonce с AES-CBC
|
// Шифруем keyAndNonce с AES-CBC используя sharedSecret как ключ
|
||||||
val secretKey = SecretKeySpec(aesKey, "AES")
|
// ВАЖНО: Используем сам sharedSecret напрямую (32 байта) без SHA-256!
|
||||||
|
val aesKey = SecretKeySpec(sharedSecret, "AES")
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(iv))
|
cipher.init(Cipher.ENCRYPT_MODE, aesKey, IvParameterSpec(iv))
|
||||||
val encryptedKey = cipher.doFinal(keyAndNonce)
|
val encryptedKey = cipher.doFinal(keyAndNonce)
|
||||||
|
val encryptedKeyHex = encryptedKey.toHex()
|
||||||
|
|
||||||
// Формат: iv:ciphertext:ephemeralPublicKey (Base64)
|
// Формат как в RN: btoa(ivHex:encryptedHex:ephemeralPrivateHex)
|
||||||
val result = ByteArray(iv.size + encryptedKey.size + recipientPublicKeyBytes.size)
|
val combined = "$ivHex:$encryptedKeyHex:$ephemeralPrivateKeyHex"
|
||||||
System.arraycopy(iv, 0, result, 0, iv.size)
|
|
||||||
System.arraycopy(encryptedKey, 0, result, iv.size, encryptedKey.size)
|
|
||||||
|
|
||||||
// Возвращаем как Base64 с ephemeral public key в конце
|
ProtocolManager.addLog("🔐 ECDH Encrypt:")
|
||||||
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" +
|
ProtocolManager.addLog(" - Shared secret: ${sharedSecretHex.take(40)}...")
|
||||||
Base64.encodeToString(encryptedKey, Base64.NO_WRAP) + ":" +
|
ProtocolManager.addLog(" - IV: ${ivHex.take(32)}...")
|
||||||
ephemeralPublicKeyHex
|
ProtocolManager.addLog(" - Ephemeral private: ${ephemeralPrivateKeyHex.take(40)}...")
|
||||||
|
ProtocolManager.addLog(" - Recipient public: ${recipientPublicKeyHex.take(40)}...")
|
||||||
|
|
||||||
|
return Base64.encodeToString(combined.toByteArray(), Base64.NO_WRAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ECDH расшифровка ключа
|
* ECDH расшифровка ключа
|
||||||
|
* Формат: Base64(ivHex:encryptedHex:ephemeralPrivateHex)
|
||||||
*/
|
*/
|
||||||
fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray {
|
fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray {
|
||||||
val parts = encryptedKeyBase64.split(":")
|
val combined = String(Base64.decode(encryptedKeyBase64, Base64.NO_WRAP))
|
||||||
if (parts.size != 3) throw IllegalArgumentException("Invalid encrypted key format")
|
val parts = combined.split(":")
|
||||||
|
if (parts.size != 3) throw IllegalArgumentException("Invalid encrypted key format: expected 3 parts, got ${parts.size}")
|
||||||
|
|
||||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
val ivHex = parts[0]
|
||||||
val encryptedKey = Base64.decode(parts[1], Base64.NO_WRAP)
|
val encryptedKeyHex = parts[1]
|
||||||
val ephemeralPublicKeyHex = parts[2]
|
val ephemeralPrivateKeyHex = parts[2]
|
||||||
|
|
||||||
|
val iv = ivHex.hexToBytes()
|
||||||
|
val encryptedKey = encryptedKeyHex.hexToBytes()
|
||||||
|
|
||||||
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||||||
|
|
||||||
|
// Парсим эфемерный приватный ключ
|
||||||
|
val ephemeralPrivateKey = BigInteger(ephemeralPrivateKeyHex, 16)
|
||||||
|
val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey)
|
||||||
|
|
||||||
// Парсим мой приватный ключ
|
// Парсим мой приватный ключ
|
||||||
val myPrivateKey = BigInteger(myPrivateKeyHex, 16)
|
val myPrivateKey = BigInteger(myPrivateKeyHex, 16)
|
||||||
|
val myPublicKey = ecSpec.g.multiply(myPrivateKey)
|
||||||
|
|
||||||
// Парсим эфемерный публичный ключ отправителя
|
// ECDH: ephemeralPrivate * myPublic = sharedSecret
|
||||||
val ephemeralPublicKeyBytes = ephemeralPublicKeyHex.hexToBytes()
|
val sharedPoint = myPublicKey.multiply(ephemeralPrivateKey)
|
||||||
val ephemeralPublicKey = ecSpec.curve.decodePoint(ephemeralPublicKeyBytes)
|
|
||||||
|
|
||||||
// ECDH: получаем общий секрет
|
|
||||||
val sharedPoint = ephemeralPublicKey.multiply(myPrivateKey)
|
|
||||||
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
||||||
|
|
||||||
// Derive AES key from shared secret
|
// Расшифровываем используя sharedSecret как AES ключ
|
||||||
val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret)
|
val aesKey = SecretKeySpec(sharedSecret, "AES")
|
||||||
|
|
||||||
// Расшифровываем
|
|
||||||
val secretKey = SecretKeySpec(aesKey, "AES")
|
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv))
|
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
||||||
|
|
||||||
return cipher.doFinal(encryptedKey)
|
return cipher.doFinal(encryptedKey)
|
||||||
}
|
}
|
||||||
@@ -359,15 +365,31 @@ object MessageCrypto {
|
|||||||
* Полное шифрование сообщения для отправки
|
* Полное шифрование сообщения для отправки
|
||||||
*/
|
*/
|
||||||
fun encryptForSending(plaintext: String, recipientPublicKey: String): Pair<String, String> {
|
fun encryptForSending(plaintext: String, recipientPublicKey: String): Pair<String, String> {
|
||||||
|
android.util.Log.d("MessageCrypto", "🔐 === START ENCRYPTION ===")
|
||||||
|
android.util.Log.d("MessageCrypto", "📝 Plaintext: '$plaintext'")
|
||||||
|
android.util.Log.d("MessageCrypto", "📝 Plaintext length: ${plaintext.length}")
|
||||||
|
android.util.Log.d("MessageCrypto", "🔑 Recipient public key: ${recipientPublicKey.take(20)}...")
|
||||||
|
|
||||||
// 1. Шифруем текст
|
// 1. Шифруем текст
|
||||||
|
android.util.Log.d("MessageCrypto", "⚡ Step 1: Encrypting message with XChaCha20-Poly1305...")
|
||||||
val encrypted = encryptMessage(plaintext)
|
val encrypted = encryptMessage(plaintext)
|
||||||
|
android.util.Log.d("MessageCrypto", "✅ Ciphertext: ${encrypted.ciphertext.take(40)}...")
|
||||||
|
android.util.Log.d("MessageCrypto", "✅ Ciphertext length: ${encrypted.ciphertext.length}")
|
||||||
|
android.util.Log.d("MessageCrypto", "✅ Key: ${encrypted.key.take(20)}...")
|
||||||
|
android.util.Log.d("MessageCrypto", "✅ Nonce: ${encrypted.nonce.take(20)}...")
|
||||||
|
|
||||||
// 2. Собираем key + nonce
|
// 2. Собираем key + nonce
|
||||||
|
android.util.Log.d("MessageCrypto", "⚡ Step 2: Combining key + nonce...")
|
||||||
val keyAndNonce = encrypted.key.hexToBytes() + encrypted.nonce.hexToBytes()
|
val keyAndNonce = encrypted.key.hexToBytes() + encrypted.nonce.hexToBytes()
|
||||||
|
android.util.Log.d("MessageCrypto", "✅ Combined keyAndNonce length: ${keyAndNonce.size} bytes")
|
||||||
|
|
||||||
// 3. Шифруем ключ для получателя
|
// 3. Шифруем ключ для получателя
|
||||||
|
android.util.Log.d("MessageCrypto", "⚡ Step 3: Encrypting key with ECDH + AES...")
|
||||||
val encryptedKey = encryptKeyForRecipient(keyAndNonce, recipientPublicKey)
|
val encryptedKey = encryptKeyForRecipient(keyAndNonce, recipientPublicKey)
|
||||||
|
android.util.Log.d("MessageCrypto", "✅ Encrypted key: ${encryptedKey.take(40)}...")
|
||||||
|
android.util.Log.d("MessageCrypto", "✅ Encrypted key length: ${encryptedKey.length}")
|
||||||
|
|
||||||
|
android.util.Log.d("MessageCrypto", "🔐 === ENCRYPTION COMPLETE ===")
|
||||||
return Pair(encrypted.ciphertext, encryptedKey)
|
return Pair(encrypted.ciphertext, encryptedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ private fun Message.toChatMessage() = ChatMessage(
|
|||||||
/**
|
/**
|
||||||
* Экран детального чата с пользователем
|
* Экран детального чата с пользователем
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatDetailScreen(
|
fun ChatDetailScreen(
|
||||||
user: SearchUser,
|
user: SearchUser,
|
||||||
@@ -97,6 +97,7 @@ fun ChatDetailScreen(
|
|||||||
onUserProfileClick: () -> Unit = {},
|
onUserProfileClick: () -> Unit = {},
|
||||||
viewModel: ChatViewModel = viewModel()
|
viewModel: ChatViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
@@ -122,7 +123,7 @@ fun ChatDetailScreen(
|
|||||||
// Инициализируем ViewModel с ключами и открываем диалог
|
// Инициализируем ViewModel с ключами и открываем диалог
|
||||||
LaunchedEffect(user.publicKey) {
|
LaunchedEffect(user.publicKey) {
|
||||||
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
||||||
viewModel.openDialog(user.publicKey)
|
viewModel.openDialog(user.publicKey, user.title, user.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Прокрутка при новых сообщениях
|
// Прокрутка при новых сообщениях
|
||||||
@@ -154,7 +155,10 @@ fun ChatDetailScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Кнопка назад
|
// Кнопка назад
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = {
|
||||||
|
keyboardController?.hide()
|
||||||
|
onBack()
|
||||||
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ArrowBack,
|
Icons.Default.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
@@ -545,7 +549,7 @@ private fun MessageInputBar(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.imePadding()
|
.then(if (!showEmojiPicker) Modifier.imePadding() else Modifier)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -709,8 +713,8 @@ private fun MessageInputBar(
|
|||||||
// Apple Emoji Picker
|
// Apple Emoji Picker
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showEmojiPicker,
|
visible = showEmojiPicker,
|
||||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(),
|
||||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
|
exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut()
|
||||||
) {
|
) {
|
||||||
AppleEmojiPickerPanel(
|
AppleEmojiPickerPanel(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.crypto.MessageCrypto
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
|
import com.rosetta.messenger.database.DialogEntity
|
||||||
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.*
|
import com.rosetta.messenger.network.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -22,6 +24,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private const val TAG = "ChatViewModel"
|
private const val TAG = "ChatViewModel"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Database
|
||||||
|
private val database = RosettaDatabase.getDatabase(application)
|
||||||
|
private val dialogDao = database.dialogDao()
|
||||||
|
|
||||||
|
// Информация о собеседнике
|
||||||
|
private var opponentTitle: String = ""
|
||||||
|
private var opponentUsername: String = ""
|
||||||
|
|
||||||
// Текущий диалог
|
// Текущий диалог
|
||||||
private var opponentKey: String? = null
|
private var opponentKey: String? = null
|
||||||
private var myPublicKey: String? = null
|
private var myPublicKey: String? = null
|
||||||
@@ -120,14 +130,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
/**
|
/**
|
||||||
* Открыть диалог
|
* Открыть диалог
|
||||||
*/
|
*/
|
||||||
fun openDialog(publicKey: String) {
|
fun openDialog(publicKey: String, title: String = "", username: String = "") {
|
||||||
if (opponentKey == publicKey) {
|
if (opponentKey == publicKey) {
|
||||||
ProtocolManager.addLog("💬 Dialog already open: ${publicKey.take(16)}...")
|
ProtocolManager.addLog("💬 Dialog already open: ${publicKey.take(16)}...")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
opponentKey = publicKey
|
opponentKey = publicKey
|
||||||
|
opponentTitle = title
|
||||||
|
opponentUsername = username
|
||||||
_messages.value = emptyList()
|
_messages.value = emptyList()
|
||||||
ProtocolManager.addLog("💬 Dialog opened: ${publicKey.take(16)}...")
|
ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,15 +191,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_messages.value = _messages.value + optimisticMessage
|
_messages.value = _messages.value + optimisticMessage
|
||||||
_inputText.value = ""
|
_inputText.value = ""
|
||||||
|
|
||||||
ProtocolManager.addLog("📤 Send: \"${text.take(20)}...\"")
|
ProtocolManager.addLog("📤 === START SENDING MESSAGE ===")
|
||||||
ProtocolManager.addLog("📋 Messages count: ${_messages.value.size}")
|
ProtocolManager.addLog("📤 Text: \"${text.take(20)}...\"")
|
||||||
|
ProtocolManager.addLog("📤 Text length: ${text.length}")
|
||||||
|
ProtocolManager.addLog("📤 Recipient: ${recipient.take(20)}...")
|
||||||
|
ProtocolManager.addLog("📤 Sender: ${sender.take(20)}...")
|
||||||
|
ProtocolManager.addLog("📤 Message ID: $messageId")
|
||||||
|
ProtocolManager.addLog("📋 Current messages count: ${_messages.value.size}")
|
||||||
|
|
||||||
// 2. Отправка в фоне
|
// 2. Отправка в фоне
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
ProtocolManager.addLog("🔐 Encrypting...")
|
ProtocolManager.addLog("🔐 Starting encryption...")
|
||||||
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient)
|
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient)
|
||||||
ProtocolManager.addLog("✓ Encrypted")
|
ProtocolManager.addLog("✅ Encryption complete")
|
||||||
|
ProtocolManager.addLog(" - Encrypted content length: ${encryptedContent.length}")
|
||||||
|
ProtocolManager.addLog(" - Encrypted key length: ${encryptedKey.length}")
|
||||||
|
|
||||||
|
ProtocolManager.addLog("📦 Creating PacketMessage...")
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
ProtocolManager.addLog("🔑 Private key hash: ${privateKeyHash.take(20)}...")
|
||||||
|
|
||||||
val packet = PacketMessage().apply {
|
val packet = PacketMessage().apply {
|
||||||
fromPublicKey = sender
|
fromPublicKey = sender
|
||||||
@@ -195,16 +218,29 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
content = encryptedContent
|
content = encryptedContent
|
||||||
chachaKey = encryptedKey
|
chachaKey = encryptedKey
|
||||||
this.timestamp = timestamp
|
this.timestamp = timestamp
|
||||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
this.privateKey = privateKeyHash
|
||||||
this.messageId = messageId
|
this.messageId = messageId
|
||||||
attachments = emptyList()
|
attachments = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
ProtocolManager.addLog("📡 Sending packet...")
|
ProtocolManager.addLog("<EFBFBD> Packet created:")
|
||||||
|
ProtocolManager.addLog(" - From: ${sender.take(20)}...")
|
||||||
|
ProtocolManager.addLog(" - To: ${recipient.take(20)}...")
|
||||||
|
ProtocolManager.addLog(" - Content: ${encryptedContent.take(40)}...")
|
||||||
|
ProtocolManager.addLog(" - ChaCha Key: ${encryptedKey.take(40)}...")
|
||||||
|
ProtocolManager.addLog(" - Timestamp: $timestamp")
|
||||||
|
ProtocolManager.addLog(" - Message ID: $messageId")
|
||||||
|
|
||||||
|
ProtocolManager.addLog("📡 Sending packet to server...")
|
||||||
ProtocolManager.send(packet)
|
ProtocolManager.send(packet)
|
||||||
|
ProtocolManager.addLog("📡 Packet sent to ProtocolManager")
|
||||||
|
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
ProtocolManager.addLog("✓ Sent!")
|
ProtocolManager.addLog("✅ Message status updated to SENT")
|
||||||
|
ProtocolManager.addLog("📤 === SENDING COMPLETE ===")
|
||||||
|
|
||||||
|
// Сохраняем/обновляем диалог в базе
|
||||||
|
saveDialog(text, timestamp)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ProtocolManager.addLog("❌ Error: ${e.message}")
|
ProtocolManager.addLog("❌ Error: ${e.message}")
|
||||||
@@ -215,6 +251,36 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить диалог в базу данных
|
||||||
|
*/
|
||||||
|
private suspend fun saveDialog(lastMessage: String, timestamp: Long) {
|
||||||
|
val account = myPublicKey ?: return
|
||||||
|
val opponent = opponentKey ?: return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val existingDialog = dialogDao.getDialog(account, opponent)
|
||||||
|
|
||||||
|
if (existingDialog != null) {
|
||||||
|
// Обновляем последнее сообщение
|
||||||
|
dialogDao.updateLastMessage(account, opponent, lastMessage, timestamp)
|
||||||
|
} else {
|
||||||
|
// Создаём новый диалог
|
||||||
|
dialogDao.insertDialog(DialogEntity(
|
||||||
|
account = account,
|
||||||
|
opponentKey = opponent,
|
||||||
|
opponentTitle = opponentTitle,
|
||||||
|
opponentUsername = opponentUsername,
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageTimestamp = timestamp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
ProtocolManager.addLog("💾 Dialog saved")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ProtocolManager.addLog("❌ Failed to save dialog: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun showTypingIndicator() {
|
private fun showTypingIndicator() {
|
||||||
_opponentTyping.value = true
|
_opponentTyping.value = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.airbnb.lottie.compose.*
|
import com.airbnb.lottie.compose.*
|
||||||
import com.rosetta.messenger.R
|
import com.rosetta.messenger.R
|
||||||
|
import com.rosetta.messenger.database.DialogEntity
|
||||||
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.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
@@ -148,6 +149,7 @@ fun ChatsListScreen(
|
|||||||
onSearchClick: () -> Unit,
|
onSearchClick: () -> Unit,
|
||||||
onNewChat: () -> Unit,
|
onNewChat: () -> Unit,
|
||||||
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||||
|
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||||
onLogout: () -> Unit
|
onLogout: () -> Unit
|
||||||
) {
|
) {
|
||||||
// Theme transition state
|
// Theme transition state
|
||||||
@@ -187,6 +189,16 @@ fun ChatsListScreen(
|
|||||||
val protocolState by ProtocolManager.state.collectAsState()
|
val protocolState by ProtocolManager.state.collectAsState()
|
||||||
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
||||||
|
|
||||||
|
// Dialogs from database
|
||||||
|
val dialogsList by chatsViewModel.dialogs.collectAsState()
|
||||||
|
|
||||||
|
// Load dialogs when account is available
|
||||||
|
LaunchedEffect(accountPublicKey) {
|
||||||
|
if (accountPublicKey.isNotEmpty()) {
|
||||||
|
chatsViewModel.setAccount(accountPublicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dev console state
|
// Dev console state
|
||||||
var showDevConsole by remember { mutableStateOf(false) }
|
var showDevConsole by remember { mutableStateOf(false) }
|
||||||
var titleClickCount by remember { mutableStateOf(0) }
|
var titleClickCount by remember { mutableStateOf(0) }
|
||||||
@@ -776,12 +788,28 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else if (dialogsList.isEmpty()) {
|
||||||
// Empty state with Lottie animation
|
// Empty state with Lottie animation
|
||||||
EmptyChatsState(
|
EmptyChatsState(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
// Show dialogs list
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(dialogsList, key = { it.opponentKey }) { dialog ->
|
||||||
|
DialogItem(
|
||||||
|
dialog = dialog,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onClick = {
|
||||||
|
val user = chatsViewModel.dialogToSearchUser(dialog)
|
||||||
|
onUserSelect(user)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Console button - always visible at bottom left
|
// Console button - always visible at bottom left
|
||||||
@@ -1096,3 +1124,138 @@ private fun formatTime(date: Date): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент диалога из базы данных
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DialogItem(
|
||||||
|
dialog: DialogEntity,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
|
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||||
|
|
||||||
|
val avatarColors = getAvatarColor(dialog.opponentKey, isDarkTheme)
|
||||||
|
val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
|
||||||
|
val initials = if (dialog.opponentTitle.isNotEmpty()) {
|
||||||
|
dialog.opponentTitle.split(" ")
|
||||||
|
.take(2)
|
||||||
|
.mapNotNull { it.firstOrNull()?.uppercase() }
|
||||||
|
.joinToString("")
|
||||||
|
} else {
|
||||||
|
dialog.opponentKey.take(2).uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Avatar
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(avatarColors.backgroundColor),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = initials,
|
||||||
|
color = avatarColors.textColor,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Online indicator
|
||||||
|
if (dialog.isOnline == 1) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(14.dp)
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = (-2).dp, y = (-2).dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isDarkTheme) Color(0xFF1A1A1A) else Color.White)
|
||||||
|
.padding(2.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// Name and last message
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = formatTime(Date(dialog.lastMessageTimestamp)),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = dialog.lastMessage.ifEmpty { "No messages" },
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unread badge
|
||||||
|
if (dialog.unreadCount > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(22.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(PrimaryBlue),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (dialog.unreadCount > 99) "99+" else dialog.unreadCount.toString(),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier.padding(start = 84.dp),
|
||||||
|
color = dividerColor,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rosetta.messenger.database.DialogEntity
|
||||||
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel для списка чатов
|
||||||
|
* Загружает диалоги из базы данных
|
||||||
|
*/
|
||||||
|
class ChatsListViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
private val database = RosettaDatabase.getDatabase(application)
|
||||||
|
private val dialogDao = database.dialogDao()
|
||||||
|
|
||||||
|
private var currentAccount: String = ""
|
||||||
|
|
||||||
|
// Список диалогов из базы
|
||||||
|
private val _dialogs = MutableStateFlow<List<DialogEntity>>(emptyList())
|
||||||
|
val dialogs: StateFlow<List<DialogEntity>> = _dialogs.asStateFlow()
|
||||||
|
|
||||||
|
// Загрузка
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установить текущий аккаунт и загрузить диалоги
|
||||||
|
*/
|
||||||
|
fun setAccount(publicKey: String) {
|
||||||
|
if (currentAccount == publicKey) return
|
||||||
|
currentAccount = publicKey
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
dialogDao.getDialogsFlow(publicKey)
|
||||||
|
.collect { dialogsList ->
|
||||||
|
_dialogs.value = dialogsList
|
||||||
|
ProtocolManager.addLog("📋 Dialogs loaded: ${dialogsList.size}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать или обновить диалог после отправки/получения сообщения
|
||||||
|
*/
|
||||||
|
suspend fun upsertDialog(
|
||||||
|
opponentKey: String,
|
||||||
|
opponentTitle: String,
|
||||||
|
opponentUsername: String = "",
|
||||||
|
lastMessage: String,
|
||||||
|
timestamp: Long,
|
||||||
|
verified: Int = 0,
|
||||||
|
isOnline: Int = 0
|
||||||
|
) {
|
||||||
|
if (currentAccount.isEmpty()) return
|
||||||
|
|
||||||
|
val existingDialog = dialogDao.getDialog(currentAccount, opponentKey)
|
||||||
|
|
||||||
|
if (existingDialog != null) {
|
||||||
|
// Обновляем
|
||||||
|
dialogDao.updateLastMessage(currentAccount, opponentKey, lastMessage, timestamp)
|
||||||
|
if (opponentTitle.isNotEmpty()) {
|
||||||
|
dialogDao.updateOpponentInfo(currentAccount, opponentKey, opponentTitle, opponentUsername, verified)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Создаём новый
|
||||||
|
dialogDao.insertDialog(DialogEntity(
|
||||||
|
account = currentAccount,
|
||||||
|
opponentKey = opponentKey,
|
||||||
|
opponentTitle = opponentTitle,
|
||||||
|
opponentUsername = opponentUsername,
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageTimestamp = timestamp,
|
||||||
|
verified = verified,
|
||||||
|
isOnline = isOnline
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертировать DialogEntity в SearchUser для навигации
|
||||||
|
*/
|
||||||
|
fun dialogToSearchUser(dialog: DialogEntity): SearchUser {
|
||||||
|
return SearchUser(
|
||||||
|
title = dialog.opponentTitle,
|
||||||
|
username = dialog.opponentUsername,
|
||||||
|
publicKey = dialog.opponentKey,
|
||||||
|
verified = dialog.verified,
|
||||||
|
online = dialog.isOnline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user