feat: Enhance group chat functionality and UI improvements
- Added support for group action system messages in MessageBubble. - Implemented group invite handling with inline cards for joining groups. - Updated MessageBubble to display group sender labels and admin badges. - Enhanced image decryption logic for group attachments. - Modified BlurredAvatarBackground to load system avatars based on public keys. - Improved SwipeBackContainer with layer management for better swipe effects. - Updated VerifiedBadge to use dynamic icons based on user verification status. - Added new drawable resource for admin badge icon.
This commit is contained in:
431
app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt
Normal file
431
app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt
Normal file
@@ -0,0 +1,431 @@
|
||||
package com.rosetta.messenger.data
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.database.GroupEntity
|
||||
import com.rosetta.messenger.database.MessageEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.GroupStatus
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketCreateGroup
|
||||
import com.rosetta.messenger.network.PacketGroupBan
|
||||
import com.rosetta.messenger.network.PacketGroupInfo
|
||||
import com.rosetta.messenger.network.PacketGroupInviteInfo
|
||||
import com.rosetta.messenger.network.PacketGroupJoin
|
||||
import com.rosetta.messenger.network.PacketGroupLeave
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class GroupRepository private constructor(context: Context) {
|
||||
|
||||
private val db = RosettaDatabase.getDatabase(context.applicationContext)
|
||||
private val groupDao = db.groupDao()
|
||||
private val messageDao = db.messageDao()
|
||||
private val dialogDao = db.dialogDao()
|
||||
|
||||
companion object {
|
||||
private const val GROUP_PREFIX = "#group:"
|
||||
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
||||
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: GroupRepository? = null
|
||||
|
||||
fun getInstance(context: Context): GroupRepository {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: GroupRepository(context).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ParsedGroupInvite(
|
||||
val groupId: String,
|
||||
val title: String,
|
||||
val encryptKey: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class GroupJoinResult(
|
||||
val success: Boolean,
|
||||
val dialogPublicKey: String? = null,
|
||||
val title: String = "",
|
||||
val status: GroupStatus = GroupStatus.NOT_JOINED,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class GroupInviteInfoResult(
|
||||
val groupId: String,
|
||||
val membersCount: Int,
|
||||
val status: GroupStatus
|
||||
)
|
||||
|
||||
fun isGroupKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase()
|
||||
return normalized.startsWith(GROUP_PREFIX) || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
fun normalizeGroupId(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
return when {
|
||||
trimmed.startsWith(GROUP_PREFIX) -> trimmed.removePrefix(GROUP_PREFIX).trim()
|
||||
trimmed.startsWith("group:", ignoreCase = true) ->
|
||||
trimmed.substringAfter(':').trim()
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
fun toGroupDialogPublicKey(groupId: String): String = "$GROUP_PREFIX${normalizeGroupId(groupId)}"
|
||||
|
||||
fun constructInviteString(
|
||||
groupId: String,
|
||||
title: String,
|
||||
encryptKey: String,
|
||||
description: String = ""
|
||||
): String {
|
||||
val normalizedGroupId = normalizeGroupId(groupId)
|
||||
if (normalizedGroupId.isBlank() || title.isBlank() || encryptKey.isBlank()) {
|
||||
return ""
|
||||
}
|
||||
val payload = buildString {
|
||||
append(normalizedGroupId)
|
||||
append(':')
|
||||
append(title)
|
||||
append(':')
|
||||
append(encryptKey)
|
||||
if (description.isNotBlank()) {
|
||||
append(':')
|
||||
append(description)
|
||||
}
|
||||
}
|
||||
val encoded = CryptoManager.encryptWithPassword(payload, GROUP_INVITE_PASSWORD)
|
||||
return "$GROUP_PREFIX$encoded"
|
||||
}
|
||||
|
||||
fun parseInviteString(inviteString: String): ParsedGroupInvite? {
|
||||
if (!inviteString.trim().startsWith(GROUP_PREFIX)) return null
|
||||
val encodedPayload = inviteString.trim().removePrefix(GROUP_PREFIX)
|
||||
if (encodedPayload.isBlank()) return null
|
||||
|
||||
val decodedPayload =
|
||||
CryptoManager.decryptWithPassword(encodedPayload, GROUP_INVITE_PASSWORD) ?: return null
|
||||
val parts = decodedPayload.split(':')
|
||||
if (parts.size < 3) return null
|
||||
|
||||
return ParsedGroupInvite(
|
||||
groupId = normalizeGroupId(parts[0]),
|
||||
title = parts[1],
|
||||
encryptKey = parts[2],
|
||||
description = parts.drop(3).joinToString(":")
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getGroupKey(
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
groupPublicKeyOrId: String
|
||||
): String? {
|
||||
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
||||
if (groupId.isBlank()) return null
|
||||
val stored = groupDao.getGroup(accountPublicKey, groupId) ?: return null
|
||||
return CryptoManager.decryptWithPassword(stored.key, accountPrivateKey)
|
||||
}
|
||||
|
||||
suspend fun getGroup(accountPublicKey: String, groupPublicKeyOrId: String): GroupEntity? {
|
||||
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
||||
if (groupId.isBlank()) return null
|
||||
return groupDao.getGroup(accountPublicKey, groupId)
|
||||
}
|
||||
|
||||
suspend fun requestGroupMembers(groupPublicKeyOrId: String): List<String>? {
|
||||
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
||||
if (groupId.isBlank()) return null
|
||||
|
||||
val packet = PacketGroupInfo().apply {
|
||||
this.groupId = groupId
|
||||
this.members = emptyList()
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupInfo>(
|
||||
packetId = 0x12,
|
||||
timeoutMs = GROUP_WAIT_TIMEOUT_MS
|
||||
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
|
||||
?: return null
|
||||
|
||||
return response.members
|
||||
}
|
||||
|
||||
suspend fun requestInviteInfo(groupPublicKeyOrId: String): GroupInviteInfoResult? {
|
||||
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
||||
if (groupId.isBlank()) return null
|
||||
|
||||
val packet = PacketGroupInviteInfo().apply {
|
||||
this.groupId = groupId
|
||||
this.membersCount = 0
|
||||
this.groupStatus = GroupStatus.NOT_JOINED
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupInviteInfo>(
|
||||
packetId = 0x13,
|
||||
timeoutMs = GROUP_WAIT_TIMEOUT_MS
|
||||
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
|
||||
?: return null
|
||||
|
||||
return GroupInviteInfoResult(
|
||||
groupId = groupId,
|
||||
membersCount = response.membersCount.coerceAtLeast(0),
|
||||
status = response.groupStatus
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun createGroup(
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
title: String,
|
||||
description: String = ""
|
||||
): GroupJoinResult {
|
||||
if (title.isBlank()) {
|
||||
return GroupJoinResult(success = false, error = "Title is empty")
|
||||
}
|
||||
|
||||
val createPacket = PacketCreateGroup()
|
||||
ProtocolManager.send(createPacket)
|
||||
|
||||
val response = awaitPacketOnce<PacketCreateGroup>(
|
||||
packetId = 0x11,
|
||||
timeoutMs = GROUP_WAIT_TIMEOUT_MS
|
||||
) { packet -> packet.groupId.isNotBlank() }
|
||||
?: return GroupJoinResult(success = false, error = "Create group timeout")
|
||||
|
||||
val groupId = normalizeGroupId(response.groupId)
|
||||
if (groupId.isBlank()) {
|
||||
return GroupJoinResult(success = false, error = "Server returned empty group id")
|
||||
}
|
||||
|
||||
val groupKey = generateGroupKey()
|
||||
val invite = constructInviteString(groupId, title.trim(), groupKey, description.trim())
|
||||
if (invite.isBlank()) {
|
||||
return GroupJoinResult(success = false, error = "Failed to construct invite")
|
||||
}
|
||||
|
||||
return joinGroup(accountPublicKey, accountPrivateKey, invite)
|
||||
}
|
||||
|
||||
suspend fun joinGroup(
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
inviteString: String
|
||||
): GroupJoinResult {
|
||||
val parsed = parseInviteString(inviteString)
|
||||
?: return GroupJoinResult(success = false, error = "Invalid invite string")
|
||||
|
||||
val encodedGroupStringForServer =
|
||||
CryptoManager.encryptWithPassword(inviteString, accountPrivateKey)
|
||||
|
||||
val packet = PacketGroupJoin().apply {
|
||||
groupId = parsed.groupId
|
||||
groupString = encodedGroupStringForServer
|
||||
groupStatus = GroupStatus.NOT_JOINED
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupJoin>(
|
||||
packetId = 0x14,
|
||||
timeoutMs = GROUP_WAIT_TIMEOUT_MS
|
||||
) { incoming -> normalizeGroupId(incoming.groupId) == parsed.groupId }
|
||||
?: return GroupJoinResult(success = false, error = "Join group timeout")
|
||||
|
||||
if (response.groupStatus != GroupStatus.JOINED) {
|
||||
return GroupJoinResult(
|
||||
success = false,
|
||||
status = response.groupStatus,
|
||||
title = parsed.title,
|
||||
dialogPublicKey = toGroupDialogPublicKey(parsed.groupId),
|
||||
error = "Join rejected"
|
||||
)
|
||||
}
|
||||
|
||||
persistJoinedGroup(
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
parsedInvite = parsed,
|
||||
emitSystemJoinMessage = true
|
||||
)
|
||||
|
||||
return GroupJoinResult(
|
||||
success = true,
|
||||
status = GroupStatus.JOINED,
|
||||
dialogPublicKey = toGroupDialogPublicKey(parsed.groupId),
|
||||
title = parsed.title
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun synchronizeJoinedGroup(
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
packet: PacketGroupJoin
|
||||
): GroupJoinResult? {
|
||||
if (packet.groupStatus != GroupStatus.JOINED) return null
|
||||
if (packet.groupString.isBlank()) return null
|
||||
|
||||
val decryptedInvite =
|
||||
CryptoManager.decryptWithPassword(packet.groupString, accountPrivateKey) ?: return null
|
||||
val parsed = parseInviteString(decryptedInvite) ?: return null
|
||||
|
||||
persistJoinedGroup(
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
parsedInvite = parsed,
|
||||
emitSystemJoinMessage = false
|
||||
)
|
||||
|
||||
return GroupJoinResult(
|
||||
success = true,
|
||||
status = GroupStatus.JOINED,
|
||||
dialogPublicKey = toGroupDialogPublicKey(parsed.groupId),
|
||||
title = parsed.title
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun leaveGroup(accountPublicKey: String, groupPublicKeyOrId: String): Boolean {
|
||||
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
||||
if (groupId.isBlank()) return false
|
||||
|
||||
val packet = PacketGroupLeave().apply {
|
||||
this.groupId = groupId
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupLeave>(
|
||||
packetId = 0x15,
|
||||
timeoutMs = GROUP_WAIT_TIMEOUT_MS
|
||||
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
|
||||
?: return false
|
||||
|
||||
if (normalizeGroupId(response.groupId) != groupId) return false
|
||||
|
||||
val groupDialogKey = toGroupDialogPublicKey(groupId)
|
||||
groupDao.deleteGroup(accountPublicKey, groupId)
|
||||
messageDao.deleteDialog(accountPublicKey, groupDialogKey)
|
||||
dialogDao.deleteDialog(accountPublicKey, groupDialogKey)
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun kickMember(groupPublicKeyOrId: String, memberPublicKey: String): Boolean {
|
||||
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
||||
val targetPublicKey = memberPublicKey.trim()
|
||||
if (groupId.isBlank() || targetPublicKey.isBlank()) return false
|
||||
|
||||
val packet = PacketGroupBan().apply {
|
||||
this.groupId = groupId
|
||||
this.publicKey = targetPublicKey
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupBan>(
|
||||
packetId = 0x16,
|
||||
timeoutMs = GROUP_WAIT_TIMEOUT_MS
|
||||
) { incoming ->
|
||||
normalizeGroupId(incoming.groupId) == groupId &&
|
||||
incoming.publicKey.trim().equals(targetPublicKey, ignoreCase = true)
|
||||
} ?: return false
|
||||
|
||||
return normalizeGroupId(response.groupId) == groupId &&
|
||||
response.publicKey.trim().equals(targetPublicKey, ignoreCase = true)
|
||||
}
|
||||
|
||||
private suspend fun persistJoinedGroup(
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
parsedInvite: ParsedGroupInvite,
|
||||
emitSystemJoinMessage: Boolean
|
||||
) {
|
||||
val encryptedGroupKey =
|
||||
CryptoManager.encryptWithPassword(parsedInvite.encryptKey, accountPrivateKey)
|
||||
|
||||
groupDao.insertGroup(
|
||||
GroupEntity(
|
||||
account = accountPublicKey,
|
||||
groupId = parsedInvite.groupId,
|
||||
title = parsedInvite.title,
|
||||
description = parsedInvite.description,
|
||||
key = encryptedGroupKey
|
||||
)
|
||||
)
|
||||
|
||||
val dialogPublicKey = toGroupDialogPublicKey(parsedInvite.groupId)
|
||||
|
||||
if (emitSystemJoinMessage) {
|
||||
val joinText = "\$a=Group joined"
|
||||
val encryptedPlainMessage = CryptoManager.encryptWithPassword(joinText, accountPrivateKey)
|
||||
val encryptedContent = CryptoManager.encryptWithPassword(joinText, parsedInvite.encryptKey)
|
||||
|
||||
messageDao.insertMessage(
|
||||
MessageEntity(
|
||||
account = accountPublicKey,
|
||||
fromPublicKey = accountPublicKey,
|
||||
toPublicKey = dialogPublicKey,
|
||||
content = encryptedContent,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
chachaKey = buildStoredGroupKey(parsedInvite.encryptKey, accountPrivateKey),
|
||||
read = 1,
|
||||
fromMe = 1,
|
||||
delivered = 1,
|
||||
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
dialogKey = dialogPublicKey
|
||||
)
|
||||
)
|
||||
|
||||
dialogDao.updateDialogFromMessages(accountPublicKey, dialogPublicKey)
|
||||
}
|
||||
|
||||
// Ensure title is present after updateDialogFromMessages for list/header rendering.
|
||||
dialogDao.updateOpponentDisplayName(
|
||||
accountPublicKey,
|
||||
dialogPublicKey,
|
||||
parsedInvite.title,
|
||||
parsedInvite.description
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
||||
val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey)
|
||||
return "group:$encrypted"
|
||||
}
|
||||
|
||||
private fun generateGroupKey(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : Packet> awaitPacketOnce(
|
||||
packetId: Int,
|
||||
timeoutMs: Long,
|
||||
crossinline predicate: (T) -> Boolean = { true }
|
||||
): T? {
|
||||
return withTimeoutOrNull(timeoutMs) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
lateinit var callback: (Packet) -> Unit
|
||||
callback = { packet ->
|
||||
val typedPacket = packet as? T
|
||||
if (typedPacket != null && predicate(typedPacket)) {
|
||||
ProtocolManager.unwaitPacket(packetId, callback)
|
||||
continuation.resume(typedPacket)
|
||||
}
|
||||
}
|
||||
ProtocolManager.waitPacket(packetId, callback)
|
||||
continuation.invokeOnCancellation {
|
||||
ProtocolManager.unwaitPacket(packetId, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private val dialogDao = database.dialogDao()
|
||||
private val avatarDao = database.avatarDao()
|
||||
private val syncTimeDao = database.syncTimeDao()
|
||||
private val groupDao = database.groupDao()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -365,6 +366,23 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return currentAccount != null && currentPrivateKey != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear active account binding for this process session.
|
||||
* Must be called on explicit logout/account switch so stale account context
|
||||
* cannot be reused after reconnect.
|
||||
*/
|
||||
fun clearInitialization() {
|
||||
currentAccount = null
|
||||
currentPrivateKey = null
|
||||
requestedUserInfoKeys.clear()
|
||||
messageCache.clear()
|
||||
clearProcessedCache()
|
||||
}
|
||||
|
||||
fun getCurrentAccountKey(): String? = currentAccount
|
||||
|
||||
fun getCurrentPrivateKey(): String? = currentPrivateKey
|
||||
|
||||
suspend fun getLastSyncTimestamp(): Long {
|
||||
val account = currentAccount ?: return 0L
|
||||
val stored = syncTimeDao.getLastSync(account) ?: 0L
|
||||
@@ -647,9 +665,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
)
|
||||
|
||||
val isOwnMessage = packet.fromPublicKey == account
|
||||
val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
|
||||
|
||||
// 🔥 Проверяем, не заблокирован ли отправитель
|
||||
if (!isOwnMessage) {
|
||||
if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
|
||||
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
|
||||
if (isBlocked) {
|
||||
MessageLogger.logBlockedSender(packet.fromPublicKey)
|
||||
@@ -688,25 +707,51 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return true
|
||||
}
|
||||
|
||||
val dialogOpponentKey = if (isOwnMessage) packet.toPublicKey else packet.fromPublicKey
|
||||
val dialogOpponentKey =
|
||||
when {
|
||||
isGroupMessage -> packet.toPublicKey
|
||||
isOwnMessage -> packet.toPublicKey
|
||||
else -> packet.fromPublicKey
|
||||
}
|
||||
val dialogKey = getDialogKey(dialogOpponentKey)
|
||||
|
||||
try {
|
||||
val groupKey =
|
||||
if (isGroupMessage) {
|
||||
val fromPacket =
|
||||
if (packet.aesChachaKey.isNotBlank()) {
|
||||
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
fromPacket ?: resolveGroupKeyForDialog(account, privateKey, packet.toPublicKey)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (isGroupMessage && groupKey.isNullOrBlank()) {
|
||||
MessageLogger.debug(
|
||||
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
|
||||
)
|
||||
processedMessageIds.remove(messageId)
|
||||
return false
|
||||
}
|
||||
|
||||
val plainKeyAndNonce =
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
|
||||
?.toByteArray(Charsets.ISO_8859_1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
||||
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
||||
ProtocolManager.addLog(
|
||||
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
|
||||
)
|
||||
}
|
||||
|
||||
if (isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
|
||||
if (!isGroupMessage && isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
|
||||
MessageLogger.debug(
|
||||
"📥 OWN SYNC fallback: aesChachaKey is missing, trying chachaKey decrypt"
|
||||
)
|
||||
@@ -714,7 +759,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// Расшифровываем
|
||||
val plainText =
|
||||
if (plainKeyAndNonce != null) {
|
||||
if (isGroupMessage) {
|
||||
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
|
||||
?: throw IllegalStateException("Failed to decrypt group payload")
|
||||
} else if (plainKeyAndNonce != null) {
|
||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||
} else {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
@@ -733,7 +781,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
packet.attachments,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
plainKeyAndNonce,
|
||||
groupKey
|
||||
)
|
||||
|
||||
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
|
||||
@@ -742,7 +791,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce,
|
||||
messageId
|
||||
messageId,
|
||||
groupKey
|
||||
)
|
||||
|
||||
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||
@@ -751,14 +801,17 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
packet.fromPublicKey,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
plainKeyAndNonce,
|
||||
groupKey
|
||||
)
|
||||
|
||||
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
||||
|
||||
val storedChachaKey =
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
if (isGroupMessage) {
|
||||
buildStoredGroupKey(groupKey!!, privateKey)
|
||||
} else if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
"sync:${packet.aesChachaKey}"
|
||||
} else {
|
||||
packet.chachaKey
|
||||
@@ -807,8 +860,19 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
||||
// Desktop parity: always re-fetch on incoming message so renamed contacts
|
||||
// get their new name/username updated in the chat list.
|
||||
requestedUserInfoKeys.remove(dialogOpponentKey)
|
||||
requestUserInfo(dialogOpponentKey)
|
||||
if (!isGroupDialogKey(dialogOpponentKey)) {
|
||||
requestedUserInfoKeys.remove(dialogOpponentKey)
|
||||
requestUserInfo(dialogOpponentKey)
|
||||
} else {
|
||||
applyGroupDisplayNameToDialog(account, dialogOpponentKey)
|
||||
val senderKey = packet.fromPublicKey.trim()
|
||||
if (senderKey.isNotBlank() &&
|
||||
senderKey != account &&
|
||||
!isGroupDialogKey(senderKey)
|
||||
) {
|
||||
requestUserInfo(senderKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем кэш только если сообщение новое
|
||||
if (!stillExists) {
|
||||
@@ -889,6 +953,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 1) from=opponent, to=account -> собеседник прочитал НАШИ сообщения (double check)
|
||||
// 2) from=account, to=opponent -> sync с другого нашего устройства (мы прочитали входящие)
|
||||
val isOwnReadSync = fromPublicKey == account
|
||||
if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) {
|
||||
// Group read receipts are currently not mapped to per-message states.
|
||||
return
|
||||
}
|
||||
val opponentKey = if (isOwnReadSync) toPublicKey else fromPublicKey
|
||||
if (opponentKey.isBlank()) return
|
||||
|
||||
@@ -1038,6 +1106,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// We can re-send the PacketMessage directly using stored fields.
|
||||
val aesChachaKeyValue = if (entity.chachaKey.startsWith("sync:")) {
|
||||
entity.chachaKey.removePrefix("sync:")
|
||||
} else if (entity.chachaKey.startsWith("group:")) {
|
||||
entity.chachaKey.removePrefix("group:")
|
||||
} else {
|
||||
// Re-generate aesChachaKey from the stored chachaKey + privateKey.
|
||||
// The chachaKey in DB is the ECC-encrypted key for the recipient.
|
||||
@@ -1058,7 +1128,15 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.fromPublicKey = account
|
||||
this.toPublicKey = entity.toPublicKey
|
||||
this.content = entity.content
|
||||
this.chachaKey = if (entity.chachaKey.startsWith("sync:")) "" else entity.chachaKey
|
||||
this.chachaKey =
|
||||
if (
|
||||
entity.chachaKey.startsWith("sync:") ||
|
||||
entity.chachaKey.startsWith("group:")
|
||||
) {
|
||||
""
|
||||
} else {
|
||||
entity.chachaKey
|
||||
}
|
||||
this.aesChachaKey = aesChachaKeyValue
|
||||
this.timestamp = entity.timestamp
|
||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
@@ -1088,6 +1166,9 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
*/
|
||||
private fun getDialogKey(opponentKey: String): String {
|
||||
val account = currentAccount ?: return opponentKey
|
||||
if (isGroupDialogKey(opponentKey)) {
|
||||
return opponentKey.trim()
|
||||
}
|
||||
// Для saved messages dialog_key = просто publicKey
|
||||
if (account == opponentKey) {
|
||||
return account
|
||||
@@ -1096,6 +1177,54 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return if (account < opponentKey) "$account:$opponentKey" else "$opponentKey:$account"
|
||||
}
|
||||
|
||||
private fun isGroupDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase()
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun normalizeGroupId(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
return when {
|
||||
trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
|
||||
trimmed.startsWith("group:", ignoreCase = true) -> trimmed.substringAfter(':').trim()
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
||||
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
|
||||
}
|
||||
|
||||
private fun decodeStoredGroupKey(storedKey: String, privateKey: String): String? {
|
||||
if (!storedKey.startsWith("group:")) return null
|
||||
val encoded = storedKey.removePrefix("group:")
|
||||
if (encoded.isBlank()) return null
|
||||
return CryptoManager.decryptWithPassword(encoded, privateKey)
|
||||
}
|
||||
|
||||
private suspend fun resolveGroupKeyForDialog(
|
||||
account: String,
|
||||
privateKey: String,
|
||||
groupDialogPublicKey: String
|
||||
): String? {
|
||||
val groupId = normalizeGroupId(groupDialogPublicKey)
|
||||
if (groupId.isBlank()) return null
|
||||
val group = groupDao.getGroup(account, groupId) ?: return null
|
||||
return CryptoManager.decryptWithPassword(group.key, privateKey)
|
||||
}
|
||||
|
||||
private suspend fun applyGroupDisplayNameToDialog(account: String, dialogKey: String) {
|
||||
val groupId = normalizeGroupId(dialogKey)
|
||||
if (groupId.isBlank()) return
|
||||
val group = groupDao.getGroup(account, groupId) ?: return
|
||||
dialogDao.updateOpponentDisplayName(
|
||||
account = account,
|
||||
opponentKey = dialogKey,
|
||||
title = group.title,
|
||||
username = group.description
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateMessageCache(dialogKey: String, message: Message) {
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
val currentList = flow.value.toMutableList()
|
||||
@@ -1252,6 +1381,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
for (dialog in dialogs) {
|
||||
// Skip self (Saved Messages)
|
||||
if (dialog.opponentKey == account) continue
|
||||
if (isGroupDialogKey(dialog.opponentKey)) continue
|
||||
// Skip if already requested in this cycle
|
||||
if (requestedUserInfoKeys.contains(dialog.opponentKey)) continue
|
||||
requestedUserInfoKeys.add(dialog.opponentKey)
|
||||
@@ -1271,6 +1401,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
* Use when opening a dialog to ensure the name/username is fresh.
|
||||
*/
|
||||
fun forceRequestUserInfo(publicKey: String) {
|
||||
if (isGroupDialogKey(publicKey)) return
|
||||
requestedUserInfoKeys.remove(publicKey)
|
||||
requestUserInfo(publicKey)
|
||||
}
|
||||
@@ -1281,6 +1412,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
*/
|
||||
fun requestUserInfo(publicKey: String) {
|
||||
val privateKey = currentPrivateKey ?: return
|
||||
if (isGroupDialogKey(publicKey)) return
|
||||
|
||||
// 🔥 Не запрашиваем если уже запрашивали
|
||||
if (requestedUserInfoKeys.contains(publicKey)) {
|
||||
@@ -1396,7 +1528,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
fromPublicKey: String,
|
||||
encryptedKey: String,
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
plainKeyAndNonce: ByteArray? = null,
|
||||
groupKey: String? = null
|
||||
) {
|
||||
|
||||
for (attachment in attachments) {
|
||||
@@ -1406,14 +1539,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
if (groupKey != null) {
|
||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
||||
} else {
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем аватар в кэш
|
||||
@@ -1446,7 +1583,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
encryptedKey: String,
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null,
|
||||
messageId: String = ""
|
||||
messageId: String = "",
|
||||
groupKey: String? = null
|
||||
) {
|
||||
val publicKey = currentAccount ?: return
|
||||
|
||||
@@ -1458,14 +1596,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
if (groupKey != null) {
|
||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
||||
} else {
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем в файл (как в desktop)
|
||||
@@ -1503,7 +1645,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
attachments: List<MessageAttachment>,
|
||||
encryptedKey: String,
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
plainKeyAndNonce: ByteArray? = null,
|
||||
groupKey: String? = null
|
||||
): String {
|
||||
if (attachments.isEmpty()) return "[]"
|
||||
|
||||
@@ -1517,14 +1660,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
try {
|
||||
// 1. Расшифровываем с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
if (groupKey != null) {
|
||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
||||
} else {
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)
|
||||
|
||||
Reference in New Issue
Block a user