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 {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
// kotlin("kapt") // Временно отключено из-за проблемы с Java 21
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -114,7 +114,7 @@ dependencies {
|
||||
// Room for database
|
||||
implementation("androidx.room:room-runtime: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
|
||||
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)}"
|
||||
} ?: "+7 775 9932587"
|
||||
val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
|
||||
val accountPrivateKey = account?.privateKey ?: ""
|
||||
val privateKeyHash = account?.privateKeyHash ?: ""
|
||||
|
||||
// Навигация между экранами
|
||||
@@ -250,6 +251,7 @@ fun MainScreen(
|
||||
ChatDetailScreen(
|
||||
user = user,
|
||||
currentUserPublicKey = accountPublicKey,
|
||||
currentUserPrivateKey = accountPrivateKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
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
|
||||
|
||||
@Database(
|
||||
entities = [EncryptedAccountEntity::class],
|
||||
version = 1,
|
||||
entities = [
|
||||
EncryptedAccountEntity::class,
|
||||
MessageEntity::class,
|
||||
DialogEntity::class
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class RosettaDatabase : RoomDatabase() {
|
||||
abstract fun accountDao(): AccountDao
|
||||
abstract fun messageDao(): MessageDao
|
||||
abstract fun dialogDao(): DialogDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
@@ -25,6 +31,7 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
"rosetta_secure.db"
|
||||
)
|
||||
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
|
||||
.fallbackToDestructiveMigration() // Для разработки
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
|
||||
@@ -186,3 +186,185 @@ class PacketOnlineSubscribe : Packet() {
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -18,11 +21,17 @@ object ProtocolManager {
|
||||
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
|
||||
|
||||
private var protocol: Protocol? = null
|
||||
private var messageRepository: MessageRepository? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Debug logs for dev console
|
||||
private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
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())
|
||||
|
||||
fun addLog(message: String) {
|
||||
@@ -36,6 +45,62 @@ object ProtocolManager {
|
||||
_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
|
||||
*/
|
||||
@@ -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) {
|
||||
getProtocol().sendPacket(packet)
|
||||
send(packet)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +185,7 @@ object ProtocolManager {
|
||||
fun destroy() {
|
||||
protocol?.destroy()
|
||||
protocol = null
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
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.items
|
||||
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.SearchUser
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Модель сообщения
|
||||
* Модель сообщения (Legacy - для совместимости)
|
||||
*/
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
@@ -62,6 +69,19 @@ enum class MessageStatus {
|
||||
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(
|
||||
user: SearchUser,
|
||||
currentUserPublicKey: String,
|
||||
currentUserPrivateKey: String,
|
||||
isDarkTheme: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onUserProfileClick: () -> Unit = {}
|
||||
onUserProfileClick: () -> Unit = {},
|
||||
viewModel: ChatViewModel = viewModel()
|
||||
) {
|
||||
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 secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
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 chatSubtitle = if (isSavedMessages) "Notes" else if (user.online == 1) "online" else "last seen recently"
|
||||
|
||||
// Состояние сообщений
|
||||
var messages by remember { mutableStateOf<List<ChatMessage>>(emptyList()) }
|
||||
var inputText by remember { mutableStateOf("") }
|
||||
// Состояние показа логов
|
||||
var showLogs by remember { mutableStateOf(false) }
|
||||
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 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(
|
||||
if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey },
|
||||
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(
|
||||
topBar = {
|
||||
// Кастомный 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 */ }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
@@ -276,30 +315,10 @@ fun ChatDetailScreen(
|
||||
// Поле ввода сообщения
|
||||
MessageInputBar(
|
||||
value = inputText,
|
||||
onValueChange = { inputText = it },
|
||||
onValueChange = { viewModel.updateInputText(it) },
|
||||
onSend = {
|
||||
if (inputText.isNotBlank()) {
|
||||
val newMessage = ChatMessage(
|
||||
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: Отправить через протокол
|
||||
}
|
||||
viewModel.sendMessage()
|
||||
ProtocolManager.addLog("📤 Sending message...")
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
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
|
||||
* - Слева: круглая кнопка Attach (скрепка)
|
||||
* - Посередине: стеклянный инпут с текстом + справа emoji + send
|
||||
* - Справа: круглая кнопка Mic (уезжает когда есть текст)
|
||||
* Оптимизированная версия с правильным позиционированием
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@@ -410,63 +476,71 @@ private fun MessageInputBar(
|
||||
) {
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
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 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 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 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)
|
||||
|
||||
// === Анимации как в 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 smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
|
||||
|
||||
// Send button animations
|
||||
val sendOpacity by animateFloatAsState(
|
||||
targetValue = if (canSend) 1f else 0f,
|
||||
animationSpec = tween(200, easing = smoothEasing),
|
||||
label = "sendOpacity"
|
||||
)
|
||||
// Анимации Send
|
||||
val sendScale by animateFloatAsState(
|
||||
targetValue = if (canSend) 1f else 0.5f,
|
||||
targetValue = if (canSend) 1f else 0f,
|
||||
animationSpec = tween(220, easing = backEasing),
|
||||
label = "sendScale"
|
||||
)
|
||||
|
||||
// Mic button animations
|
||||
// Анимации Mic
|
||||
val micOpacity by animateFloatAsState(
|
||||
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"
|
||||
)
|
||||
val micTranslateX by animateFloatAsState(
|
||||
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"
|
||||
)
|
||||
|
||||
// Emoji button animation (сдвигается влево когда появляется send)
|
||||
val emojiTranslateX by animateFloatAsState(
|
||||
targetValue = if (canSend) -50f else 0f,
|
||||
animationSpec = tween(220, easing = smoothEasing),
|
||||
label = "emojiTranslateX"
|
||||
)
|
||||
|
||||
// Input margin animation (расширяется когда текст есть)
|
||||
// Input margin
|
||||
val inputEndMargin by animateDpAsState(
|
||||
targetValue = if (canSend) 0.dp else 56.dp,
|
||||
animationSpec = tween(220, easing = smoothEasing),
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -483,14 +557,17 @@ private fun MessageInputBar(
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
// === ATTACH BUTTON (круг слева) ===
|
||||
// ATTACH BUTTON
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(circleBackground)
|
||||
.border(1.dp, circleBorder, CircleShape)
|
||||
.clickable { /* TODO: Attach */ },
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) { /* TODO */ },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
@@ -503,32 +580,32 @@ private fun MessageInputBar(
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// === GLASS INPUT (расширяется вправо) ===
|
||||
// GLASS INPUT
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = inputEndMargin)
|
||||
.heightIn(min = 48.dp, max = 120.dp)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(glassBackground)
|
||||
.border(1.dp, glassBorder, RoundedCornerShape(22.dp))
|
||||
.border(1.dp, glassBorder, RoundedCornerShape(24.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Apple Emoji Text Field (с PNG эмодзи)
|
||||
// Text input
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(top = 8.dp, bottom = 8.dp, end = 70.dp), // место для emoji + send
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 52.dp, top = 12.dp, bottom = 12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
AppleEmojiTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
onValueChange = { newValue ->
|
||||
// Закрываем emoji picker при печати с клавиатуры
|
||||
if (showEmojiPicker && newValue.length > value.length) {
|
||||
// Не закрываем - пользователь мог выбрать emoji
|
||||
}
|
||||
onValueChange(newValue)
|
||||
},
|
||||
textColor = textColor,
|
||||
textSize = 16f,
|
||||
hint = "Message",
|
||||
@@ -536,27 +613,24 @@ private fun MessageInputBar(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// === RIGHT ZONE (emoji + send) - абсолютная позиция справа внутри инпута ===
|
||||
// RIGHT ZONE - emoji или send
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 6.dp)
|
||||
.size(40.dp)
|
||||
) {
|
||||
// Emoji button (сдвигается влево при send)
|
||||
// Emoji button (показывается когда нет текста)
|
||||
if (!canSend) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer { translationX = emojiTranslateX }
|
||||
.size(40.dp)
|
||||
.clickable {
|
||||
if (showEmojiPicker) {
|
||||
showEmojiPicker = false
|
||||
} else {
|
||||
keyboardController?.hide()
|
||||
showEmojiPicker = true
|
||||
}
|
||||
},
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = { toggleEmojiPicker() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
@@ -566,26 +640,29 @@ private fun MessageInputBar(
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send button - красивая круглая кнопка с градиентом
|
||||
// Send button (показывается когда есть текст)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = sendScale
|
||||
scaleY = sendScale
|
||||
alpha = sendOpacity
|
||||
alpha = sendScale
|
||||
}
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF007AFF),
|
||||
Color(0xFF5AC8FA)
|
||||
colors = listOf(Color(0xFF007AFF), Color(0xFF5AC8FA))
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable(enabled = canSend) { onSend() },
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = canSend,
|
||||
onClick = { handleSend() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
@@ -599,7 +676,7 @@ private fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
// === MIC BUTTON (абсолютная позиция справа, уезжает вправо) ===
|
||||
// MIC BUTTON
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
@@ -612,7 +689,11 @@ private fun MessageInputBar(
|
||||
.clip(CircleShape)
|
||||
.background(circleBackground)
|
||||
.border(1.dp, circleBorder, CircleShape)
|
||||
.clickable(enabled = !canSend) { /* TODO: Voice */ },
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = !canSend
|
||||
) { /* TODO */ },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
@@ -624,20 +705,11 @@ private fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
// Apple Emoji Picker с PNG изображениями
|
||||
// Apple Emoji Picker
|
||||
AnimatedVisibility(
|
||||
visible = showEmojiPicker,
|
||||
enter = expandVertically(
|
||||
expandFrom = Alignment.Top,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
) + fadeIn(animationSpec = tween(150)),
|
||||
exit = shrinkVertically(
|
||||
shrinkTowards = Alignment.Top,
|
||||
animationSpec = tween(200)
|
||||
) + fadeOut(animationSpec = tween(100))
|
||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
|
||||
) {
|
||||
AppleEmojiPickerPanel(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -648,7 +720,6 @@ private fun MessageInputBar(
|
||||
)
|
||||
}
|
||||
|
||||
// Spacer для navigation bar когда эмодзи пикер НЕ открыт
|
||||
if (!showEmojiPicker) {
|
||||
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 {
|
||||
id("com.android.application") version "8.2.0" 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) {
|
||||
|
||||
@@ -6,6 +6,9 @@ android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete"
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user