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:
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 { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
// kotlin("kapt") // Временно отключено из-за проблемы с Java 21 id("com.google.devtools.ksp")
} }
android { android {
@@ -114,7 +114,7 @@ dependencies {
// Room for database // Room for database
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.room:room-ktx:2.6.1")
// kapt("androidx.room:room-compiler:2.6.1") // Временно отключено ksp("androidx.room:room-compiler:2.6.1")
// Biometric authentication // Biometric authentication
implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.biometric:biometric:1.1.0")

View File

@@ -214,6 +214,7 @@ fun MainScreen(
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}" "+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
} ?: "+7 775 9932587" } ?: "+7 775 9932587"
val accountPublicKey = account?.publicKey ?: "04c266b98ae5" val accountPublicKey = account?.publicKey ?: "04c266b98ae5"
val accountPrivateKey = account?.privateKey ?: ""
val privateKeyHash = account?.privateKeyHash ?: "" val privateKeyHash = account?.privateKeyHash ?: ""
// Навигация между экранами // Навигация между экранами
@@ -250,6 +251,7 @@ fun MainScreen(
ChatDetailScreen( ChatDetailScreen(
user = user, user = user,
currentUserPublicKey = accountPublicKey, currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { selectedUser = null } onBack = { selectedUser = null }
) )

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 import androidx.room.RoomDatabase
@Database( @Database(
entities = [EncryptedAccountEntity::class], entities = [
version = 1, EncryptedAccountEntity::class,
MessageEntity::class,
DialogEntity::class
],
version = 2,
exportSchema = false exportSchema = false
) )
abstract class RosettaDatabase : RoomDatabase() { abstract class RosettaDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao abstract fun accountDao(): AccountDao
abstract fun messageDao(): MessageDao
abstract fun dialogDao(): DialogDao
companion object { companion object {
@Volatile @Volatile
@@ -25,6 +31,7 @@ abstract class RosettaDatabase : RoomDatabase() {
"rosetta_secure.db" "rosetta_secure.db"
) )
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance .setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
.fallbackToDestructiveMigration() // Для разработки
.build() .build()
INSTANCE = instance INSTANCE = instance
instance instance

View File

@@ -186,3 +186,185 @@ class PacketOnlineSubscribe : Packet() {
return stream return stream
} }
} }
// ============================================================================
// MESSAGE PACKETS - Как в React Native версии
// ============================================================================
/**
* Типы вложений
*/
enum class AttachmentType(val value: Int) {
IMAGE(0), // Изображение
MESSAGES(1), // Reply (цитата сообщения)
FILE(2); // Файл
companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE
}
}
/**
* Статус доставки сообщения
*/
enum class DeliveryStatus(val value: Int) {
WAITING(0), // Ожидает отправки
DELIVERED(1), // Доставлено
ERROR(2); // Ошибка
companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: WAITING
}
}
/**
* Вложение к сообщению
*/
data class MessageAttachment(
val id: String,
val blob: String, // Base64 данные или пусто для CDN
val type: AttachmentType,
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
val width: Int = 0,
val height: Int = 0
)
/**
* Message packet (ID: 0x06)
* Отправка и получение сообщений
*/
class PacketMessage : Packet() {
var fromPublicKey: String = ""
var toPublicKey: String = ""
var content: String = "" // Зашифрованный текст
var chachaKey: String = "" // RSA зашифрованный ключ
var timestamp: Long = 0
var privateKey: String = "" // Hash приватного ключа (для авторизации)
var messageId: String = ""
var attachments: List<MessageAttachment> = emptyList()
override fun getPacketId(): Int = 0x06
override fun receive(stream: Stream) {
fromPublicKey = stream.readString()
toPublicKey = stream.readString()
content = stream.readString()
chachaKey = stream.readString()
timestamp = stream.readInt64()
privateKey = stream.readString()
messageId = stream.readString()
val attachmentCount = stream.readInt8()
val attachmentsList = mutableListOf<MessageAttachment>()
for (i in 0 until attachmentCount) {
attachmentsList.add(MessageAttachment(
id = stream.readString(),
preview = stream.readString(),
blob = stream.readString(),
type = AttachmentType.fromInt(stream.readInt8())
))
}
attachments = attachmentsList
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(fromPublicKey)
stream.writeString(toPublicKey)
stream.writeString(content)
stream.writeString(chachaKey)
stream.writeInt64(timestamp)
stream.writeString(privateKey)
stream.writeString(messageId)
stream.writeInt8(attachments.size)
for (attachment in attachments) {
stream.writeString(attachment.id)
stream.writeString(attachment.preview)
stream.writeString(attachment.blob)
stream.writeInt8(attachment.type.value)
}
return stream
}
}
/**
* Read packet (ID: 0x07)
* Уведомление о прочтении сообщения
*/
class PacketRead : Packet() {
var messageId: String = ""
var fromPublicKey: String = ""
var toPublicKey: String = ""
var privateKey: String = ""
override fun getPacketId(): Int = 0x07
override fun receive(stream: Stream) {
messageId = stream.readString()
fromPublicKey = stream.readString()
toPublicKey = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(messageId)
stream.writeString(fromPublicKey)
stream.writeString(toPublicKey)
stream.writeString(privateKey)
return stream
}
}
/**
* Delivery packet (ID: 0x08)
* Уведомление о доставке сообщения
*/
class PacketDelivery : Packet() {
var messageId: String = ""
var toPublicKey: String = ""
override fun getPacketId(): Int = 0x08
override fun receive(stream: Stream) {
messageId = stream.readString()
toPublicKey = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(messageId)
stream.writeString(toPublicKey)
return stream
}
}
/**
* Typing packet (ID: 0x0B)
* Уведомление "печатает..."
*/
class PacketTyping : Packet() {
var fromPublicKey: String = ""
var toPublicKey: String = ""
var privateKey: String = ""
override fun getPacketId(): Int = 0x0B
override fun receive(stream: Stream) {
fromPublicKey = stream.readString()
toPublicKey = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(fromPublicKey)
stream.writeString(toPublicKey)
stream.writeString(privateKey)
return stream
}
}

