From 6014d23d6926c17c446ae0fdb630dec572c69fe6 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 10 Jan 2026 22:15:27 +0500 Subject: [PATCH] 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 --- app/build.gradle.kts | 4 +- .../com/rosetta/messenger/MainActivity.kt | 2 + .../rosetta/messenger/crypto/MessageCrypto.kt | 214 +++++++++ .../messenger/data/MessageRepository.kt | 416 ++++++++++++++++++ .../messenger/database/MessageEntities.kt | 305 +++++++++++++ .../messenger/database/RosettaDatabase.kt | 11 +- .../com/rosetta/messenger/network/Packets.kt | 182 ++++++++ .../messenger/network/ProtocolManager.kt | 77 +++- .../messenger/ui/chats/ChatDetailScreen.kt | 343 +++++++++------ .../messenger/ui/chats/ChatViewModel.kt | 227 ++++++++++ build.gradle.kts | 1 + gradle.properties | 3 + 12 files changed, 1643 insertions(+), 142 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt create mode 100644 app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt create mode 100644 app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d90a64..aa494ae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 1bda167..0d989e4 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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 } ) diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt new file mode 100644 index 0000000..1a764d1 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -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 { + // 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() +} diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt new file mode 100644 index 0000000..ea21fbd --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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 = 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>>() + + // Кэш диалогов + private val _dialogs = MutableStateFlow>(emptyList()) + val dialogs: StateFlow> = _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> { + val dialogKey = getDialogKey(opponentKey) + + return messageCache.getOrPut(dialogKey) { + MutableStateFlow>(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 = 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 + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt new file mode 100644 index 0000000..0c3bb8d --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -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) + + /** + * Получить сообщения диалога (постранично) + */ + @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 + + /** + * Получить сообщения диалога как Flow + */ + @Query(""" + SELECT * FROM messages + WHERE account = :account AND dialog_key = :dialogKey + ORDER BY timestamp ASC + """) + fun getMessagesFlow(account: String, dialogKey: String): Flow> + + /** + * Получить последние 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 + + /** + * Найти сообщение по 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> + + /** + * Получить диалог + */ + @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 + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt index 07fe6a9..95529a4 100644 --- a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt +++ b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/network/Packets.kt b/app/src/main/java/com/rosetta/messenger/network/Packets.kt index c1022a8..e9572f2 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Packets.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Packets.kt @@ -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 = 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() + 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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 72d9f69..e53e2c6 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -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>(emptyList()) val debugLogs: StateFlow> = _debugLogs.asStateFlow() + // Typing status + private val _typingUsers = MutableStateFlow>(emptySet()) + val typingUsers: StateFlow> = _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() } /** diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index ebeb3ca..00e3de0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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>(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,89 +580,89 @@ 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( + // Text input + Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically + .padding(start = 16.dp, end = 52.dp, top = 12.dp, bottom = 12.dp), + contentAlignment = Alignment.CenterStart ) { - // Apple Emoji Text Field (с PNG эмодзи) - Box( - modifier = Modifier - .weight(1f) - .padding(top = 8.dp, bottom = 8.dp, end = 70.dp), // место для emoji + send - contentAlignment = Alignment.CenterStart - ) { - AppleEmojiTextField( - value = value, - onValueChange = onValueChange, - textColor = textColor, - textSize = 16f, - hint = "Message", - hintColor = placeholderColor.copy(alpha = 0.6f), - modifier = Modifier.fillMaxWidth() - ) - } + AppleEmojiTextField( + value = value, + onValueChange = { newValue -> + // Закрываем emoji picker при печати с клавиатуры + if (showEmojiPicker && newValue.length > value.length) { + // Не закрываем - пользователь мог выбрать emoji + } + onValueChange(newValue) + }, + textColor = textColor, + textSize = 16f, + hint = "Message", + hintColor = placeholderColor.copy(alpha = 0.6f), + 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) - Box( - modifier = Modifier - .graphicsLayer { translationX = emojiTranslateX } - .size(40.dp) - .clickable { - if (showEmojiPicker) { - showEmojiPicker = false - } else { - keyboardController?.hide() - showEmojiPicker = true - } - }, - contentAlignment = Alignment.Center - ) { - Icon( - if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, - contentDescription = "Emoji", - tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor, - modifier = Modifier.size(24.dp) - ) + // Emoji button (показывается когда нет текста) + if (!canSend) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { toggleEmojiPicker() } + ), + contentAlignment = Alignment.Center + ) { + Icon( + if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, + contentDescription = "Emoji", + tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor, + 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()) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt new file mode 100644 index 0000000..70cdbfa --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _opponentTyping = MutableStateFlow(false) + val opponentTyping: StateFlow = _opponentTyping.asStateFlow() + + // Input state + private val _inputText = MutableStateFlow("") + val inputText: StateFlow = _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 + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 4f4e1f6..734772c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) { diff --git a/gradle.properties b/gradle.properties index b570ddc..9c44b4b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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