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:
2026-03-01 00:01:01 +05:00
parent 3f2b52b578
commit a0569648e8
28 changed files with 5053 additions and 483 deletions

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

View File

@@ -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 Архиве)