View File

@@ -1,6 +1,9 @@
package com.rosetta.messenger.network package com.rosetta.messenger.network
import android.content.Context
import android.util.Log import android.util.Log
import com.rosetta.messenger.data.MessageRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -18,11 +21,17 @@ object ProtocolManager {
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000" private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
private var protocol: Protocol? = null private var protocol: Protocol? = null
private var messageRepository: MessageRepository? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Debug logs for dev console // Debug logs for dev console
private val _debugLogs = MutableStateFlow<List<String>>(emptyList()) private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow() val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
// Typing status
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
fun addLog(message: String) { fun addLog(message: String) {
@@ -36,6 +45,62 @@ object ProtocolManager {
_debugLogs.value = emptyList() _debugLogs.value = emptyList()
} }
/**
* Инициализация с контекстом для доступа к MessageRepository
*/
fun initialize(context: Context) {
messageRepository = MessageRepository.getInstance(context)
setupPacketHandlers()
}
/**
* Настройка обработчиков пакетов
*/
private fun setupPacketHandlers() {
// Обработчик входящих сообщений (0x06)
waitPacket(0x06) { packet ->
val messagePacket = packet as PacketMessage
addLog("📩 Incoming message from ${messagePacket.fromPublicKey.take(16)}...")
scope.launch {
messageRepository?.handleIncomingMessage(messagePacket)
}
}
// Обработчик доставки (0x08)
waitPacket(0x08) { packet ->
val deliveryPacket = packet as PacketDelivery
addLog("✓ Delivered: ${deliveryPacket.messageId.take(16)}...")
scope.launch {
messageRepository?.handleDelivery(deliveryPacket)
}
}
// Обработчик прочтения (0x07)
waitPacket(0x07) { packet ->
val readPacket = packet as PacketRead
addLog("✓✓ Read: ${readPacket.messageId.take(16)}...")
scope.launch {
messageRepository?.handleRead(readPacket)
}
}
// Обработчик typing (0x0B)
waitPacket(0x0B) { packet ->
val typingPacket = packet as PacketTyping
addLog("⌨️ Typing: ${typingPacket.fromPublicKey.take(16)}...")
// Добавляем в set и удаляем через 3 секунды
_typingUsers.value = _typingUsers.value + typingPacket.fromPublicKey
scope.launch {
delay(3000)
_typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey
}
}
}
/** /**
* Get or create Protocol instance * Get or create Protocol instance
*/ */
@@ -79,10 +144,17 @@ object ProtocolManager {
} }
/** /**
* Send packet * Send packet (simplified)
*/
fun send(packet: Packet) {
getProtocol().sendPacket(packet)
}
/**
* Send packet (legacy name)
*/ */
fun sendPacket(packet: Packet) { fun sendPacket(packet: Packet) {
getProtocol().sendPacket(packet) send(packet)
} }
/** /**
@@ -113,6 +185,7 @@ object ProtocolManager {
fun destroy() { fun destroy() {
protocol?.destroy() protocol?.destroy()
protocol = null protocol = null
scope.cancel()
} }
/** /**

View File

@@ -4,6 +4,7 @@ import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -22,6 +23,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -37,18 +39,23 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rosetta.messenger.data.Message
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.DeliveryStatus
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel
import com.rosetta.messenger.ui.components.AppleEmojiTextField import com.rosetta.messenger.ui.components.AppleEmojiTextField
import androidx.compose.ui.text.font.FontFamily
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
/** /**
* Модель сообщения * Модель сообщения (Legacy - для совместимости)
*/ */
data class ChatMessage( data class ChatMessage(
val id: String, val id: String,
@@ -62,6 +69,19 @@ enum class MessageStatus {
SENDING, SENT, DELIVERED, READ SENDING, SENT, DELIVERED, READ
} }
// Extension для конвертации
private fun Message.toChatMessage() = ChatMessage(
id = messageId,
text = content,
isOutgoing = isFromMe,
timestamp = Date(timestamp),
status = when (deliveryStatus) {
DeliveryStatus.WAITING -> MessageStatus.SENDING
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
DeliveryStatus.ERROR -> MessageStatus.SENT
}
)
/** /**
* Экран детального чата с пользователем * Экран детального чата с пользователем
*/ */
@@ -70,12 +90,13 @@ enum class MessageStatus {
fun ChatDetailScreen( fun ChatDetailScreen(
user: SearchUser, user: SearchUser,
currentUserPublicKey: String, currentUserPublicKey: String,
currentUserPrivateKey: String,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onUserProfileClick: () -> Unit = {} onUserProfileClick: () -> Unit = {},
viewModel: ChatViewModel = viewModel()
) { ) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val surfaceColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
@@ -85,28 +106,37 @@ fun ChatDetailScreen(
val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) } val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) }
val chatSubtitle = if (isSavedMessages) "Notes" else if (user.online == 1) "online" else "last seen recently" val chatSubtitle = if (isSavedMessages) "Notes" else if (user.online == 1) "online" else "last seen recently"
// Состояние сообщений // Состояние показа логов
var messages by remember { mutableStateOf<List<ChatMessage>>(emptyList()) } var showLogs by remember { mutableStateOf(false) }
var inputText by remember { mutableStateOf("") } val debugLogs by ProtocolManager.debugLogs.collectAsState()
// Подключаем к ViewModel
val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Инициализируем ViewModel с ключами и открываем диалог
LaunchedEffect(user.publicKey) {
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
viewModel.openDialog(user.publicKey)
}
// Прокрутка при новых сообщениях
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
// Аватар // Аватар
val avatarColors = getAvatarColor( val avatarColors = getAvatarColor(
if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey }, if (isSavedMessages) "SavedMessages" else user.title.ifEmpty { user.publicKey },
isDarkTheme isDarkTheme
) )
// Анимация появления
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
// Логируем открытие чата
LaunchedEffect(user.publicKey) {
ProtocolManager.addLog("💬 Chat opened with: ${user.title.ifEmpty { user.publicKey.take(10) }}")
ProtocolManager.addLog(" PublicKey: ${user.publicKey.take(20)}...")
}
Scaffold( Scaffold(
topBar = { topBar = {
// Кастомный TopAppBar для чата // Кастомный TopAppBar для чата
@@ -198,6 +228,15 @@ fun ChatDetailScreen(
} }
} }
// Кнопка логов (для отладки)
IconButton(onClick = { showLogs = true }) {
Icon(
Icons.Default.BugReport,
contentDescription = "Logs",
tint = if (debugLogs.isNotEmpty()) PrimaryBlue else textColor
)
}
IconButton(onClick = { /* TODO: More options */ }) { IconButton(onClick = { /* TODO: More options */ }) {
Icon( Icon(
Icons.Default.MoreVert, Icons.Default.MoreVert,
@@ -276,30 +315,10 @@ fun ChatDetailScreen(
// Поле ввода сообщения // Поле ввода сообщения
MessageInputBar( MessageInputBar(
value = inputText, value = inputText,
onValueChange = { inputText = it }, onValueChange = { viewModel.updateInputText(it) },
onSend = { onSend = {
if (inputText.isNotBlank()) { viewModel.sendMessage()
val newMessage = ChatMessage( ProtocolManager.addLog("📤 Sending message...")
id = UUID.randomUUID().toString(),
text = inputText.trim(),
isOutgoing = true,
timestamp = Date(),
status = MessageStatus.SENDING
)
messages = messages + newMessage
// Логируем отправку
ProtocolManager.addLog("📤 Message sent: \"${inputText.take(30)}...\"")
inputText = ""
// Прокрутка вниз
scope.launch {
listState.animateScrollToItem(0)
}
// TODO: Отправить через протокол
}
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
backgroundColor = inputBackgroundColor, backgroundColor = inputBackgroundColor,
@@ -308,6 +327,55 @@ fun ChatDetailScreen(
) )
} }
} }
// Диалог логов
if (showLogs) {
AlertDialog(
onDismissRequest = { showLogs = false },
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text("Debug Logs", fontWeight = FontWeight.Bold)
IconButton(onClick = { ProtocolManager.clearLogs() }) {
Icon(Icons.Default.Delete, contentDescription = "Clear")
}
}
},
text = {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
) {
items(debugLogs.reversed()) { log ->
Text(
text = log,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(vertical = 2.dp)
)
}
if (debugLogs.isEmpty()) {
item {
Text(
text = "No logs yet. Try sending a message.",
color = Color.Gray,
fontSize = 12.sp
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showLogs = false }) {
Text("Close")
}
}
)
}
} }
/** /**
@@ -393,9 +461,7 @@ private fun MessageBubble(
/** /**
* Панель ввода сообщения 1:1 как в React Native * Панель ввода сообщения 1:1 как в React Native
* - Слева: круглая кнопка Attach (скрепка) * Оптимизированная версия с правильным позиционированием
* - Посередине: стеклянный инпут с текстом + справа emoji + send
* - Справа: круглая кнопка Mic (уезжает когда есть текст)
*/ */
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
@@ -410,63 +476,71 @@ private fun MessageInputBar(
) { ) {
var showEmojiPicker by remember { mutableStateOf(false) } var showEmojiPicker by remember { mutableStateOf(false) }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val interactionSource = remember { MutableInteractionSource() }
// Цвета как в RN // Цвета
val circleBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f) else Color(0xFFF0F0F0).copy(alpha = 0.85f) val circleBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f) else Color(0xFFF0F0F0).copy(alpha = 0.85f)
val circleBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f) val circleBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
val circleIcon = if (isDarkTheme) Color.White else Color(0xFF333333) val circleIcon = if (isDarkTheme) Color.White else Color(0xFF333333)
val glassBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.9f) else Color(0xFFF0F0F0).copy(alpha = 0.92f) val glassBackground = if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.9f) else Color(0xFFF0F0F0).copy(alpha = 0.92f)
val glassBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f) val glassBorder = if (isDarkTheme) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.1f)
val emojiIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.62f) else Color.Black.copy(alpha = 0.5f) val emojiIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.62f) else Color.Black.copy(alpha = 0.5f)
val panelBackground = if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f) val panelBackground = if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
// === Анимации как в React Native === // Состояние отправки
val canSend = value.isNotBlank() val canSend = remember(value) { value.isNotBlank() }
// Easing functions // Easing
val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f) val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f)
val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f) val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
// Send button animations // Анимации Send
val sendOpacity by animateFloatAsState(
targetValue = if (canSend) 1f else 0f,
animationSpec = tween(200, easing = smoothEasing),
label = "sendOpacity"
)
val sendScale by animateFloatAsState( val sendScale by animateFloatAsState(
targetValue = if (canSend) 1f else 0.5f, targetValue = if (canSend) 1f else 0f,
animationSpec = tween(220, easing = backEasing), animationSpec = tween(220, easing = backEasing),
label = "sendScale" label = "sendScale"
) )
// Mic button animations // Анимации Mic
val micOpacity by animateFloatAsState( val micOpacity by animateFloatAsState(
targetValue = if (canSend) 0f else 1f, targetValue = if (canSend) 0f else 1f,
animationSpec = if (canSend) tween(150, easing = smoothEasing) else tween(200, delayMillis = 100, easing = smoothEasing), animationSpec = tween(200, easing = smoothEasing),
label = "micOpacity" label = "micOpacity"
) )
val micTranslateX by animateFloatAsState( val micTranslateX by animateFloatAsState(
targetValue = if (canSend) 80f else 0f, targetValue = if (canSend) 80f else 0f,
animationSpec = if (canSend) tween(250, easing = smoothEasing) else tween(250, delayMillis = 80, easing = smoothEasing), animationSpec = tween(250, easing = smoothEasing),
label = "micTranslateX" label = "micTranslateX"
) )
// Emoji button animation (сдвигается влево когда появляется send) // Input margin
val emojiTranslateX by animateFloatAsState(
targetValue = if (canSend) -50f else 0f,
animationSpec = tween(220, easing = smoothEasing),
label = "emojiTranslateX"
)
// Input margin animation (расширяется когда текст есть)
val inputEndMargin by animateDpAsState( val inputEndMargin by animateDpAsState(
targetValue = if (canSend) 0.dp else 56.dp, targetValue = if (canSend) 0.dp else 56.dp,
animationSpec = tween(220, easing = smoothEasing), animationSpec = tween(220, easing = smoothEasing),
label = "inputEndMargin" label = "inputEndMargin"
) )
// Функция переключения emoji picker
fun toggleEmojiPicker() {
if (showEmojiPicker) {
showEmojiPicker = false
} else {
// Скрываем клавиатуру и убираем фокус
keyboardController?.hide()
focusManager.clearFocus()
showEmojiPicker = true
}
}
// Функция отправки
fun handleSend() {
if (value.isNotBlank()) {
onSend()
onValueChange("")
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -483,14 +557,17 @@ private fun MessageInputBar(
.padding(horizontal = 14.dp, vertical = 8.dp), .padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
// === ATTACH BUTTON (круг слева) === // ATTACH BUTTON
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(circleBackground) .background(circleBackground)
.border(1.dp, circleBorder, CircleShape) .border(1.dp, circleBorder, CircleShape)
.clickable { /* TODO: Attach */ }, .clickable(
interactionSource = interactionSource,
indication = null
) { /* TODO */ },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -503,89 +580,89 @@ private fun MessageInputBar(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
// === GLASS INPUT (расширяется вправо) === // GLASS INPUT
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(end = inputEndMargin) .padding(end = inputEndMargin)
.heightIn(min = 48.dp, max = 120.dp) .heightIn(min = 48.dp, max = 120.dp)
.clip(RoundedCornerShape(22.dp)) .clip(RoundedCornerShape(24.dp))
.background(glassBackground) .background(glassBackground)
.border(1.dp, glassBorder, RoundedCornerShape(22.dp)) .border(1.dp, glassBorder, RoundedCornerShape(24.dp))
) { ) {
Row( // Text input
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 4.dp), .padding(start = 16.dp, end = 52.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically contentAlignment = Alignment.CenterStart
) { ) {
// Apple Emoji Text Field (с PNG эмодзи) AppleEmojiTextField(
Box( value = value,
modifier = Modifier onValueChange = { newValue ->
.weight(1f) // Закрываем emoji picker при печати с клавиатуры
.padding(top = 8.dp, bottom = 8.dp, end = 70.dp), // место для emoji + send if (showEmojiPicker && newValue.length > value.length) {
contentAlignment = Alignment.CenterStart // Не закрываем - пользователь мог выбрать emoji
) { }
AppleEmojiTextField( onValueChange(newValue)
value = value, },
onValueChange = onValueChange, textColor = textColor,
textColor = textColor, textSize = 16f,
textSize = 16f, hint = "Message",
hint = "Message", hintColor = placeholderColor.copy(alpha = 0.6f),
hintColor = placeholderColor.copy(alpha = 0.6f), modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth() )
)
}
} }
// === RIGHT ZONE (emoji + send) - абсолютная позиция справа внутри инпута === // RIGHT ZONE - emoji или send
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.padding(end = 6.dp) .padding(end = 6.dp)
.size(40.dp)
) { ) {
// Emoji button (сдвигается влево при send) // Emoji button (показывается когда нет текста)
Box( if (!canSend) {
modifier = Modifier Box(
.graphicsLayer { translationX = emojiTranslateX } modifier = Modifier
.size(40.dp) .fillMaxSize()
.clickable { .clickable(
if (showEmojiPicker) { interactionSource = interactionSource,
showEmojiPicker = false indication = null,
} else { onClick = { toggleEmojiPicker() }
keyboardController?.hide() ),
showEmojiPicker = true contentAlignment = Alignment.Center
} ) {
}, Icon(
contentAlignment = Alignment.Center if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions,
) { contentDescription = "Emoji",
Icon( tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor,
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Default.EmojiEmotions, modifier = Modifier.size(24.dp)
contentDescription = "Emoji", )
tint = if (showEmojiPicker) PrimaryBlue else emojiIconColor, }
modifier = Modifier.size(24.dp)
)
} }
// Send button - красивая круглая кнопка с градиентом // Send button (показывается когда есть текст)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize()
.graphicsLayer { .graphicsLayer {
scaleX = sendScale scaleX = sendScale
scaleY = sendScale scaleY = sendScale
alpha = sendOpacity alpha = sendScale
} }
.size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
brush = Brush.linearGradient( brush = Brush.linearGradient(
colors = listOf( colors = listOf(Color(0xFF007AFF), Color(0xFF5AC8FA))
Color(0xFF007AFF),
Color(0xFF5AC8FA)
)
) )
) )
.clickable(enabled = canSend) { onSend() }, .clickable(
interactionSource = interactionSource,
indication = null,
enabled = canSend,
onClick = { handleSend() }
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -599,7 +676,7 @@ private fun MessageInputBar(
} }
} }
// === MIC BUTTON (абсолютная позиция справа, уезжает вправо) === // MIC BUTTON
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
@@ -612,7 +689,11 @@ private fun MessageInputBar(
.clip(CircleShape) .clip(CircleShape)
.background(circleBackground) .background(circleBackground)
.border(1.dp, circleBorder, CircleShape) .border(1.dp, circleBorder, CircleShape)
.clickable(enabled = !canSend) { /* TODO: Voice */ }, .clickable(
interactionSource = interactionSource,
indication = null,
enabled = !canSend
) { /* TODO */ },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -624,20 +705,11 @@ private fun MessageInputBar(
} }
} }
// Apple Emoji Picker с PNG изображениями // Apple Emoji Picker
AnimatedVisibility( AnimatedVisibility(
visible = showEmojiPicker, visible = showEmojiPicker,
enter = expandVertically( enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
expandFrom = Alignment.Top, exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
) + fadeIn(animationSpec = tween(150)),
exit = shrinkVertically(
shrinkTowards = Alignment.Top,
animationSpec = tween(200)
) + fadeOut(animationSpec = tween(100))
) { ) {
AppleEmojiPickerPanel( AppleEmojiPickerPanel(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -648,7 +720,6 @@ private fun MessageInputBar(
) )
} }
// Spacer для navigation bar когда эмодзи пикер НЕ открыт
if (!showEmojiPicker) { if (!showEmojiPicker) {
Spacer(modifier = Modifier.navigationBarsPadding()) Spacer(modifier = Modifier.navigationBarsPadding())
} }

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 { plugins {
id("com.android.application") version "8.2.0" apply false id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false id("org.jetbrains.kotlin.android") version "1.9.20" apply false
id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false
} }
tasks.register("clean", Delete::class) { tasks.register("clean", Delete::class) {

View File

@@ -6,6 +6,9 @@ android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete" # Kotlin code style for this project: "official" or "obsolete"
kotlin.code.style=official kotlin.code.style=official
# Use Java 17 for build
org.gradle.java.home=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home
# Increase heap size for Gradle # Increase heap size for Gradle
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
org.gradle.daemon=true org.gradle.daemon=true