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:
k1ngsterr1
2026-01-10 22:15:27 +05:00
parent 286706188b
commit 6014d23d69
12 changed files with 1643 additions and 142 deletions

View File

@@ -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")

View File

@@ -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 }
)

View 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()
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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()
}
/**

View File

@@ -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())
}

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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