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:
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user