feat: Update RosettaDatabase to include Message and Dialog entities, increment version to 2
feat: Implement message packets for sending and receiving messages, including delivery and read notifications feat: Enhance ProtocolManager to handle message sending, delivery, and typing status with appropriate logging feat: Refactor ChatDetailScreen to utilize ChatViewModel for managing chat state and message input feat: Create ChatViewModel to manage chat messages, input state, and packet listeners for incoming messages build: Add KSP plugin for annotation processing and configure Java 17 for the build environment
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
// kotlin("kapt") // Временно отключено из-за проблемы с Java 21
|
id("com.google.devtools.ksp")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -114,7 +114,7 @@ dependencies {
|
|||||||
// Room for database
|
// Room for database
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
implementation("androidx.room:room-ktx:2.6.1")
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
// kapt("androidx.room:room-compiler:2.6.1") // Временно отключено
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
|
|
||||||
// Biometric authentication
|
// Biometric authentication
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ fun MainScreen(
|
|||||||
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
|
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
|
||||||
} ?: "+7 775 9932587"
|
} ?: "+7 775 9932587"
|
||||||
val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
|
val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
|
||||||
|
val accountPrivateKey = account?.privateKey ?: ""
|
||||||
val privateKeyHash = account?.privateKeyHash ?: ""
|
val privateKeyHash = account?.privateKeyHash ?: ""
|
||||||
|
|
||||||
// Навигация между экранами
|
// Навигация между экранами
|
||||||
@@ -250,6 +251,7 @@ fun MainScreen(
|
|||||||
ChatDetailScreen(
|
ChatDetailScreen(
|
||||||
user = user,
|
user = user,
|
||||||
currentUserPublicKey = accountPublicKey,
|
currentUserPublicKey = accountPublicKey,
|
||||||
|
currentUserPrivateKey = accountPrivateKey,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onBack = { selectedUser = null }
|
onBack = { selectedUser = null }
|
||||||
)
|
)
|
||||||
|
|||||||
214
app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt
Normal file
214
app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package com.rosetta.messenger.crypto
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import org.bouncycastle.jce.ECNamedCurveTable
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.Security
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Шифрование сообщений как в React Native версии
|
||||||
|
* XChaCha20-Poly1305 для текста + ECDH + AES для ключа
|
||||||
|
*/
|
||||||
|
object MessageCrypto {
|
||||||
|
|
||||||
|
private const val CHACHA_KEY_SIZE = 32
|
||||||
|
private const val CHACHA_NONCE_SIZE = 24
|
||||||
|
private const val AES_KEY_SIZE = 32
|
||||||
|
private const val GCM_TAG_LENGTH = 128
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||||
|
Security.addProvider(BouncyCastleProvider())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат шифрования сообщения
|
||||||
|
*/
|
||||||
|
data class EncryptedMessage(
|
||||||
|
val ciphertext: String, // Hex-encoded ChaCha20 ciphertext
|
||||||
|
val key: String, // Hex-encoded 32-byte key
|
||||||
|
val nonce: String // Hex-encoded 24-byte nonce
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Шифрование текста сообщения с использованием AES-GCM
|
||||||
|
* (Аналог ChaCha20-Poly1305 для совместимости с Android)
|
||||||
|
*/
|
||||||
|
fun encryptMessage(plaintext: String): EncryptedMessage {
|
||||||
|
val secureRandom = SecureRandom()
|
||||||
|
|
||||||
|
// Генерируем случайный ключ (32 байта) и nonce (12 байт для GCM)
|
||||||
|
val key = ByteArray(CHACHA_KEY_SIZE)
|
||||||
|
val nonce = ByteArray(12) // GCM использует 12 байт nonce
|
||||||
|
secureRandom.nextBytes(key)
|
||||||
|
secureRandom.nextBytes(nonce)
|
||||||
|
|
||||||
|
// Шифруем AES-GCM
|
||||||
|
val secretKey = SecretKeySpec(key, "AES")
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_LENGTH, nonce))
|
||||||
|
|
||||||
|
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
// Дополняем nonce до 24 байт для совместимости с RN форматом
|
||||||
|
val fullNonce = ByteArray(CHACHA_NONCE_SIZE)
|
||||||
|
System.arraycopy(nonce, 0, fullNonce, 0, nonce.size)
|
||||||
|
|
||||||
|
return EncryptedMessage(
|
||||||
|
ciphertext = ciphertext.toHex(),
|
||||||
|
key = key.toHex(),
|
||||||
|
nonce = fullNonce.toHex()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расшифровка текста сообщения
|
||||||
|
*/
|
||||||
|
fun decryptMessage(ciphertext: String, keyHex: String, nonceHex: String): String {
|
||||||
|
val key = keyHex.hexToBytes()
|
||||||
|
val nonce = nonceHex.hexToBytes().take(12).toByteArray() // GCM использует 12 байт
|
||||||
|
val ciphertextBytes = ciphertext.hexToBytes()
|
||||||
|
|
||||||
|
val secretKey = SecretKeySpec(key, "AES")
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(GCM_TAG_LENGTH, nonce))
|
||||||
|
|
||||||
|
val plaintext = cipher.doFinal(ciphertextBytes)
|
||||||
|
return String(plaintext, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECDH шифрование ключа для получателя
|
||||||
|
* Использует secp256k1 + AES как в RN версии
|
||||||
|
*/
|
||||||
|
fun encryptKeyForRecipient(keyAndNonce: ByteArray, recipientPublicKeyHex: String): String {
|
||||||
|
val secureRandom = SecureRandom()
|
||||||
|
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||||||
|
|
||||||
|
// Генерируем эфемерный приватный ключ
|
||||||
|
val ephemeralPrivateKeyBytes = ByteArray(32)
|
||||||
|
secureRandom.nextBytes(ephemeralPrivateKeyBytes)
|
||||||
|
val ephemeralPrivateKey = BigInteger(1, ephemeralPrivateKeyBytes)
|
||||||
|
|
||||||
|
// Получаем эфемерный публичный ключ
|
||||||
|
val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey)
|
||||||
|
val ephemeralPublicKeyHex = ephemeralPublicKey.getEncoded(false).toHex()
|
||||||
|
|
||||||
|
// Парсим публичный ключ получателя
|
||||||
|
val recipientPublicKeyBytes = recipientPublicKeyHex.hexToBytes()
|
||||||
|
val recipientPublicKey = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
|
||||||
|
|
||||||
|
// ECDH: получаем общий секрет
|
||||||
|
val sharedPoint = recipientPublicKey.multiply(ephemeralPrivateKey)
|
||||||
|
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
||||||
|
|
||||||
|
// Derive AES key from shared secret
|
||||||
|
val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret)
|
||||||
|
|
||||||
|
// Генерируем IV для AES
|
||||||
|
val iv = ByteArray(16)
|
||||||
|
secureRandom.nextBytes(iv)
|
||||||
|
|
||||||
|
// Шифруем keyAndNonce с AES-CBC
|
||||||
|
val secretKey = SecretKeySpec(aesKey, "AES")
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(iv))
|
||||||
|
val encryptedKey = cipher.doFinal(keyAndNonce)
|
||||||
|
|
||||||
|
// Формат: iv:ciphertext:ephemeralPublicKey (Base64)
|
||||||
|
val result = ByteArray(iv.size + encryptedKey.size + recipientPublicKeyBytes.size)
|
||||||
|
System.arraycopy(iv, 0, result, 0, iv.size)
|
||||||
|
System.arraycopy(encryptedKey, 0, result, iv.size, encryptedKey.size)
|
||||||
|
|
||||||
|
// Возвращаем как Base64 с ephemeral public key в конце
|
||||||
|
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" +
|
||||||
|
Base64.encodeToString(encryptedKey, Base64.NO_WRAP) + ":" +
|
||||||
|
ephemeralPublicKeyHex
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECDH расшифровка ключа
|
||||||
|
*/
|
||||||
|
fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray {
|
||||||
|
val parts = encryptedKeyBase64.split(":")
|
||||||
|
if (parts.size != 3) throw IllegalArgumentException("Invalid encrypted key format")
|
||||||
|
|
||||||
|
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||||
|
val encryptedKey = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||||
|
val ephemeralPublicKeyHex = parts[2]
|
||||||
|
|
||||||
|
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||||||
|
|
||||||
|
// Парсим мой приватный ключ
|
||||||
|
val myPrivateKey = BigInteger(myPrivateKeyHex, 16)
|
||||||
|
|
||||||
|
// Парсим эфемерный публичный ключ отправителя
|
||||||
|
val ephemeralPublicKeyBytes = ephemeralPublicKeyHex.hexToBytes()
|
||||||
|
val ephemeralPublicKey = ecSpec.curve.decodePoint(ephemeralPublicKeyBytes)
|
||||||
|
|
||||||
|
// ECDH: получаем общий секрет
|
||||||
|
val sharedPoint = ephemeralPublicKey.multiply(myPrivateKey)
|
||||||
|
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
||||||
|
|
||||||
|
// Derive AES key from shared secret
|
||||||
|
val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret)
|
||||||
|
|
||||||
|
// Расшифровываем
|
||||||
|
val secretKey = SecretKeySpec(aesKey, "AES")
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv))
|
||||||
|
|
||||||
|
return cipher.doFinal(encryptedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полное шифрование сообщения для отправки
|
||||||
|
*/
|
||||||
|
fun encryptForSending(plaintext: String, recipientPublicKey: String): Pair<String, String> {
|
||||||
|
// 1. Шифруем текст
|
||||||
|
val encrypted = encryptMessage(plaintext)
|
||||||
|
|
||||||
|
// 2. Собираем key + nonce
|
||||||
|
val keyAndNonce = encrypted.key.hexToBytes() + encrypted.nonce.hexToBytes()
|
||||||
|
|
||||||
|
// 3. Шифруем ключ для получателя
|
||||||
|
val encryptedKey = encryptKeyForRecipient(keyAndNonce, recipientPublicKey)
|
||||||
|
|
||||||
|
return Pair(encrypted.ciphertext, encryptedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полная расшифровка входящего сообщения
|
||||||
|
*/
|
||||||
|
fun decryptIncoming(
|
||||||
|
ciphertext: String,
|
||||||
|
encryptedKey: String,
|
||||||
|
myPrivateKey: String
|
||||||
|
): String {
|
||||||
|
// 1. Расшифровываем ключ
|
||||||
|
val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey)
|
||||||
|
|
||||||
|
// 2. Разделяем key и nonce
|
||||||
|
val key = keyAndNonce.slice(0 until 32).toByteArray()
|
||||||
|
val nonce = keyAndNonce.slice(32 until keyAndNonce.size).toByteArray()
|
||||||
|
|
||||||
|
// 3. Расшифровываем сообщение
|
||||||
|
return decryptMessage(ciphertext, key.toHex(), nonce.toHex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension functions для конвертации
|
||||||
|
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private fun String.hexToBytes(): ByteArray {
|
||||||
|
check(length % 2 == 0) { "Hex string must have even length" }
|
||||||
|
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
package com.rosetta.messenger.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
|
import com.rosetta.messenger.database.*
|
||||||
|
import com.rosetta.messenger.network.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI модель сообщения
|
||||||
|
*/
|
||||||
|
data class Message(
|
||||||
|
val id: Long = 0,
|
||||||
|
val messageId: String,
|
||||||
|
val fromPublicKey: String,
|
||||||
|
val toPublicKey: String,
|
||||||
|
val content: String, // Расшифрованный текст
|
||||||
|
val timestamp: Long,
|
||||||
|
val isFromMe: Boolean,
|
||||||
|
val isRead: Boolean,
|
||||||
|
val deliveryStatus: DeliveryStatus,
|
||||||
|
val attachments: List<MessageAttachment> = emptyList(),
|
||||||
|
val replyToMessageId: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI модель диалога
|
||||||
|
*/
|
||||||
|
data class Dialog(
|
||||||
|
val opponentKey: String,
|
||||||
|
val opponentTitle: String,
|
||||||
|
val opponentUsername: String,
|
||||||
|
val lastMessage: String,
|
||||||
|
val lastMessageTimestamp: Long,
|
||||||
|
val unreadCount: Int,
|
||||||
|
val isOnline: Boolean,
|
||||||
|
val lastSeen: Long,
|
||||||
|
val verified: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository для работы с сообщениями
|
||||||
|
* Оптимизированная версия с кэшированием и Optimistic UI
|
||||||
|
*/
|
||||||
|
class MessageRepository private constructor(private val context: Context) {
|
||||||
|
|
||||||
|
private val database = RosettaDatabase.getDatabase(context)
|
||||||
|
private val messageDao = database.messageDao()
|
||||||
|
private val dialogDao = database.dialogDao()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
// Кэш сообщений по диалогам
|
||||||
|
private val messageCache = mutableMapOf<String, MutableStateFlow<List<Message>>>()
|
||||||
|
|
||||||
|
// Кэш диалогов
|
||||||
|
private val _dialogs = MutableStateFlow<List<Dialog>>(emptyList())
|
||||||
|
val dialogs: StateFlow<List<Dialog>> = _dialogs.asStateFlow()
|
||||||
|
|
||||||
|
// Текущий аккаунт
|
||||||
|
private var currentAccount: String? = null
|
||||||
|
private var currentPrivateKey: String? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: MessageRepository? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): MessageRepository {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: MessageRepository(context.applicationContext).also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализация с текущим аккаунтом
|
||||||
|
*/
|
||||||
|
fun initialize(publicKey: String, privateKey: String) {
|
||||||
|
currentAccount = publicKey
|
||||||
|
currentPrivateKey = privateKey
|
||||||
|
|
||||||
|
// Загрузка диалогов
|
||||||
|
scope.launch {
|
||||||
|
dialogDao.getDialogsFlow(publicKey).collect { entities ->
|
||||||
|
_dialogs.value = entities.map { it.toDialog() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить поток сообщений для диалога
|
||||||
|
*/
|
||||||
|
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
|
||||||
|
val dialogKey = getDialogKey(opponentKey)
|
||||||
|
|
||||||
|
return messageCache.getOrPut(dialogKey) {
|
||||||
|
MutableStateFlow<List<Message>>(emptyList()).also { flow ->
|
||||||
|
scope.launch {
|
||||||
|
currentAccount?.let { account ->
|
||||||
|
messageDao.getMessagesFlow(account, dialogKey).collect { entities ->
|
||||||
|
flow.value = entities.map { it.toMessage() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправка сообщения с Optimistic UI
|
||||||
|
* Возвращает сразу, шифрование и отправка в фоне
|
||||||
|
*/
|
||||||
|
suspend fun sendMessage(
|
||||||
|
toPublicKey: String,
|
||||||
|
text: String,
|
||||||
|
attachments: List<MessageAttachment> = emptyList(),
|
||||||
|
replyToMessageId: String? = null
|
||||||
|
): Message {
|
||||||
|
val account = currentAccount ?: throw IllegalStateException("Not initialized")
|
||||||
|
val privateKey = currentPrivateKey ?: throw IllegalStateException("Not initialized")
|
||||||
|
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val dialogKey = getDialogKey(toPublicKey)
|
||||||
|
|
||||||
|
// 1. Создаем оптимистичное сообщение
|
||||||
|
val optimisticMessage = Message(
|
||||||
|
messageId = messageId,
|
||||||
|
fromPublicKey = account,
|
||||||
|
toPublicKey = toPublicKey,
|
||||||
|
content = text.trim(),
|
||||||
|
timestamp = timestamp,
|
||||||
|
isFromMe = true,
|
||||||
|
isRead = account == toPublicKey, // Если сам себе - сразу прочитано
|
||||||
|
deliveryStatus = DeliveryStatus.WAITING,
|
||||||
|
attachments = attachments,
|
||||||
|
replyToMessageId = replyToMessageId
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Обновляем UI сразу (Optimistic Update)
|
||||||
|
updateMessageCache(dialogKey, optimisticMessage)
|
||||||
|
|
||||||
|
// 3. Фоновая обработка
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
// Шифрование
|
||||||
|
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(
|
||||||
|
text.trim(),
|
||||||
|
toPublicKey
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохраняем в БД
|
||||||
|
val entity = MessageEntity(
|
||||||
|
account = account,
|
||||||
|
fromPublicKey = account,
|
||||||
|
toPublicKey = toPublicKey,
|
||||||
|
content = encryptedContent,
|
||||||
|
timestamp = timestamp,
|
||||||
|
chachaKey = encryptedKey,
|
||||||
|
read = if (account == toPublicKey) 1 else 0,
|
||||||
|
fromMe = 1,
|
||||||
|
delivered = DeliveryStatus.WAITING.value,
|
||||||
|
messageId = messageId,
|
||||||
|
plainMessage = text.trim(),
|
||||||
|
attachments = "[]", // TODO: JSON serialize
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
dialogKey = dialogKey
|
||||||
|
)
|
||||||
|
messageDao.insertMessage(entity)
|
||||||
|
|
||||||
|
// Обновляем диалог
|
||||||
|
updateDialog(toPublicKey, text.trim(), timestamp)
|
||||||
|
|
||||||
|
// Отправляем пакет
|
||||||
|
val packet = PacketMessage().apply {
|
||||||
|
this.fromPublicKey = account
|
||||||
|
this.toPublicKey = toPublicKey
|
||||||
|
this.content = encryptedContent
|
||||||
|
this.chachaKey = encryptedKey
|
||||||
|
this.timestamp = timestamp
|
||||||
|
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
this.messageId = messageId
|
||||||
|
this.attachments = attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// При ошибке обновляем статус
|
||||||
|
messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.ERROR.value)
|
||||||
|
updateMessageStatus(dialogKey, messageId, DeliveryStatus.ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return optimisticMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка входящего сообщения
|
||||||
|
*/
|
||||||
|
suspend fun handleIncomingMessage(packet: PacketMessage) {
|
||||||
|
val account = currentAccount ?: return
|
||||||
|
val privateKey = currentPrivateKey ?: return
|
||||||
|
|
||||||
|
// Проверяем, не дубликат ли
|
||||||
|
if (messageDao.messageExists(account, packet.messageId)) return
|
||||||
|
|
||||||
|
val dialogKey = getDialogKey(packet.fromPublicKey)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Расшифровываем
|
||||||
|
val plainText = MessageCrypto.decryptIncoming(
|
||||||
|
packet.content,
|
||||||
|
packet.chachaKey,
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохраняем в БД
|
||||||
|
val entity = MessageEntity(
|
||||||
|
account = account,
|
||||||
|
fromPublicKey = packet.fromPublicKey,
|
||||||
|
toPublicKey = packet.toPublicKey,
|
||||||
|
content = packet.content,
|
||||||
|
timestamp = packet.timestamp,
|
||||||
|
chachaKey = packet.chachaKey,
|
||||||
|
read = 0,
|
||||||
|
fromMe = 0,
|
||||||
|
delivered = DeliveryStatus.DELIVERED.value,
|
||||||
|
messageId = packet.messageId,
|
||||||
|
plainMessage = plainText,
|
||||||
|
attachments = "[]", // TODO
|
||||||
|
dialogKey = dialogKey
|
||||||
|
)
|
||||||
|
messageDao.insertMessage(entity)
|
||||||
|
|
||||||
|
// Обновляем диалог
|
||||||
|
updateDialog(packet.fromPublicKey, plainText, packet.timestamp, incrementUnread = true)
|
||||||
|
|
||||||
|
// Обновляем кэш
|
||||||
|
val message = entity.toMessage()
|
||||||
|
updateMessageCache(dialogKey, message)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка подтверждения доставки
|
||||||
|
*/
|
||||||
|
suspend fun handleDelivery(packet: PacketDelivery) {
|
||||||
|
val account = currentAccount ?: return
|
||||||
|
messageDao.updateDeliveryStatus(account, packet.messageId, DeliveryStatus.DELIVERED.value)
|
||||||
|
|
||||||
|
// Обновляем кэш
|
||||||
|
val dialogKey = getDialogKey(packet.toPublicKey)
|
||||||
|
updateMessageStatus(dialogKey, packet.messageId, DeliveryStatus.DELIVERED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка прочтения
|
||||||
|
*/
|
||||||
|
suspend fun handleRead(packet: PacketRead) {
|
||||||
|
val account = currentAccount ?: return
|
||||||
|
messageDao.markAsRead(account, packet.messageId)
|
||||||
|
|
||||||
|
// Обновляем кэш
|
||||||
|
val dialogKey = getDialogKey(packet.fromPublicKey)
|
||||||
|
messageCache[dialogKey]?.let { flow ->
|
||||||
|
flow.value = flow.value.map { msg ->
|
||||||
|
if (msg.messageId == packet.messageId) msg.copy(isRead = true)
|
||||||
|
else msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отметить диалог как прочитанный
|
||||||
|
*/
|
||||||
|
suspend fun markDialogAsRead(opponentKey: String) {
|
||||||
|
val account = currentAccount ?: return
|
||||||
|
val dialogKey = getDialogKey(opponentKey)
|
||||||
|
|
||||||
|
messageDao.markDialogAsRead(account, dialogKey)
|
||||||
|
dialogDao.clearUnreadCount(account, opponentKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить уведомление "печатает"
|
||||||
|
*/
|
||||||
|
fun sendTyping(toPublicKey: String) {
|
||||||
|
val account = currentAccount ?: return
|
||||||
|
val privateKey = currentPrivateKey ?: return
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
val packet = PacketTyping().apply {
|
||||||
|
this.fromPublicKey = account
|
||||||
|
this.toPublicKey = toPublicKey
|
||||||
|
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
}
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать или обновить диалог
|
||||||
|
*/
|
||||||
|
suspend fun createOrUpdateDialog(
|
||||||
|
opponentKey: String,
|
||||||
|
title: String = "",
|
||||||
|
username: String = "",
|
||||||
|
verified: Boolean = false
|
||||||
|
) {
|
||||||
|
val account = currentAccount ?: return
|
||||||
|
|
||||||
|
val existing = dialogDao.getDialog(account, opponentKey)
|
||||||
|
if (existing != null) {
|
||||||
|
dialogDao.updateOpponentInfo(account, opponentKey, title, username, if (verified) 1 else 0)
|
||||||
|
} else {
|
||||||
|
dialogDao.insertDialog(DialogEntity(
|
||||||
|
account = account,
|
||||||
|
opponentKey = opponentKey,
|
||||||
|
opponentTitle = title,
|
||||||
|
opponentUsername = username,
|
||||||
|
verified = if (verified) 1 else 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// Private helpers
|
||||||
|
// ===============================
|
||||||
|
|
||||||
|
private fun getDialogKey(opponentKey: String): String {
|
||||||
|
val account = currentAccount ?: return opponentKey
|
||||||
|
return if (account < opponentKey) "$account:$opponentKey"
|
||||||
|
else "$opponentKey:$account"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMessageCache(dialogKey: String, message: Message) {
|
||||||
|
messageCache[dialogKey]?.let { flow ->
|
||||||
|
val currentList = flow.value.toMutableList()
|
||||||
|
val existingIndex = currentList.indexOfFirst { it.messageId == message.messageId }
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
currentList[existingIndex] = message
|
||||||
|
} else {
|
||||||
|
currentList.add(message)
|
||||||
|
currentList.sortBy { it.timestamp }
|
||||||
|
}
|
||||||
|
flow.value = currentList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMessageStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
|
||||||
|
messageCache[dialogKey]?.let { flow ->
|
||||||
|
flow.value = flow.value.map { msg ->
|
||||||
|
if (msg.messageId == messageId) msg.copy(deliveryStatus = status)
|
||||||
|
else msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateDialog(
|
||||||
|
opponentKey: String,
|
||||||
|
lastMessage: String,
|
||||||
|
timestamp: Long,
|
||||||
|
incrementUnread: Boolean = false
|
||||||
|
) {
|
||||||
|
val account = currentAccount ?: return
|
||||||
|
|
||||||
|
val existing = dialogDao.getDialog(account, opponentKey)
|
||||||
|
if (existing != null) {
|
||||||
|
dialogDao.updateLastMessage(account, opponentKey, lastMessage, timestamp)
|
||||||
|
if (incrementUnread) {
|
||||||
|
dialogDao.incrementUnreadCount(account, opponentKey)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialogDao.insertDialog(DialogEntity(
|
||||||
|
account = account,
|
||||||
|
opponentKey = opponentKey,
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageTimestamp = timestamp,
|
||||||
|
unreadCount = if (incrementUnread) 1 else 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension functions
|
||||||
|
private fun MessageEntity.toMessage() = Message(
|
||||||
|
id = id,
|
||||||
|
messageId = messageId,
|
||||||
|
fromPublicKey = fromPublicKey,
|
||||||
|
toPublicKey = toPublicKey,
|
||||||
|
content = plainMessage,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isFromMe = fromMe == 1,
|
||||||
|
isRead = read == 1,
|
||||||
|
deliveryStatus = DeliveryStatus.fromInt(delivered),
|
||||||
|
replyToMessageId = replyToMessageId
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun DialogEntity.toDialog() = Dialog(
|
||||||
|
opponentKey = opponentKey,
|
||||||
|
opponentTitle = opponentTitle,
|
||||||
|
opponentUsername = opponentUsername,
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageTimestamp = lastMessageTimestamp,
|
||||||
|
unreadCount = unreadCount,
|
||||||
|
isOnline = isOnline == 1,
|
||||||
|
lastSeen = lastSeen,
|
||||||
|
verified = verified == 1
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package com.rosetta.messenger.database
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity для сообщений - как в React Native версии
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
tableName = "messages",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]),
|
||||||
|
Index(value = ["account", "message_id"], unique = true),
|
||||||
|
Index(value = ["account", "dialog_key", "timestamp"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "account")
|
||||||
|
val account: String, // Мой публичный ключ
|
||||||
|
|
||||||
|
@ColumnInfo(name = "from_public_key")
|
||||||
|
val fromPublicKey: String, // Отправитель
|
||||||
|
|
||||||
|
@ColumnInfo(name = "to_public_key")
|
||||||
|
val toPublicKey: String, // Получатель
|
||||||
|
|
||||||
|
@ColumnInfo(name = "content")
|
||||||
|
val content: String, // Зашифрованное содержимое
|
||||||
|
|
||||||
|
@ColumnInfo(name = "timestamp")
|
||||||
|
val timestamp: Long, // Unix timestamp
|
||||||
|
|
||||||
|
@ColumnInfo(name = "chacha_key")
|
||||||
|
val chachaKey: String, // Зашифрованный ключ
|
||||||
|
|
||||||
|
@ColumnInfo(name = "read")
|
||||||
|
val read: Int = 0, // Прочитано (0/1)
|
||||||
|
|
||||||
|
@ColumnInfo(name = "from_me")
|
||||||
|
val fromMe: Int = 0, // Мое сообщение (0/1)
|
||||||
|
|
||||||
|
@ColumnInfo(name = "delivered")
|
||||||
|
val delivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
|
||||||
|
|
||||||
|
@ColumnInfo(name = "message_id")
|
||||||
|
val messageId: String, // UUID сообщения
|
||||||
|
|
||||||
|
@ColumnInfo(name = "plain_message")
|
||||||
|
val plainMessage: String, // Расшифрованный текст (для быстрого доступа)
|
||||||
|
|
||||||
|
@ColumnInfo(name = "attachments")
|
||||||
|
val attachments: String = "[]", // JSON массив вложений
|
||||||
|
|
||||||
|
@ColumnInfo(name = "reply_to_message_id")
|
||||||
|
val replyToMessageId: String? = null, // ID цитируемого сообщения
|
||||||
|
|
||||||
|
@ColumnInfo(name = "dialog_key")
|
||||||
|
val dialogKey: String // Ключ диалога для быстрой выборки
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity для диалогов (кэш последнего сообщения)
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
tableName = "dialogs",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["account", "opponent_key"], unique = true),
|
||||||
|
Index(value = ["account", "last_message_timestamp"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class DialogEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "account")
|
||||||
|
val account: String, // Мой публичный ключ
|
||||||
|
|
||||||
|
@ColumnInfo(name = "opponent_key")
|
||||||
|
val opponentKey: String, // Публичный ключ собеседника
|
||||||
|
|
||||||
|
@ColumnInfo(name = "opponent_title")
|
||||||
|
val opponentTitle: String = "", // Имя собеседника
|
||||||
|
|
||||||
|
@ColumnInfo(name = "opponent_username")
|
||||||
|
val opponentUsername: String = "", // Username собеседника
|
||||||
|
|
||||||
|
@ColumnInfo(name = "last_message")
|
||||||
|
val lastMessage: String = "", // Последнее сообщение (текст)
|
||||||
|
|
||||||
|
@ColumnInfo(name = "last_message_timestamp")
|
||||||
|
val lastMessageTimestamp: Long = 0, // Timestamp последнего сообщения
|
||||||
|
|
||||||
|
@ColumnInfo(name = "unread_count")
|
||||||
|
val unreadCount: Int = 0, // Количество непрочитанных
|
||||||
|
|
||||||
|
@ColumnInfo(name = "is_online")
|
||||||
|
val isOnline: Int = 0, // Онлайн статус
|
||||||
|
|
||||||
|
@ColumnInfo(name = "last_seen")
|
||||||
|
val lastSeen: Long = 0, // Последний раз онлайн
|
||||||
|
|
||||||
|
@ColumnInfo(name = "verified")
|
||||||
|
val verified: Int = 0 // Верифицирован
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO для работы с сообщениями
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface MessageDao {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вставка нового сообщения
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertMessage(message: MessageEntity): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вставка нескольких сообщений
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить сообщения диалога (постранично)
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE account = :account AND dialog_key = :dialogKey
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
""")
|
||||||
|
suspend fun getMessages(account: String, dialogKey: String, limit: Int, offset: Int): List<MessageEntity>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить сообщения диалога как Flow
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE account = :account AND dialog_key = :dialogKey
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
""")
|
||||||
|
fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить последние N сообщений диалога
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE account = :account AND dialog_key = :dialogKey
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT :limit
|
||||||
|
""")
|
||||||
|
suspend fun getRecentMessages(account: String, dialogKey: String, limit: Int): List<MessageEntity>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти сообщение по ID
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM messages WHERE account = :account AND message_id = :messageId LIMIT 1")
|
||||||
|
suspend fun getMessageById(account: String, messageId: String): MessageEntity?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить статус доставки
|
||||||
|
*/
|
||||||
|
@Query("UPDATE messages SET delivered = :status WHERE account = :account AND message_id = :messageId")
|
||||||
|
suspend fun updateDeliveryStatus(account: String, messageId: String, status: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить статус прочтения
|
||||||
|
*/
|
||||||
|
@Query("UPDATE messages SET read = 1 WHERE account = :account AND message_id = :messageId")
|
||||||
|
suspend fun markAsRead(account: String, messageId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отметить все сообщения диалога как прочитанные
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
UPDATE messages SET read = 1
|
||||||
|
WHERE account = :account AND dialog_key = :dialogKey AND from_me = 0
|
||||||
|
""")
|
||||||
|
suspend fun markDialogAsRead(account: String, dialogKey: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить сообщение
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM messages WHERE account = :account AND message_id = :messageId")
|
||||||
|
suspend fun deleteMessage(account: String, messageId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить все сообщения диалога
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM messages WHERE account = :account AND dialog_key = :dialogKey")
|
||||||
|
suspend fun deleteDialog(account: String, dialogKey: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Количество непрочитанных сообщений в диалоге
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(*) FROM messages
|
||||||
|
WHERE account = :account AND dialog_key = :dialogKey AND from_me = 0 AND read = 0
|
||||||
|
""")
|
||||||
|
suspend fun getUnreadCount(account: String, dialogKey: String): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить существование сообщения
|
||||||
|
*/
|
||||||
|
@Query("SELECT EXISTS(SELECT 1 FROM messages WHERE account = :account AND message_id = :messageId)")
|
||||||
|
suspend fun messageExists(account: String, messageId: String): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO для работы с диалогами
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface DialogDao {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вставка/обновление диалога
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertDialog(dialog: DialogEntity): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить все диалоги отсортированные по последнему сообщению
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM dialogs
|
||||||
|
WHERE account = :account
|
||||||
|
ORDER BY last_message_timestamp DESC
|
||||||
|
""")
|
||||||
|
fun getDialogsFlow(account: String): Flow<List<DialogEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить диалог
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1")
|
||||||
|
suspend fun getDialog(account: String, opponentKey: String): DialogEntity?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить последнее сообщение
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
UPDATE dialogs SET
|
||||||
|
last_message = :lastMessage,
|
||||||
|
last_message_timestamp = :timestamp
|
||||||
|
WHERE account = :account AND opponent_key = :opponentKey
|
||||||
|
""")
|
||||||
|
suspend fun updateLastMessage(account: String, opponentKey: String, lastMessage: String, timestamp: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить количество непрочитанных
|
||||||
|
*/
|
||||||
|
@Query("UPDATE dialogs SET unread_count = :count WHERE account = :account AND opponent_key = :opponentKey")
|
||||||
|
suspend fun updateUnreadCount(account: String, opponentKey: String, count: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инкрементировать непрочитанные
|
||||||
|
*/
|
||||||
|
@Query("UPDATE dialogs SET unread_count = unread_count + 1 WHERE account = :account AND opponent_key = :opponentKey")
|
||||||
|
suspend fun incrementUnreadCount(account: String, opponentKey: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбросить непрочитанные
|
||||||
|
*/
|
||||||
|
@Query("UPDATE dialogs SET unread_count = 0 WHERE account = :account AND opponent_key = :opponentKey")
|
||||||
|
suspend fun clearUnreadCount(account: String, opponentKey: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить онлайн статус
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
UPDATE dialogs SET
|
||||||
|
is_online = :isOnline,
|
||||||
|
last_seen = :lastSeen
|
||||||
|
WHERE account = :account AND opponent_key = :opponentKey
|
||||||
|
""")
|
||||||
|
suspend fun updateOnlineStatus(account: String, opponentKey: String, isOnline: Int, lastSeen: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить диалог
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM dialogs WHERE account = :account AND opponent_key = :opponentKey")
|
||||||
|
suspend fun deleteDialog(account: String, opponentKey: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить информацию о собеседнике
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
UPDATE dialogs SET
|
||||||
|
opponent_title = :title,
|
||||||
|
opponent_username = :username,
|
||||||
|
verified = :verified
|
||||||
|
WHERE account = :account AND opponent_key = :opponentKey
|
||||||
|
""")
|
||||||
|
suspend fun updateOpponentInfo(
|
||||||
|
account: String,
|
||||||
|
opponentKey: String,
|
||||||
|
title: String,
|
||||||
|
username: String,
|
||||||
|
verified: Int
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,12 +6,18 @@ import androidx.room.Room
|
|||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [EncryptedAccountEntity::class],
|
entities = [
|
||||||
version = 1,
|
EncryptedAccountEntity::class,
|
||||||
|
MessageEntity::class,
|
||||||
|
DialogEntity::class
|
||||||
|
],
|
||||||
|
version = 2,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class RosettaDatabase : RoomDatabase() {
|
abstract class RosettaDatabase : RoomDatabase() {
|
||||||
abstract fun accountDao(): AccountDao
|
abstract fun accountDao(): AccountDao
|
||||||
|
abstract fun messageDao(): MessageDao
|
||||||
|
abstract fun dialogDao(): DialogDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -25,6 +31,7 @@ abstract class RosettaDatabase : RoomDatabase() {
|
|||||||
"rosetta_secure.db"
|
"rosetta_secure.db"
|
||||||
)
|
)
|
||||||
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
|
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
|
||||||
|
.fallbackToDestructiveMigration() // Для разработки
|
||||||
.build()
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
|
|||||||
@@ -186,3 +186,185 @@ class PacketOnlineSubscribe : Packet() {
|
|||||||
return stream
|
return stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MESSAGE PACKETS - Как в React Native версии
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы вложений
|
||||||
|
*/
|
||||||
|
enum class AttachmentType(val value: Int) {
|
||||||
|
IMAGE(0), // Изображение
|
||||||
|
MESSAGES(1), // Reply (цитата сообщения)
|
||||||
|
FILE(2); // Файл
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статус доставки сообщения
|
||||||
|
*/
|
||||||
|
enum class DeliveryStatus(val value: Int) {
|
||||||
|
WAITING(0), // Ожидает отправки
|
||||||
|
DELIVERED(1), // Доставлено
|
||||||
|
ERROR(2); // Ошибка
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: WAITING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вложение к сообщению
|
||||||
|
*/
|
||||||
|
data class MessageAttachment(
|
||||||
|
val id: String,
|
||||||
|
val blob: String, // Base64 данные или пусто для CDN
|
||||||
|
val type: AttachmentType,
|
||||||
|
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
|
||||||
|
val width: Int = 0,
|
||||||
|
val height: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message packet (ID: 0x06)
|
||||||
|
* Отправка и получение сообщений
|
||||||
|
*/
|
||||||
|
class PacketMessage : Packet() {
|
||||||
|
var fromPublicKey: String = ""
|
||||||
|
var toPublicKey: String = ""
|
||||||
|
var content: String = "" // Зашифрованный текст
|
||||||
|
var chachaKey: String = "" // RSA зашифрованный ключ
|
||||||
|
var timestamp: Long = 0
|
||||||
|
var privateKey: String = "" // Hash приватного ключа (для авторизации)
|
||||||
|
var messageId: String = ""
|
||||||
|
var attachments: List<MessageAttachment> = emptyList()
|
||||||
|
|
||||||
|
override fun getPacketId(): Int = 0x06
|
||||||
|
|
||||||
|
override fun receive(stream: Stream) {
|
||||||
|
fromPublicKey = stream.readString()
|
||||||
|
toPublicKey = stream.readString()
|
||||||
|
content = stream.readString()
|
||||||
|
chachaKey = stream.readString()
|
||||||
|
timestamp = stream.readInt64()
|
||||||
|
privateKey = stream.readString()
|
||||||
|
messageId = stream.readString()
|
||||||
|
|
||||||
|
val attachmentCount = stream.readInt8()
|
||||||
|
val attachmentsList = mutableListOf<MessageAttachment>()
|
||||||
|
for (i in 0 until attachmentCount) {
|
||||||
|
attachmentsList.add(MessageAttachment(
|
||||||
|
id = stream.readString(),
|
||||||
|
preview = stream.readString(),
|
||||||
|
blob = stream.readString(),
|
||||||
|
type = AttachmentType.fromInt(stream.readInt8())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
attachments = attachmentsList
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun send(): Stream {
|
||||||
|
val stream = Stream()
|
||||||
|
stream.writeInt16(getPacketId())
|
||||||
|
stream.writeString(fromPublicKey)
|
||||||
|
stream.writeString(toPublicKey)
|
||||||
|
stream.writeString(content)
|
||||||
|
stream.writeString(chachaKey)
|
||||||
|
stream.writeInt64(timestamp)
|
||||||
|
stream.writeString(privateKey)
|
||||||
|
stream.writeString(messageId)
|
||||||
|
stream.writeInt8(attachments.size)
|
||||||
|
|
||||||
|
for (attachment in attachments) {
|
||||||
|
stream.writeString(attachment.id)
|
||||||
|
stream.writeString(attachment.preview)
|
||||||
|
stream.writeString(attachment.blob)
|
||||||
|
stream.writeInt8(attachment.type.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read packet (ID: 0x07)
|
||||||
|
* Уведомление о прочтении сообщения
|
||||||
|
*/
|
||||||
|
class PacketRead : Packet() {
|
||||||
|
var messageId: String = ""
|
||||||
|
var fromPublicKey: String = ""
|
||||||
|
var toPublicKey: String = ""
|
||||||
|
var privateKey: String = ""
|
||||||
|
|
||||||
|
override fun getPacketId(): Int = 0x07
|
||||||
|
|
||||||
|
override fun receive(stream: Stream) {
|
||||||
|
messageId = stream.readString()
|
||||||
|
fromPublicKey = stream.readString()
|
||||||
|
toPublicKey = stream.readString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun send(): Stream {
|
||||||
|
val stream = Stream()
|
||||||
|
stream.writeInt16(getPacketId())
|
||||||
|
stream.writeString(messageId)
|
||||||
|
stream.writeString(fromPublicKey)
|
||||||
|
stream.writeString(toPublicKey)
|
||||||
|
stream.writeString(privateKey)
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivery packet (ID: 0x08)
|
||||||
|
* Уведомление о доставке сообщения
|
||||||
|
*/
|
||||||
|
class PacketDelivery : Packet() {
|
||||||
|
var messageId: String = ""
|
||||||
|
var toPublicKey: String = ""
|
||||||
|
|
||||||
|
override fun getPacketId(): Int = 0x08
|
||||||
|
|
||||||
|
override fun receive(stream: Stream) {
|
||||||
|
messageId = stream.readString()
|
||||||
|
toPublicKey = stream.readString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun send(): Stream {
|
||||||
|
val stream = Stream()
|
||||||
|
stream.writeInt16(getPacketId())
|
||||||
|
stream.writeString(messageId)
|
||||||
|
stream.writeString(toPublicKey)
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typing packet (ID: 0x0B)
|
||||||
|
* Уведомление "печатает..."
|
||||||
|
*/
|
||||||
|
class PacketTyping : Packet() {
|
||||||
|
var fromPublicKey: String = ""
|
||||||
|
var toPublicKey: String = ""
|
||||||
|
var privateKey: String = ""
|
||||||
|
|
||||||
|
override fun getPacketId(): Int = 0x0B
|
||||||
|
|
||||||
|
override fun receive(stream: Stream) {
|
||||||
|
fromPublicKey = stream.readString()
|
||||||
|
toPublicKey = stream.readString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun send(): Stream {
|
||||||
|
val stream = Stream()
|
||||||
|
stream.writeInt16(getPacketId())
|
||||||
|
stream.writeString(fromPublicKey)
|
||||||
|
stream.writeString(toPublicKey)
|
||||||
|
stream.writeString(privateKey)
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.rosetta.messenger.network
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
|
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
|
||||||
@@ -18,11 +21,17 @@ object ProtocolManager {
|
|||||||
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
|
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
|
||||||
|
|
||||||
private var protocol: Protocol? = null
|
private var protocol: Protocol? = null
|
||||||
|
private var messageRepository: MessageRepository? = null
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
// Debug logs for dev console
|
// Debug logs for dev console
|
||||||
private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
|
private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
|
||||||
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
||||||
|
|
||||||
|
// Typing status
|
||||||
|
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
||||||
|
|
||||||
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||||
|
|
||||||
fun addLog(message: String) {
|
fun addLog(message: String) {
|
||||||
@@ -36,6 +45,62 @@ object ProtocolManager {
|
|||||||
_debugLogs.value = emptyList()
|
_debugLogs.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализация с контекстом для доступа к MessageRepository
|
||||||
|
*/
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
messageRepository = MessageRepository.getInstance(context)
|
||||||
|
setupPacketHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Настройка обработчиков пакетов
|
||||||
|
*/
|
||||||
|
private fun setupPacketHandlers() {
|
||||||
|
// Обработчик входящих сообщений (0x06)
|
||||||
|
waitPacket(0x06) { packet ->
|
||||||
|
val messagePacket = packet as PacketMessage
|
||||||
|
addLog("📩 Incoming message from ${messagePacket.fromPublicKey.take(16)}...")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
messageRepository?.handleIncomingMessage(messagePacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик доставки (0x08)
|
||||||
|
waitPacket(0x08) { packet ->
|
||||||
|
val deliveryPacket = packet as PacketDelivery
|
||||||
|
addLog("✓ Delivered: ${deliveryPacket.messageId.take(16)}...")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
messageRepository?.handleDelivery(deliveryPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик прочтения (0x07)
|
||||||
|
waitPacket(0x07) { packet ->
|
||||||
|
val readPacket = packet as PacketRead
|
||||||
|
addLog("✓✓ Read: ${readPacket.messageId.take(16)}...")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
messageRepository?.handleRead(readPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик typing (0x0B)
|
||||||
|
waitPacket(0x0B) { packet ->
|
||||||
|
val typingPacket = packet as PacketTyping
|
||||||
|
addLog("⌨️ Typing: ${typingPacket.fromPublicKey.take(16)}...")
|
||||||
|
|
||||||
|
// Добавляем в set и удаляем через 3 секунды
|
||||||
|
_typingUsers.value = _typingUsers.value + typingPacket.fromPublicKey
|
||||||
|
scope.launch {
|
||||||
|
delay(3000)
|
||||||
|
_typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create Protocol instance
|
* Get or create Protocol instance
|
||||||
*/
|
*/
|
||||||
@@ -79,10 +144,17 @@ object ProtocolManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send packet
|
* Send packet (simplified)
|
||||||
|
*/
|
||||||
|
fun send(packet: Packet) {
|
||||||
|
getProtocol().sendPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send packet (legacy name)
|
||||||
*/
|
*/
|
||||||
fun sendPacket(packet: Packet) {
|
fun sendPacket(packet: Packet) {
|
||||||
getProtocol().sendPacket(packet)
|
send(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +185,7 @@ object ProtocolManager {
|
|||||||
fun destroy() {
|
fun destroy() {
|
||||||
protocol?.destroy()
|
protocol?.destroy()
|
||||||
protocol = null
|
protocol = null
|
||||||
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.animation.*
|
|||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -22,6 +23,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -37,18 +39,23 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
|||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.rosetta.messenger.data.Message
|
||||||
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
|
import com.rosetta.messenger.network.DeliveryStatus
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
|
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Модель сообщения
|
* Модель сообщения (Legacy - для совместимости)
|
||||||
*/
|
*/
|
||||||
data class ChatMessage(
|
data class ChatMessage(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -62,6 +69,19 @@ enum class MessageStatus {
|
|||||||
SENDING, SENT, DELIVERED, READ
|
SENDING, SENT, DELIVERED, READ
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extension для конвертации
|
||||||
|
private fun Message.toChatMessage() = ChatMessage(
|
||||||
|
id = messageId,
|
||||||
|
text = content,
|
||||||
|
isOutgoing = isFromMe,
|
||||||
|
timestamp = Date(timestamp),
|
||||||
|
status = when (deliveryStatus) {
|
||||||
|
DeliveryStatus.WAITING -> MessageStatus.SENDING
|
||||||
|
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
|
||||||
|
DeliveryStatus.ERROR -> MessageStatus.SENT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экран детального чата с пользователем
|
* Экран детального чата с пользователем
|
||||||
*/
|
*/
|
||||||
@@ -70,12 +90,13 @@ enum class MessageStatus {
|
|||||||
fun ChatDetailScreen(
|
fun ChatDetailScreen(
|
||||||
user: SearchUser,
|
user: SearchUser,
|
||||||
currentUserPublicKey: String,
|
currentUserPublicKey: String,
|
||||||
|
currentUserPrivateKey: String,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onUserProfileClick: () -> Unit = {}
|
onUserProfileClick: () -> Unit = {},
|
||||||
|
viewModel: ChatViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
val surfaceColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
|
||||||
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)
|
||||||
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
|
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
|
||||||
@@ -85,28 +106,37 @@ fun ChatDetailScreen(
|
|||||||
val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) }
|
val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) }
|
||||||
val chatSubtitle = if (isSavedMessages) "Notes" else if (user.online == 1) "online" else "last seen recently"
|
val chatSubtitle = if (isSavedMessages) "Notes" else if (user.online == 1) "online" else "last seen recently"
|
||||||
|
|
||||||
// Состояние сообщений
|
// Состояние показа логов
|
||||||
var messages by remember { mutableStateOf<List<ChatMessage>>(emptyList()) }
|
var showLogs by remember { mutableStateOf(false) }
|
||||||
var inputText by remember { mutableStateOf("") }
|
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
||||||
|
|
||||||
|
// Подключаем к ViewModel
|
||||||
|
val messages by viewModel.messages.collectAsState()
|
||||||
|
val inputText by viewModel.inputText.collectAsState()
|
||||||
|
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Инициализируем ViewModel с ключами и открываем диалог
|
||||||
|
LaunchedEffect(user.publicKey) {
|
||||||
|
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
||||||
|
viewModel.openDialog(user.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прокрутка при новых сообщениях
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Аватар
|
// Аватар
|
||||||
val avatarColors = getAvatarColor(
|
val avatarColors = getAvatarColor(
|
||||||
if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey },
|
if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey },
|
||||||
isDarkTheme
|
isDarkTheme
|
||||||
)
|
)
|
||||||
|
|
||||||
// Анимация появления
|
|
||||||
var visible by remember { mutableStateOf(false) }
|
|
||||||
LaunchedEffect(Unit) { visible = true }
|
|
||||||
|
|
||||||
// Логируем открытие чата
|
|
||||||
LaunchedEffect(user.publicKey) {
|
|
||||||
ProtocolManager.addLog("💬 Chat opened with: ${user.title.ifEmpty { user.publicKey.take(10) }}")
|
|
||||||
ProtocolManager.addLog(" PublicKey: ${user.publicKey.take(20)}...")
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
// Кастомный TopAppBar для чата
|
// Кастомный TopAppBar для чата
|
||||||
@@ -198,6 +228,15 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кнопка логов (для отладки)
|
||||||
|
IconButton(onClick = { showLogs = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.BugReport,
|
||||||
|
contentDescription = "Logs",
|
||||||
|
tint = if (debugLogs.isNotEmpty()) PrimaryBlue else textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
IconButton(onClick = { /* TODO: More options */ }) {
|
IconButton(onClick = { /* TODO: More options */ }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.MoreVert,
|
Icons.Default.MoreVert,
|
||||||
@@ -276,30 +315,10 @@ fun ChatDetailScreen(
|
|||||||
// Поле ввода сообщения
|
// Поле ввода сообщения
|
||||||
MessageInputBar(
|
MessageInputBar(
|
||||||
value = inputText,
|
value = inputText,
|
||||||
onValueChange = { inputText = it },
|
onValueChange = { viewModel.updateInputText(it) },
|
||||||
onSend = {
|
onSend = {
|
||||||
if (inputText.isNotBlank()) {
|
viewModel.sendMessage()
|
||||||
val newMessage = ChatMessage(
|
ProtocolManager.addLog("📤 Sending message...")
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
text = inputText.trim(),
|
|
||||||
isOutgoing = true,
|
|
||||||
timestamp = Date(),
|
|
||||||
status = MessageStatus.SENDING
|
|
||||||
)
|
|
||||||
messages = messages + newMessage
|
|
||||||
|
|
||||||
// Логируем отправку
|
|
||||||
ProtocolManager.addLog("📤 Message sent: \"${inputText.take(30)}...\"")
|
|
||||||
|
|
||||||
inputText = ""
|
|
||||||
|
|
||||||
// Прокрутка вниз
|
|
||||||
scope.launch {
|
|
||||||
listState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Отправить через протокол
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
backgroundColor = inputBackgroundColor,
|
backgroundColor = inputBackgroundColor,
|
||||||
@@ -308,6 +327,55 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Диалог логов
|
||||||
|
if (showLogs) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showLogs = false },
|
||||||
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Debug Logs", fontWeight = FontWeight.Bold)
|
||||||
|
IconButton(onClick = { ProtocolManager.clearLogs() }) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 400.dp)
|
||||||
|
) {
|
||||||
|
items(debugLogs.reversed()) { log ->
|
||||||
|
Text(
|
||||||
|
text = log,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (debugLogs.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "No logs yet. Try sending a message.",
|
||||||
|
color = Color.Gray,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showLogs = false }) {
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -393,9 +461,7 @@ private fun MessageBubble(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Панель ввода сообщения 1:1 как в React Native
|
* Панель ввода сообщения 1:1 как в React Native
|
||||||
* - Слева: круглая кнопка Attach (скрепка)
|
* Оптимизированная версия с правильным позиционированием
|
||||||
* - Посередине: стеклянный инпут с текстом + справа emoji + send
|
|
||||||
* - Справа: круглая кнопка Mic (уезжает когда есть текст)
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -410,63 +476,71 @@ private fun MessageInputBar(
|
|||||||
) {
|
) {
|
||||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
// Цвета как в RN
|
// Цвета
|
||||||
val circleBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f) else Color(0xFFF0F0F0).copy(alpha = 0.85f)
|
val circleBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f) else Color(0xFFF0F0F0).copy(alpha = 0.85f)
|
||||||
val circleBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
|
val circleBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
|
||||||
val circleIcon = if (isDarkTheme) Color.White else Color(0xFF333333)
|
val circleIcon = if (isDarkTheme) Color.White else Color(0xFF333333)
|
||||||
|
|
||||||
val glassBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.9f) else Color(0xFFF0F0F0).copy(alpha = 0.92f)
|
val glassBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.9f) else Color(0xFFF0F0F0).copy(alpha = 0.92f)
|
||||||
val glassBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
|
val glassBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
|
||||||
val emojiIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.62f) else Color.Black.copy(alpha = 0.5f)
|
val emojiIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.62f) else Color.Black.copy(alpha = 0.5f)
|
||||||
|
|
||||||
val panelBackground = if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
|
val panelBackground = if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
|
||||||
|
|
||||||
// === Анимации как в React Native ===
|
// Состояние отправки
|
||||||
val canSend = value.isNotBlank()
|
val canSend = remember(value) { value.isNotBlank() }
|
||||||
|
|
||||||
// Easing functions
|
// Easing
|
||||||
val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f)
|
val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f)
|
||||||
val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
|
val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
|
||||||
|
|
||||||
// Send button animations
|
// Анимации Send
|
||||||
val sendOpacity by animateFloatAsState(
|
|
||||||
targetValue = if (canSend) 1f else 0f,
|
|
||||||
animationSpec = tween(200, easing = smoothEasing),
|
|
||||||
label = "sendOpacity"
|
|
||||||
)
|
|
||||||
val sendScale by animateFloatAsState(
|
val sendScale by animateFloatAsState(
|
||||||
targetValue = if (canSend) 1f else 0.5f,
|
targetValue = if (canSend) 1f else 0f,
|
||||||
animationSpec = tween(220, easing = backEasing),
|
animationSpec = tween(220, easing = backEasing),
|
||||||
label = "sendScale"
|
label = "sendScale"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mic button animations
|
// Анимации Mic
|
||||||
val micOpacity by animateFloatAsState(
|
val micOpacity by animateFloatAsState(
|
||||||
targetValue = if (canSend) 0f else 1f,
|
targetValue = if (canSend) 0f else 1f,
|
||||||
animationSpec = if (canSend) tween(150, easing = smoothEasing) else tween(200, delayMillis = 100, easing = smoothEasing),
|
animationSpec = tween(200, easing = smoothEasing),
|
||||||
label = "micOpacity"
|
label = "micOpacity"
|
||||||
)
|
)
|
||||||
val micTranslateX by animateFloatAsState(
|
val micTranslateX by animateFloatAsState(
|
||||||
targetValue = if (canSend) 80f else 0f,
|
targetValue = if (canSend) 80f else 0f,
|
||||||
animationSpec = if (canSend) tween(250, easing = smoothEasing) else tween(250, delayMillis = 80, easing = smoothEasing),
|
animationSpec = tween(250, easing = smoothEasing),
|
||||||
label = "micTranslateX"
|
label = "micTranslateX"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Emoji button animation (сдвигается влево когда появляется send)
|
// Input margin
|
||||||
val emojiTranslateX by animateFloatAsState(
|
|
||||||
targetValue = if (canSend) -50f else 0f,
|
|
||||||
animationSpec = tween(220, easing = smoothEasing),
|
|
||||||
label = "emojiTranslateX"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Input margin animation (расширяется когда текст есть)
|
|
||||||
val inputEndMargin by animateDpAsState(
|
val inputEndMargin by animateDpAsState(
|
||||||
targetValue = if (canSend) 0.dp else 56.dp,
|
targetValue = if (canSend) 0.dp else 56.dp,
|
||||||
animationSpec = tween(220, easing = smoothEasing),
|
animationSpec = tween(220, easing = smoothEasing),
|
||||||
label = "inputEndMargin"
|
label = "inputEndMargin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Функция переключения emoji picker
|
||||||
|
fun toggleEmojiPicker() {
|
||||||
|
if (showEmojiPicker) {
|
||||||
|
showEmojiPicker = false
|
||||||
|
} else {
|
||||||
|
// Скрываем клавиатуру и убираем фокус
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
showEmojiPicker = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция отправки
|
||||||
|
fun handleSend() {
|
||||||
|
if (value.isNotBlank()) {
|
||||||
|
onSend()
|
||||||
|
onValueChange("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -483,14 +557,17 @@ private fun MessageInputBar(
|
|||||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.Bottom
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
// === ATTACH BUTTON (круг слева) ===
|
// ATTACH BUTTON
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(circleBackground)
|
.background(circleBackground)
|
||||||
.border(1.dp, circleBorder, CircleShape)
|
.border(1.dp, circleBorder, CircleShape)
|
||||||
.clickable { /* TODO: Attach */ },
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = null
|
||||||
|
) { /* TODO */ },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -503,89 +580,89 @@ private fun MessageInputBar(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
// === GLASS INPUT (расширяется вправо) ===
|
// GLASS INPUT
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(end = inputEndMargin)
|
.padding(end = inputEndMargin)
|
||||||
.heightIn(min = 48.dp, max = 120.dp)
|
.heightIn(min = 48.dp, max = 120.dp)
|
||||||
.clip(RoundedCornerShape(22.dp))
|
.clip(RoundedCornerShape(24.dp))
|
||||||
.background(glassBackground)
|
.background(glassBackground)
|
||||||
.border(1.dp, glassBorder, RoundedCornerShape(22.dp))
|
.border(1.dp, glassBorder, RoundedCornerShape(24.dp))
|
||||||
) {
|
) {
|
||||||
Row(
|
// Text input
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 14.dp, vertical = 4.dp),
|
.padding(start = 16.dp, end = 52.dp, top = 12.dp, bottom = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
// Apple Emoji Text Field (с PNG эмодзи)
|
AppleEmojiTextField(
|
||||||
Box(
|
value = value,
|
||||||
modifier = Modifier
|
onValueChange = { newValue ->
|
||||||
.weight(1f)
|
// Закрываем emoji picker при печати с клавиатуры
|
||||||
.padding(top = 8.dp, bottom = 8.dp, end = 70.dp), // место для emoji + send
|
if (showEmojiPicker && newValue.length > value.length) {
|
||||||
contentAlignment = Alignment.CenterStart
|
// Не закрываем - пользователь мог выбрать emoji
|
||||||
) {
|
}
|
||||||
AppleEmojiTextField(
|
onValueChange(newValue)
|
||||||
value = value,
|
},
|
||||||
onValueChange = onValueChange,
|
textColor = textColor,
|
||||||
textColor = textColor,
|
textSize = 16f,
|
||||||
textSize = 16f,
|
hint = "Message",
|
||||||
hint = "Message",
|
hintColor = placeholderColor.copy(alpha = 0.6f),
|
||||||
hintColor = placeholderColor.copy(alpha = 0.6f),
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === RIGHT ZONE (emoji + send) - абсолютная позиция справа внутри инпута ===
|
// RIGHT ZONE - emoji или send
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.padding(end = 6.dp)
|
.padding(end = 6.dp)
|
||||||
|
.size(40.dp)
|
||||||
) {
|
) {
|
||||||
// Emoji button (сдвигается влево при send)
|
// Emoji button (показывается когда нет текста)
|
||||||
Box(
|
if (!canSend) {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.graphicsLayer { translationX = emojiTranslateX }
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.fillMaxSize()
|
||||||
.clickable {
|
.clickable(
|
||||||
if (showEmojiPicker) {
|
interactionSource = interactionSource,
|
||||||
showEmojiPicker = false
|
indication = null,
|
||||||
} else {
|
onClick = { toggleEmojiPicker() }
|
||||||
keyboardController?.hide()
|
),
|
||||||
showEmojiPicker = true
|
contentAlignment = Alignment.Center
|
||||||
}
|
) {
|
||||||
},
|
Icon(
|
||||||
contentAlignment = Alignment.Center
|
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions,
|
||||||
) {
|
contentDescription = "Emoji",
|
||||||
Icon(
|
tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor,
|
||||||
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions,
|
modifier = Modifier.size(24.dp)
|
||||||
contentDescription = "Emoji",
|
)
|
||||||
tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor,
|
}
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send button - красивая круглая кнопка с градиентом
|
// Send button (показывается когда есть текст)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = sendScale
|
scaleX = sendScale
|
||||||
scaleY = sendScale
|
scaleY = sendScale
|
||||||
alpha = sendOpacity
|
alpha = sendScale
|
||||||
}
|
}
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
brush = Brush.linearGradient(
|
brush = Brush.linearGradient(
|
||||||
colors = listOf(
|
colors = listOf(Color(0xFF007AFF), Color(0xFF5AC8FA))
|
||||||
Color(0xFF007AFF),
|
|
||||||
Color(0xFF5AC8FA)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.clickable(enabled = canSend) { onSend() },
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = null,
|
||||||
|
enabled = canSend,
|
||||||
|
onClick = { handleSend() }
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -599,7 +676,7 @@ private fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === MIC BUTTON (абсолютная позиция справа, уезжает вправо) ===
|
// MIC BUTTON
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
@@ -612,7 +689,11 @@ private fun MessageInputBar(
|
|||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(circleBackground)
|
.background(circleBackground)
|
||||||
.border(1.dp, circleBorder, CircleShape)
|
.border(1.dp, circleBorder, CircleShape)
|
||||||
.clickable(enabled = !canSend) { /* TODO: Voice */ },
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = null,
|
||||||
|
enabled = !canSend
|
||||||
|
) { /* TODO */ },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -624,20 +705,11 @@ private fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Emoji Picker с PNG изображениями
|
// Apple Emoji Picker
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showEmojiPicker,
|
visible = showEmojiPicker,
|
||||||
enter = expandVertically(
|
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||||
expandFrom = Alignment.Top,
|
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
||||||
stiffness = Spring.StiffnessMediumLow
|
|
||||||
)
|
|
||||||
) + fadeIn(animationSpec = tween(150)),
|
|
||||||
exit = shrinkVertically(
|
|
||||||
shrinkTowards = Alignment.Top,
|
|
||||||
animationSpec = tween(200)
|
|
||||||
) + fadeOut(animationSpec = tween(100))
|
|
||||||
) {
|
) {
|
||||||
AppleEmojiPickerPanel(
|
AppleEmojiPickerPanel(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
@@ -648,7 +720,6 @@ private fun MessageInputBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spacer для navigation bar когда эмодзи пикер НЕ открыт
|
|
||||||
if (!showEmojiPicker) {
|
if (!showEmojiPicker) {
|
||||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
|
import com.rosetta.messenger.network.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel для экрана чата - упрощенная рабочая версия
|
||||||
|
* Без зависимости от MessageRepository
|
||||||
|
*/
|
||||||
|
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChatViewModel"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текущий диалог
|
||||||
|
private var opponentKey: String? = null
|
||||||
|
private var myPublicKey: String? = null
|
||||||
|
private var myPrivateKey: String? = null
|
||||||
|
|
||||||
|
// UI State - сообщения хранятся локально в памяти
|
||||||
|
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||||
|
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _opponentTyping = MutableStateFlow(false)
|
||||||
|
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
||||||
|
|
||||||
|
// Input state
|
||||||
|
private val _inputText = MutableStateFlow("")
|
||||||
|
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||||
|
|
||||||
|
// Защита от двойной отправки
|
||||||
|
private var isSending = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
setupPacketListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupPacketListeners() {
|
||||||
|
// Входящие сообщения
|
||||||
|
ProtocolManager.waitPacket(0x06) { packet ->
|
||||||
|
val msgPacket = packet as PacketMessage
|
||||||
|
if (msgPacket.fromPublicKey == opponentKey || msgPacket.toPublicKey == opponentKey) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
handleIncomingMessage(msgPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Доставка
|
||||||
|
ProtocolManager.waitPacket(0x08) { packet ->
|
||||||
|
val deliveryPacket = packet as PacketDelivery
|
||||||
|
viewModelScope.launch {
|
||||||
|
updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED)
|
||||||
|
ProtocolManager.addLog("✓ Delivered: ${deliveryPacket.messageId.take(8)}...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прочитано
|
||||||
|
ProtocolManager.waitPacket(0x07) { packet ->
|
||||||
|
val readPacket = packet as PacketRead
|
||||||
|
viewModelScope.launch {
|
||||||
|
updateMessageStatus(readPacket.messageId, MessageStatus.READ)
|
||||||
|
ProtocolManager.addLog("✓✓ Read: ${readPacket.messageId.take(8)}...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing
|
||||||
|
ProtocolManager.waitPacket(0x0B) { packet ->
|
||||||
|
val typingPacket = packet as PacketTyping
|
||||||
|
if (typingPacket.fromPublicKey == opponentKey) {
|
||||||
|
showTypingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIncomingMessage(packet: PacketMessage) {
|
||||||
|
try {
|
||||||
|
val message = ChatMessage(
|
||||||
|
id = packet.messageId,
|
||||||
|
text = "[Encrypted] ${packet.content.take(20)}...",
|
||||||
|
isOutgoing = packet.fromPublicKey == myPublicKey,
|
||||||
|
timestamp = Date(packet.timestamp),
|
||||||
|
status = MessageStatus.DELIVERED
|
||||||
|
)
|
||||||
|
_messages.value = _messages.value + message
|
||||||
|
ProtocolManager.addLog("📩 Incoming: ${packet.messageId.take(8)}...")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ProtocolManager.addLog("❌ Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMessageStatus(messageId: String, status: MessageStatus) {
|
||||||
|
_messages.value = _messages.value.map { msg ->
|
||||||
|
if (msg.id == messageId) msg.copy(status = status) else msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установить ключи пользователя
|
||||||
|
*/
|
||||||
|
fun setUserKeys(publicKey: String, privateKey: String) {
|
||||||
|
myPublicKey = publicKey
|
||||||
|
myPrivateKey = privateKey
|
||||||
|
ProtocolManager.addLog("🔑 Keys set: ${publicKey.take(16)}...")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть диалог
|
||||||
|
*/
|
||||||
|
fun openDialog(publicKey: String) {
|
||||||
|
opponentKey = publicKey
|
||||||
|
_messages.value = emptyList()
|
||||||
|
ProtocolManager.addLog("💬 Dialog: ${publicKey.take(16)}...")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить текст ввода
|
||||||
|
*/
|
||||||
|
fun updateInputText(text: String) {
|
||||||
|
_inputText.value = text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить сообщение - Optimistic UI
|
||||||
|
*/
|
||||||
|
fun sendMessage() {
|
||||||
|
val text = _inputText.value.trim()
|
||||||
|
val recipient = opponentKey
|
||||||
|
val sender = myPublicKey
|
||||||
|
val privateKey = myPrivateKey
|
||||||
|
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
ProtocolManager.addLog("❌ Empty text")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (recipient == null) {
|
||||||
|
ProtocolManager.addLog("❌ No recipient")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (sender == null || privateKey == null) {
|
||||||
|
ProtocolManager.addLog("❌ No keys - set via setUserKeys()")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSending) {
|
||||||
|
ProtocolManager.addLog("⏳ Already sending...")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSending = true
|
||||||
|
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// 1. Optimistic UI
|
||||||
|
val optimisticMessage = ChatMessage(
|
||||||
|
id = messageId,
|
||||||
|
text = text,
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = Date(timestamp),
|
||||||
|
status = MessageStatus.SENDING
|
||||||
|
)
|
||||||
|
_messages.value = _messages.value + optimisticMessage
|
||||||
|
_inputText.value = ""
|
||||||
|
|
||||||
|
ProtocolManager.addLog("📤 Send: \"${text.take(20)}...\"")
|
||||||
|
|
||||||
|
// 2. Отправка в фоне
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
ProtocolManager.addLog("🔐 Encrypting...")
|
||||||
|
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient)
|
||||||
|
ProtocolManager.addLog("✓ Encrypted")
|
||||||
|
|
||||||
|
val packet = PacketMessage().apply {
|
||||||
|
fromPublicKey = sender
|
||||||
|
toPublicKey = recipient
|
||||||
|
content = encryptedContent
|
||||||
|
chachaKey = encryptedKey
|
||||||
|
this.timestamp = timestamp
|
||||||
|
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
this.messageId = messageId
|
||||||
|
attachments = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
ProtocolManager.addLog("📡 Sending packet...")
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
ProtocolManager.addLog("✓ Sent!")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ProtocolManager.addLog("❌ Error: ${e.message}")
|
||||||
|
Log.e(TAG, "Send error", e)
|
||||||
|
} finally {
|
||||||
|
isSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showTypingIndicator() {
|
||||||
|
_opponentTyping.value = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
kotlinx.coroutines.delay(3000)
|
||||||
|
_opponentTyping.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
opponentKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.2.0" apply false
|
id("com.android.application") version "8.2.0" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
|
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
|
||||||
|
id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class) {
|
tasks.register("clean", Delete::class) {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ android.useAndroidX=true
|
|||||||
# Kotlin code style for this project: "official" or "obsolete"
|
# Kotlin code style for this project: "official" or "obsolete"
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
||||||
|
# Use Java 17 for build
|
||||||
|
org.gradle.java.home=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home
|
||||||
|
|
||||||
# Increase heap size for Gradle
|
# Increase heap size for Gradle
|
||||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
|
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
|
||||||
org.gradle.daemon=true
|
org.gradle.daemon=true
|
||||||
|
|||||||
Reference in New Issue
Block a user