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

@@ -74,6 +74,7 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
@@ -94,11 +95,13 @@ import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
import com.rosetta.messenger.utils.MediaUtils
import java.text.SimpleDateFormat
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(
ExperimentalMaterial3Api::class,
@@ -112,6 +115,7 @@ fun ChatDetailScreen(
onBack: () -> Unit,
onNavigateToChat: (SearchUser) -> Unit,
onUserProfileClick: (SearchUser) -> Unit = {},
onGroupInfoClick: (SearchUser) -> Unit = {},
currentUserPublicKey: String,
currentUserPrivateKey: String,
currentUserName: String = "",
@@ -183,6 +187,7 @@ fun ChatDetailScreen(
// 📌 PINNED MESSAGES
val pinnedMessages by viewModel.pinnedMessages.collectAsState()
val pinnedMessagePreviews by viewModel.pinnedMessagePreviews.collectAsState()
val currentPinnedIndex by viewModel.currentPinnedIndex.collectAsState()
var isPinnedBannerDismissed by remember { mutableStateOf(false) }
@@ -210,10 +215,23 @@ fun ChatDetailScreen(
// Определяем это Saved Messages или обычный чат
val isSavedMessages = user.publicKey == currentUserPublicKey
val isGroupChat = user.publicKey.trim().startsWith("#group:")
val chatTitle =
if (isSavedMessages) "Saved Messages"
else user.title.ifEmpty { user.publicKey.take(10) }
val openDialogInfo: () -> Unit = {
val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
if (isGroupChat) {
onGroupInfoClick(user)
} else {
onUserProfileClick(user)
}
}
// 📨 Forward: показывать ли выбор чата
var showForwardPicker by remember { mutableStateOf(false) }
@@ -393,6 +411,10 @@ fun ChatDetailScreen(
// 📨 Forward: список диалогов для выбора (загружаем из базы)
val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember { GroupRepository.getInstance(context) }
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet())
}
// 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов
LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) {
@@ -401,6 +423,20 @@ fun ChatDetailScreen(
}
}
LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) {
if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) {
groupAdminKeys = emptySet()
return@LaunchedEffect
}
val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey).orEmpty()
}
val adminKey = members.firstOrNull().orEmpty()
groupAdminKeys =
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
}
// Состояние выпадающего меню
var showMenu by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) }
@@ -503,12 +539,15 @@ fun ChatDetailScreen(
}
// <20> Текст текущего pinned сообщения для баннера
val currentPinnedMessagePreview = remember(pinnedMessages, currentPinnedIndex, messages) {
val currentPinnedMessagePreview =
remember(pinnedMessages, currentPinnedIndex, messages, pinnedMessagePreviews) {
if (pinnedMessages.isEmpty()) ""
else {
val idx = currentPinnedIndex.coerceIn(0, pinnedMessages.size - 1)
val pinnedMsgId = pinnedMessages[idx].messageId
messages.find { it.id == pinnedMsgId }?.text ?: "..."
pinnedMessagePreviews[pinnedMsgId]
?: messages.find { it.id == pinnedMsgId }?.text
?: "Pinned message"
}
}
@@ -531,8 +570,20 @@ fun ChatDetailScreen(
delay(50) // Небольшая задержка для сброса анимации
// Находим индекс сообщения в списке
val messageIndex =
messagesWithDates.indexOfFirst { it.first.id == messageId }
var messageIndex = messagesWithDates.indexOfFirst { it.first.id == messageId }
if (messageIndex == -1) {
val loaded = viewModel.ensureMessageLoaded(messageId)
if (loaded) {
for (attempt in 0 until 8) {
delay(16)
messageIndex =
viewModel.messagesWithDates.value.indexOfFirst {
it.first.id == messageId
}
if (messageIndex != -1) break
}
}
}
if (messageIndex != -1) {
// Скроллим к сообщению
listState.animateScrollToItem(messageIndex)
@@ -553,6 +604,7 @@ fun ChatDetailScreen(
val chatSubtitle =
when {
isSavedMessages -> "Notes"
isGroupChat -> "group"
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
isOnline -> "online"
isSystemAccount -> "official account"
@@ -604,7 +656,7 @@ fun ChatDetailScreen(
com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey)
// Подписываемся на онлайн статус собеседника
if (!isSavedMessages) {
if (!isSavedMessages && !isGroupChat) {
viewModel.subscribeToOnlineStatus()
}
// 🔥 Предзагружаем эмодзи в фоне
@@ -944,8 +996,7 @@ fun ChatDetailScreen(
modifier =
Modifier.size(40.dp)
.then(
if (!isSavedMessages
) {
if (!isSavedMessages) {
Modifier
.clickable(
indication =
@@ -955,21 +1006,7 @@ fun ChatDetailScreen(
MutableInteractionSource()
}
) {
// Мгновенное закрытие клавиатуры через нативный API
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager
.clearFocus()
onUserProfileClick(
user
)
openDialogInfo()
}
} else
Modifier
@@ -1034,8 +1071,7 @@ fun ChatDetailScreen(
modifier =
Modifier.weight(1f)
.then(
if (!isSavedMessages
) {
if (!isSavedMessages) {
Modifier
.clickable(
indication =
@@ -1045,21 +1081,7 @@ fun ChatDetailScreen(
MutableInteractionSource()
}
) {
// Мгновенное закрытие клавиатуры через нативный API
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager
.clearFocus()
onUserProfileClick(
user
)
openDialogInfo()
}
} else
Modifier
@@ -1087,6 +1109,7 @@ fun ChatDetailScreen(
.Ellipsis
)
if (!isSavedMessages &&
!isGroupChat &&
(user.verified >
0 || isRosettaOfficial)
) {
@@ -1146,6 +1169,7 @@ fun ChatDetailScreen(
}
// Кнопки действий
if (!isSavedMessages &&
!isGroupChat &&
!isSystemAccount
) {
IconButton(
@@ -1209,10 +1233,32 @@ fun ChatDetailScreen(
isDarkTheme,
isSavedMessages =
isSavedMessages,
isGroupChat =
isGroupChat,
isSystemAccount =
isSystemAccount,
isBlocked =
isBlocked,
onGroupInfoClick = {
showMenu =
false
onGroupInfoClick(
user
)
},
onSearchMembersClick = {
showMenu =
false
onGroupInfoClick(
user
)
},
onLeaveGroupClick = {
showMenu =
false
showDeleteConfirm =
true
},
onBlockClick = {
showMenu =
false
@@ -2016,6 +2062,14 @@ fun ChatDetailScreen(
}
val selectionKey =
message.id
val senderPublicKeyForMessage =
if (message.senderPublicKey.isNotBlank()) {
message.senderPublicKey
} else if (message.isOutgoing) {
currentUserPublicKey
} else {
user.publicKey
}
MessageBubble(
message =
message,
@@ -2042,11 +2096,20 @@ fun ChatDetailScreen(
privateKey =
currentUserPrivateKey,
senderPublicKey =
if (message.isOutgoing
)
currentUserPublicKey
else
user.publicKey,
senderPublicKeyForMessage,
senderName =
message.senderName,
showGroupSenderLabel =
isGroupChat &&
!message.isOutgoing,
isGroupSenderAdmin =
isGroupChat &&
senderPublicKeyForMessage
.isNotBlank() &&
groupAdminKeys
.contains(
senderPublicKeyForMessage
),
currentUserPublicKey =
currentUserPublicKey,
avatarRepository =
@@ -2195,6 +2258,9 @@ fun ChatDetailScreen(
}
}
},
onGroupInviteOpen = { inviteGroup ->
onNavigateToChat(inviteGroup)
},
contextMenuContent = {
// 💬 Context menu anchored to this bubble
if (showContextMenu && contextMenuMessage?.id == message.id) {
@@ -2444,15 +2510,24 @@ fun ChatDetailScreen(
// Диалог подтверждения удаления чата
if (showDeleteConfirm) {
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text("Delete Chat", fontWeight = FontWeight.Bold, color = textColor)
Text(
if (isLeaveGroupDialog) "Leave Group" else "Delete Chat",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"Are you sure you want to delete this chat? This action cannot be undone.",
if (isLeaveGroupDialog) {
"Are you sure you want to leave this group?"
} else {
"Are you sure you want to delete this chat? This action cannot be undone."
},
color = secondaryTextColor
)
},
@@ -2462,60 +2537,79 @@ fun ChatDetailScreen(
showDeleteConfirm = false
scope.launch {
try {
// Вычисляем правильный dialog_key
val dialogKey =
if (currentUserPublicKey <
if (isLeaveGroupDialog) {
GroupRepository
.getInstance(
context
)
.leaveGroup(
currentUserPublicKey,
user.publicKey
) {
"$currentUserPublicKey:${user.publicKey}"
} else {
"${user.publicKey}:$currentUserPublicKey"
}
)
} else {
// Вычисляем правильный dialog_key
val dialogKey =
if (currentUserPublicKey <
user.publicKey
) {
"$currentUserPublicKey:${user.publicKey}"
} else {
"${user.publicKey}:$currentUserPublicKey"
}
// 🗑️ Очищаем ВСЕ кэши сообщений
com.rosetta.messenger.data
.MessageRepository
.getInstance(context)
.clearDialogCache(
user.publicKey
)
ChatViewModel.clearCacheForOpponent(
user.publicKey
)
// Удаляем все сообщения из диалога
database.messageDao()
.deleteDialog(
account =
currentUserPublicKey,
dialogKey =
dialogKey
)
database.messageDao()
.deleteMessagesBetweenUsers(
account =
currentUserPublicKey,
user1 =
user.publicKey,
user2 =
currentUserPublicKey
)
// Очищаем кеш диалога
database.dialogDao()
.deleteDialog(
account =
currentUserPublicKey,
opponentKey =
// 🗑️ Очищаем ВСЕ кэши сообщений
com.rosetta.messenger.data
.MessageRepository
.getInstance(
context
)
.clearDialogCache(
user.publicKey
)
)
ChatViewModel
.clearCacheForOpponent(
user.publicKey
)
// Удаляем все сообщения из диалога
database.messageDao()
.deleteDialog(
account =
currentUserPublicKey,
dialogKey =
dialogKey
)
database.messageDao()
.deleteMessagesBetweenUsers(
account =
currentUserPublicKey,
user1 =
user.publicKey,
user2 =
currentUserPublicKey
)
// Очищаем кеш диалога
database.dialogDao()
.deleteDialog(
account =
currentUserPublicKey,
opponentKey =
user.publicKey
)
}
} catch (e: Exception) {
// Error deleting chat
}
hideKeyboardAndBack()
}
}
) { Text("Delete", color = Color(0xFFFF3B30)) }
) {
Text(
if (isLeaveGroupDialog) "Leave" else "Delete",
color = Color(0xFFFF3B30)
)
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {

View File

@@ -97,6 +97,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao()
private val groupDao = database.groupDao()
private val pinnedMessageDao = database.pinnedMessageDao()
// MessageRepository для подписки на события новых сообщений
@@ -105,6 +106,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
private val decryptionCache = ConcurrentHashMap<String, String>()
private val groupKeyCache = ConcurrentHashMap<String, String>()
private val groupSenderNameCache = ConcurrentHashMap<String, String>()
private val groupSenderResolveRequested = ConcurrentHashMap.newKeySet<String>()
@Volatile private var isCleared = false
// Информация о собеседнике
@@ -196,6 +200,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📌 Pinned messages state
private val _pinnedMessages = MutableStateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>>(emptyList())
val pinnedMessages: StateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>> = _pinnedMessages.asStateFlow()
private val _pinnedMessagePreviews = MutableStateFlow<Map<String, String>>(emptyMap())
val pinnedMessagePreviews: StateFlow<Map<String, String>> = _pinnedMessagePreviews.asStateFlow()
private val _currentPinnedIndex = MutableStateFlow(0)
val currentPinnedIndex: StateFlow<Int> = _currentPinnedIndex.asStateFlow()
@@ -529,6 +535,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// может получить стейтный кэш от предыдущего аккаунта
dialogMessagesCache.clear()
decryptionCache.clear()
groupKeyCache.clear()
groupSenderNameCache.clear()
groupSenderResolveRequested.clear()
}
myPublicKey = publicKey
myPrivateKey = privateKey
@@ -549,6 +558,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentTitle = title
opponentUsername = username
opponentVerified = verified.coerceAtLeast(0)
if (!isGroupDialogKey(publicKey)) {
groupKeyCache.remove(publicKey)
}
groupSenderNameCache.clear()
groupSenderResolveRequested.clear()
// 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния!
// Это важно для правильного отображения forward сообщений сразу
@@ -583,6 +597,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
lastReadMessageTimestamp = 0L
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен!
_pinnedMessagePreviews.value = emptyMap()
// Desktop parity: refresh opponent name/username from server on dialog open,
// so renamed contacts get their new name displayed immediately.
@@ -626,6 +641,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_pinnedMessages.value = pins
// Всегда показываем самый последний пин (index 0, ORDER BY DESC)
_currentPinnedIndex.value = 0
refreshPinnedMessagePreviews(acc, pins)
}
}
@@ -997,6 +1013,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId
val cachedText = decryptionCache[entity.messageId]
val privateKey = myPrivateKey
val groupDialogKey =
when {
isGroupDialogKey(entity.dialogKey) -> entity.dialogKey.trim()
isGroupDialogKey(entity.toPublicKey) -> entity.toPublicKey.trim()
else -> opponentKey?.trim()?.takeIf { isGroupDialogKey(it) }
}
val isGroupMessage = groupDialogKey != null || entity.chachaKey.startsWith("group:")
val groupKey =
if (isGroupMessage && privateKey != null) {
decodeStoredGroupKey(entity.chachaKey, privateKey)
?: groupDialogKey?.let { resolveGroupKeyForDialog(it) }
} else {
null
}
// Расшифровываем сообщение из content + chachaKey
var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки
var displayText =
@@ -1004,10 +1036,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
cachedText
} else
try {
val privateKey = myPrivateKey
if (privateKey != null &&
entity.content.isNotEmpty() &&
entity.chachaKey.isNotEmpty()
if (isGroupMessage && groupKey != null && entity.content.isNotEmpty()) {
val decrypted = CryptoManager.decryptWithPassword(entity.content, groupKey)
if (decrypted != null) {
decryptionCache[entity.messageId] = decrypted
decrypted
} else {
safePlainMessageFallback(entity.plainMessage)
}
} else if (
privateKey != null &&
entity.content.isNotEmpty() &&
entity.chachaKey.isNotEmpty() &&
!entity.chachaKey.startsWith("group:")
) {
// Расшифровываем как в архиве: content + chachaKey + privateKey
// 🚀 Используем Full версию чтобы получить plainKeyAndNonce для
@@ -1047,7 +1088,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
} catch (e: Exception) {
// Пробуем расшифровать plainMessage
val privateKey = myPrivateKey
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
try {
val decrypted = CryptoManager.decryptWithPassword(
@@ -1076,7 +1116,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isFromMe = entity.fromMe == 1,
content = entity.content,
chachaKey = entity.chachaKey,
plainKeyAndNonce = plainKeyAndNonce
plainKeyAndNonce = plainKeyAndNonce,
groupPassword = groupKey
)
var replyData = parsedReplyResult?.replyData
val forwardedMessages = parsedReplyResult?.forwardedMessages ?: emptyList()
@@ -1093,6 +1134,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Парсим все attachments (IMAGE, FILE, AVATAR)
val parsedAttachments = parseAllAttachments(entity.attachments)
val myKey = myPublicKey.orEmpty().trim()
val senderKey = entity.fromPublicKey.trim()
val senderName =
when {
senderKey.isBlank() -> ""
senderKey == myKey -> "You"
isGroupMessage -> resolveGroupSenderName(senderKey)
else -> opponentTitle.ifBlank { opponentUsername.ifBlank { shortPublicKey(senderKey) } }
}
if (isGroupMessage && senderKey.isNotBlank() && senderKey != myKey) {
requestGroupSenderNameIfNeeded(senderKey)
}
return ChatMessage(
id = entity.messageId,
text = displayText,
@@ -1109,10 +1164,102 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
forwardedMessages = forwardedMessages,
attachments = parsedAttachments,
chachaKey = entity.chachaKey
chachaKey = entity.chachaKey,
senderPublicKey = senderKey,
senderName = senderName
)
}
private fun shortPublicKey(value: String): String {
val trimmed = value.trim()
if (trimmed.length <= 12) return trimmed
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
}
private suspend fun resolveGroupSenderName(publicKey: String): String {
val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isBlank()) return ""
groupSenderNameCache[normalizedPublicKey]?.let { cached ->
if (isUsableSenderName(cached, normalizedPublicKey)) return cached
}
val account = myPublicKey
if (!account.isNullOrBlank()) {
try {
val dialog = dialogDao.getDialog(account, normalizedPublicKey)
val localName = dialog?.opponentTitle?.trim().orEmpty()
.ifBlank { dialog?.opponentUsername?.trim().orEmpty() }
if (isUsableSenderName(localName, normalizedPublicKey)) {
groupSenderNameCache[normalizedPublicKey] = localName
return localName
}
} catch (_: Exception) {
// ignore
}
}
val cached = ProtocolManager.getCachedUserInfo(normalizedPublicKey)
val protocolName = cached?.title?.trim().orEmpty()
.ifBlank { cached?.username?.trim().orEmpty() }
if (isUsableSenderName(protocolName, normalizedPublicKey)) {
groupSenderNameCache[normalizedPublicKey] = protocolName
return protocolName
}
return shortPublicKey(normalizedPublicKey)
}
private fun isUsableSenderName(name: String, publicKey: String): Boolean {
if (name.isBlank()) return false
val normalized = name.trim()
val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isNotBlank()) {
if (normalized.equals(normalizedPublicKey, ignoreCase = true)) return false
if (normalized.equals(normalizedPublicKey.take(7), ignoreCase = true)) return false
if (normalized.equals(normalizedPublicKey.take(8), ignoreCase = true)) return false
}
return true
}
private fun requestGroupSenderNameIfNeeded(publicKey: String) {
val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isBlank()) return
if (normalizedPublicKey == myPublicKey?.trim()) return
groupSenderNameCache[normalizedPublicKey]?.let { cached ->
if (isUsableSenderName(cached, normalizedPublicKey)) return
}
if (!groupSenderResolveRequested.add(normalizedPublicKey)) return
viewModelScope.launch(Dispatchers.IO) {
try {
val resolved = ProtocolManager.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L)
val name = resolved?.title?.trim().orEmpty()
.ifBlank { resolved?.username?.trim().orEmpty() }
if (!isUsableSenderName(name, normalizedPublicKey)) {
groupSenderResolveRequested.remove(normalizedPublicKey)
return@launch
}
groupSenderNameCache[normalizedPublicKey] = name
withContext(Dispatchers.Main) {
_messages.update { current ->
current.map { message ->
if (message.senderPublicKey.trim() == normalizedPublicKey &&
message.senderName != name
) {
message.copy(senderName = name)
} else {
message
}
}
}
}
} catch (_: Exception) {
groupSenderResolveRequested.remove(normalizedPublicKey)
}
}
}
/**
* Никогда не показываем в UI сырые шифротексты (`CHNK:`/`iv:ciphertext`) как текст сообщения.
* Это предотвращает появление "ключа" в подписи медиа при сбоях дешифровки.
@@ -1245,7 +1392,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
content: String,
chachaKey: String,
plainKeyAndNonce: ByteArray? =
null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
null, // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
groupPassword: String? = null
): ParsedReplyResult? {
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
@@ -1281,6 +1429,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey
var decryptionSuccess = false
// 🔥 Способ 0: Группа — blob шифруется ключом группы
if (groupPassword != null) {
try {
val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
if (decrypted != null) {
dataJson = decrypted
decryptionSuccess = true
}
} catch (_: Exception) {}
}
// 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих
// сообщений)
if (privateKey != null) {
@@ -1618,6 +1777,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* (account == opponent) возвращает просто account
*/
private fun getDialogKey(account: String, opponent: String): String {
if (isGroupDialogKey(opponent)) {
return opponent.trim()
}
// Для saved messages dialog_key = просто publicKey
if (account == opponent) {
return account
@@ -1630,6 +1792,96 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
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(stored: String, privateKey: String): String? {
if (!stored.startsWith("group:")) return null
val encoded = stored.removePrefix("group:")
if (encoded.isBlank()) return null
return CryptoManager.decryptWithPassword(encoded, privateKey)
}
private suspend fun resolveGroupKeyForDialog(dialogPublicKey: String): String? {
val account = myPublicKey ?: return null
val privateKey = myPrivateKey ?: return null
val normalizedDialog = dialogPublicKey.trim()
if (!isGroupDialogKey(normalizedDialog)) return null
groupKeyCache[normalizedDialog]?.let { return it }
val groupId = normalizeGroupId(normalizedDialog)
if (groupId.isBlank()) return null
val group = groupDao.getGroup(account, groupId) ?: return null
val decrypted = CryptoManager.decryptWithPassword(group.key, privateKey) ?: return null
groupKeyCache[normalizedDialog] = decrypted
return decrypted
}
private data class OutgoingEncryptionContext(
val encryptedContent: String,
val encryptedKey: String,
val aesChachaKey: String,
val plainKeyAndNonce: ByteArray?,
val attachmentPassword: String,
val isGroup: Boolean
)
private suspend fun buildEncryptionContext(
plaintext: String,
recipient: String,
privateKey: String
): OutgoingEncryptionContext? {
return if (isGroupDialogKey(recipient)) {
val groupKey = resolveGroupKeyForDialog(recipient) ?: return null
OutgoingEncryptionContext(
encryptedContent = CryptoManager.encryptWithPassword(plaintext, groupKey),
encryptedKey = "",
aesChachaKey = CryptoManager.encryptWithPassword(groupKey, privateKey),
plainKeyAndNonce = null,
attachmentPassword = groupKey,
isGroup = true
)
} else {
val encryptResult = MessageCrypto.encryptForSending(plaintext, recipient)
OutgoingEncryptionContext(
encryptedContent = encryptResult.ciphertext,
encryptedKey = encryptResult.encryptedKey,
aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey),
plainKeyAndNonce = encryptResult.plainKeyAndNonce,
attachmentPassword = String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
isGroup = false
)
}
}
private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
return if (context.isGroup) {
CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
} else {
val plainKeyAndNonce =
context.plainKeyAndNonce
?: throw IllegalStateException("Missing key+nonce for direct message")
MessageCrypto.encryptReplyBlob(payload, plainKeyAndNonce)
}
}
/** Обновить текст ввода */
fun updateInputText(text: String) {
_inputText.value = text
@@ -1695,6 +1947,79 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📌 PINNED MESSAGES
// ═══════════════════════════════════════════════════════════
private fun buildPinnedPreview(message: ChatMessage): String {
if (message.text.isNotBlank()) return message.text
return when {
message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
message.replyData != null -> "Reply"
else -> "Pinned message"
}
}
private suspend fun resolvePinnedPreviewText(account: String, messageId: String): String {
_messages.value.firstOrNull { it.id == messageId }?.let { visibleMessage ->
return buildPinnedPreview(visibleMessage)
}
decryptionCache[messageId]?.takeIf { it.isNotBlank() }?.let { cached ->
return cached
}
val entity = messageDao.getMessageById(account, messageId) ?: return "Pinned message"
return buildPinnedPreview(entityToChatMessage(entity))
}
private suspend fun refreshPinnedMessagePreviews(
account: String,
pins: List<com.rosetta.messenger.database.PinnedMessageEntity>
) {
if (pins.isEmpty()) {
_pinnedMessagePreviews.value = emptyMap()
return
}
val activeIds = pins.map { it.messageId }.toSet()
val nextPreviews = _pinnedMessagePreviews.value.filterKeys { it in activeIds }.toMutableMap()
for (pin in pins) {
val existingPreview = nextPreviews[pin.messageId]
if (existingPreview != null && existingPreview != "Pinned message") continue
val preview = resolvePinnedPreviewText(account, pin.messageId)
nextPreviews[pin.messageId] = preview
}
_pinnedMessagePreviews.value = nextPreviews
}
suspend fun ensureMessageLoaded(messageId: String): Boolean {
if (_messages.value.any { it.id == messageId }) return true
val account = myPublicKey ?: return false
val opponent = opponentKey ?: return false
val dialogKey = getDialogKey(account, opponent)
return withContext(Dispatchers.IO) {
val entity = messageDao.getMessageById(account, messageId) ?: return@withContext false
if (entity.dialogKey != dialogKey) return@withContext false
val hydratedMessage = entityToChatMessage(entity)
withContext(Dispatchers.Main.immediate) {
if (_messages.value.none { it.id == messageId }) {
_messages.value =
sortMessagesAscending((_messages.value + hydratedMessage).distinctBy { it.id })
}
}
updateCacheFromCurrentMessages()
_pinnedMessagePreviews.update { current ->
current + (messageId to buildPinnedPreview(hydratedMessage))
}
true
}
}
/** 📌 Закрепить сообщение */
fun pinMessage(messageId: String) {
viewModelScope.launch(Dispatchers.IO) {
@@ -1729,14 +2054,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return pinnedMessageDao.isPinned(account, dialogKey, messageId)
}
/** 📌 Перейти к следующему закреплённому сообщению (от нового к старому, циклически) */
/**
* 📌 Клик по pinned banner:
* 1) вернуть ТЕКУЩЕЕ отображаемое сообщение для скролла
* 2) после этого переключить индекс на следующее (циклически)
*
* Так скролл всегда попадает ровно в тот pinned, который видит пользователь в баннере.
*/
fun navigateToNextPinned(): String? {
val pins = _pinnedMessages.value
if (pins.isEmpty()) return null
val currentIdx = _currentPinnedIndex.value
val currentIdx = _currentPinnedIndex.value.coerceIn(0, pins.size - 1)
val currentMessageId = pins[currentIdx].messageId
val nextIdx = (currentIdx + 1) % pins.size
_currentPinnedIndex.value = nextIdx
return pins[nextIdx].messageId
return currentMessageId
}
/** 📌 Открепить все сообщения */
@@ -1769,6 +2101,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
*/
suspend fun resolveUserForProfile(publicKey: String): SearchUser? {
if (publicKey.isEmpty()) return null
if (isGroupDialogKey(publicKey)) return null
// If it's the current opponent, we already have info
if (publicKey == opponentKey) {
@@ -1961,12 +2294,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 2. 🔥 Шифрование и отправка в IO потоке
viewModelScope.launch(Dispatchers.IO) {
try {
// Шифрование текста - теперь возвращает EncryptedForSending с plainKeyAndNonce
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -1993,7 +2329,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
privateKey = privateKey
)
if (imageBlob != null) {
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce)
val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
var uploadTag = ""
@@ -2067,8 +2403,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyBlobPlaintext = replyJsonArray.toString()
val encryptedReplyBlob =
MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
replyBlobForDatabase =
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
@@ -2153,7 +2488,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered =
@@ -2193,11 +2536,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val isSavedMessages = (sender == recipientPublicKey)
// Шифрование (пустой текст для forward)
val encryptResult = MessageCrypto.encryptForSending("", recipientPublicKey)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = "",
recipient = recipientPublicKey,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val messageAttachments = mutableListOf<MessageAttachment>()
@@ -2218,7 +2565,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
privateKey = privateKey
)
if (imageBlob != null) {
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce)
val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
var uploadTag = ""
@@ -2277,7 +2624,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
val replyBlobPlaintext = replyJsonArray.toString()
val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
val replyAttachmentId = "reply_${timestamp}"
@@ -2325,7 +2672,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = "",
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
@@ -2565,11 +2920,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Шифрование текста
val encryptStartedAt = System.currentTimeMillis()
val encryptResult = MessageCrypto.encryptForSending(caption, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = caption,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
logPhotoPipeline(
messageId,
"text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms"
@@ -2579,7 +2938,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
val blobEncryptStartedAt = System.currentTimeMillis()
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)
logPhotoPipeline(
messageId,
"blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms"
@@ -2686,11 +3045,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
logPhotoPipeline(messageId, "ui status switched to SENT")
saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo",
timestamp = timestamp,
opponentPublicKey = recipient
)
saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo",
timestamp = timestamp,
opponentPublicKey = recipient
)
logPhotoPipeline(
messageId,
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
@@ -2760,17 +3119,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
backgroundUploadScope.launch {
try {
// Шифрование текста
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
val encryptedImageBlob =
MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)
val attachmentId = "img_$timestamp"
@@ -2846,7 +3208,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных
@@ -3022,11 +3392,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
try {
val groupStartedAt = System.currentTimeMillis()
// Шифрование текста
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
logPhotoPipeline(
messageId,
"group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}"
@@ -3047,7 +3421,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Шифруем изображение с ChaCha ключом
val encryptedImageBlob =
MessageCrypto.encryptReplyBlob(imageData.base64, plainKeyAndNonce)
encryptAttachmentPayload(imageData.base64, encryptionContext)
// Загружаем на Transport Server
val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
@@ -3122,7 +3496,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
@@ -3227,16 +3609,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
} catch (_: Exception) {}
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// 🚀 Шифруем файл с ChaCha ключом для Transport Server
val encryptedFileBlob = MessageCrypto.encryptReplyBlob(fileBase64, plainKeyAndNonce)
val encryptedFileBlob = encryptAttachmentPayload(fileBase64, encryptionContext)
val attachmentId = "file_$timestamp"
@@ -3299,7 +3685,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered =
@@ -3430,19 +3824,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
withContext(Dispatchers.Main) { addMessageSafely(optimisticMessage) }
// 2. Шифрование текста (пустой текст для аватарки)
val encryptResult = MessageCrypto.encryptForSending("", recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, userPrivateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = "",
recipient = recipient,
privateKey = userPrivateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey)
// 🔥 КРИТИЧНО: Как в desktop - шифруем аватар с ChaCha ключом (plainKeyAndNonce)
// НЕ с AVATAR_PASSWORD! AVATAR_PASSWORD используется только для локального хранения
// Используем avatarDataUrl (с префиксом data:image/...) а не avatarBlob!
val encryptedAvatarBlob =
MessageCrypto.encryptReplyBlob(avatarDataUrl, plainKeyAndNonce)
val encryptedAvatarBlob = encryptAttachmentPayload(avatarDataUrl, encryptionContext)
val avatarAttachmentId = "avatar_$timestamp"
@@ -3518,7 +3915,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = "", // Аватар без текста
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
userPrivateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage
@@ -3568,6 +3973,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
dialogDao.updateDialogFromMessages(account, opponent)
}
if (isGroupDialogKey(opponent)) {
val groupId = normalizeGroupId(opponent)
val group = groupDao.getGroup(account, groupId)
if (group != null) {
dialogDao.updateOpponentDisplayName(
account,
opponent,
group.title,
group.description
)
return
}
}
// 🔥 FIX: updateDialogFromMessages создаёт новый диалог с пустым title/username
// когда диалога ещё не было. Обновляем метаданные из уже известных данных.
if (opponent != account && opponentTitle.isNotEmpty()) {
@@ -3602,6 +4021,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
dialogDao.updateDialogFromMessages(account, opponentKey)
}
if (isGroupDialogKey(opponentKey)) {
val groupId = normalizeGroupId(opponentKey)
val group = groupDao.getGroup(account, groupId)
if (group != null) {
dialogDao.updateOpponentDisplayName(
account,
opponentKey,
group.title,
group.description
)
return
}
}
// 🔥 FIX: Сохраняем title/username после пересчёта счётчиков
if (opponentKey != account && opponentTitle.isNotEmpty()) {
dialogDao.updateOpponentDisplayName(
@@ -3711,7 +4144,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
// 📁 Для Saved Messages - не отправляем typing indicator
if (opponent == sender) {
if (opponent == sender || isGroupDialogKey(opponent)) {
return
}
@@ -3754,7 +4187,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sender = myPublicKey ?: return
// 📁 НЕ отправляем read receipt для saved messages (opponent == sender)
if (opponent == sender) {
if (opponent == sender || isGroupDialogKey(opponent)) {
return
}
@@ -3855,7 +4288,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val account = myPublicKey ?: return
// 📁 Для Saved Messages - не нужно подписываться на свой собственный статус
if (account == opponent) {
if (account == opponent || isGroupDialogKey(opponent)) {
return
}
@@ -3887,7 +4320,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) {
try {
// Удаляем все сообщения из БД
messageDao.deleteMessagesBetweenUsers(account, account, opponent)
if (isGroupDialogKey(opponent)) {
val dialogKey = getDialogKey(account, opponent)
messageDao.deleteDialog(account, dialogKey)
} else {
messageDao.deleteMessagesBetweenUsers(account, account, opponent)
}
// Очищаем кэш
val dialogKey = getDialogKey(account, opponent)
@@ -3926,6 +4364,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
override fun onCleared() {
super.onCleared()
isCleared = true
pinnedCollectionJob?.cancel()
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.chats
import android.content.Context
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.*
@@ -186,6 +187,11 @@ fun getAvatarText(publicKey: String): String {
return publicKey.take(2).uppercase()
}
private fun isGroupDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase()
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -505,6 +511,7 @@ fun ChatsListScreen(
// Confirmation dialogs state
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
@@ -1115,6 +1122,22 @@ fun ChatsListScreen(
}
)
// 👥 New Group
DrawerMenuItemEnhanced(
icon = TablerIcons.Users,
text = "New Group",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = {
scope.launch {
drawerState.close()
kotlinx.coroutines
.delay(100)
onNewGroupClick()
}
}
)
// 📖 Saved Messages
DrawerMenuItemEnhanced(
painter = painterResource(id = R.drawable.msg_saved),
@@ -2011,6 +2034,10 @@ fun ChatsListScreen(
.contains(
dialog.opponentKey
)
val isGroupDialog =
isGroupDialogKey(
dialog.opponentKey
)
val isTyping by
remember(
dialog.opponentKey
@@ -2128,6 +2155,10 @@ fun ChatsListScreen(
dialogsToDelete =
listOf(dialog)
},
onLeave = {
dialogToLeave =
dialog
},
onBlock = {
dialogToBlock =
dialog
@@ -2136,6 +2167,8 @@ fun ChatsListScreen(
dialogToUnblock =
dialog
},
isGroupChat =
isGroupDialog,
isPinned =
isPinnedDialog,
swipeEnabled =
@@ -2248,6 +2281,54 @@ fun ChatsListScreen(
)
}
// Leave Group Confirmation
dialogToLeave?.let { dialog ->
val groupTitle = dialog.opponentTitle.ifEmpty { "this group" }
AlertDialog(
onDismissRequest = { dialogToLeave = null },
containerColor =
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
"Leave Group",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"Are you sure you want to leave \"$groupTitle\"?",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
val groupKey = dialog.opponentKey
dialogToLeave = null
scope.launch {
val left = chatsViewModel.leaveGroup(groupKey)
if (!left) {
Toast
.makeText(
context,
"Failed to leave group",
Toast.LENGTH_SHORT
)
.show()
}
}
}
) { Text("Leave", color = Color(0xFFFF3B30)) }
},
dismissButton = {
TextButton(onClick = { dialogToLeave = null }) {
Text("Cancel", color = PrimaryBlue)
}
}
)
}
// Block Dialog Confirmation
dialogToBlock?.let { dialog ->
AlertDialog(
@@ -3022,7 +3103,7 @@ fun DrawerMenuItem(
}
/**
* 🔥 Swipeable wrapper для DialogItem с кнопками Block и Delete Свайп влево показывает действия
* 🔥 Swipeable wrapper для DialogItem (Pin + Leave/Block + Delete). Свайп влево показывает действия
* (как в React Native версии)
*/
@Composable
@@ -3031,6 +3112,7 @@ fun SwipeableDialogItem(
isDarkTheme: Boolean,
isTyping: Boolean = false,
isBlocked: Boolean = false,
isGroupChat: Boolean = false,
isSavedMessages: Boolean = false,
swipeEnabled: Boolean = true,
isMuted: Boolean = false,
@@ -3043,6 +3125,7 @@ fun SwipeableDialogItem(
onClick: () -> Unit,
onLongClick: () -> Unit = {},
onDelete: () -> Unit = {},
onLeave: () -> Unit = {},
onBlock: () -> Unit = {},
onUnblock: () -> Unit = {},
isPinned: Boolean = false,
@@ -3068,7 +3151,7 @@ fun SwipeableDialogItem(
label = "pinnedBackground"
)
var offsetX by remember { mutableStateOf(0f) }
// 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete)
// 📌 3 кнопки: Pin + Leave/Block + Delete (для SavedMessages: Pin + Delete)
val buttonCount =
if (!swipeEnabled) 0
else if (isSavedMessages) 2
@@ -3147,18 +3230,28 @@ fun SwipeableDialogItem(
}
}
// Кнопка Block/Unblock (только если не Saved Messages)
// Кнопка Leave (для группы) или Block/Unblock (для личных чатов)
if (!isSavedMessages) {
val middleActionColor =
if (isGroupChat) Color(0xFFFF6B6B)
else if (isBlocked) Color(0xFF4CAF50)
else Color(0xFFFF6B6B)
val middleActionIcon =
if (isGroupChat) TelegramIcons.Leave
else if (isBlocked) TelegramIcons.Unlock
else TelegramIcons.Block
val middleActionTitle =
if (isGroupChat) "Leave"
else if (isBlocked) "Unblock"
else "Block"
Box(
modifier =
Modifier.width(80.dp)
.fillMaxHeight()
.background(
if (isBlocked) Color(0xFF4CAF50)
else Color(0xFFFF6B6B)
)
.background(middleActionColor)
.clickable {
if (isBlocked) onUnblock()
if (isGroupChat) onLeave()
else if (isBlocked) onUnblock()
else onBlock()
offsetX = 0f
onSwipeClosed()
@@ -3170,20 +3263,14 @@ fun SwipeableDialogItem(
verticalArrangement = Arrangement.Center
) {
Icon(
painter =
if (isBlocked) TelegramIcons.Unlock
else TelegramIcons.Block,
contentDescription =
if (isBlocked) "Unblock"
else "Block",
painter = middleActionIcon,
contentDescription = middleActionTitle,
tint = Color.White,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
if (isBlocked) "Unblock"
else "Block",
text = middleActionTitle,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
@@ -3461,6 +3548,7 @@ fun DialogItemContent(
remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme)
}
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
// 📁 Для Saved Messages показываем специальное имя
// 🔥 Как в Архиве: title > username > "DELETED"
@@ -3628,6 +3716,15 @@ fun DialogItemContent(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (isGroupDialog) {
Spacer(modifier = Modifier.width(5.dp))
Icon(
imageVector = TablerIcons.Users,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.9f),
modifier = Modifier.size(15.dp)
)
}
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(dialog.opponentKey)
@@ -3871,7 +3968,7 @@ fun DialogItemContent(
)
}
} else {
val displayText =
val baseDisplayText =
when {
dialog.lastMessageAttachmentType ==
"Photo" -> "Photo"
@@ -3885,23 +3982,122 @@ fun DialogItemContent(
"No messages"
else -> dialog.lastMessage
}
val senderPrefix =
dialog.lastMessageSenderPrefix
.orEmpty()
.trim()
val showSenderPrefix =
isGroupDialog &&
senderPrefix.isNotEmpty() &&
baseDisplayText != "No messages"
val senderPrefixColor =
remember(
showSenderPrefix,
senderPrefix,
dialog.lastMessageSenderKey,
isDarkTheme
) {
when {
!showSenderPrefix ->
secondaryTextColor
senderPrefix.equals(
"You",
ignoreCase = true
) -> PrimaryBlue
else -> {
val colorSeed =
dialog.lastMessageSenderKey
?.trim()
.orEmpty()
val resolvedSeed =
if (colorSeed.isNotEmpty()) {
colorSeed
} else {
senderPrefix
}
getAvatarColor(
resolvedSeed,
isDarkTheme
)
.textColor
}
}
}
AppleEmojiText(
text = displayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount > 0)
textColor.copy(alpha = 0.85f)
else secondaryTextColor,
fontWeight =
if (dialog.unreadCount > 0)
FontWeight.Medium
else FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.fillMaxWidth(),
enableLinks = false
)
if (showSenderPrefix) {
Row(
modifier =
Modifier.fillMaxWidth(),
verticalAlignment =
Alignment.CenterVertically
) {
AppleEmojiText(
text = "$senderPrefix:",
fontSize = 14.sp,
color = senderPrefixColor,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow =
android.text.TextUtils.TruncateAt.END,
modifier =
Modifier.widthIn(
max = 120.dp
),
enableLinks = false
)
Spacer(
modifier =
Modifier.width(4.dp)
)
AppleEmojiText(
text =
baseDisplayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount >
0
)
textColor.copy(
alpha =
0.85f
)
else
secondaryTextColor,
fontWeight =
if (dialog.unreadCount >
0
)
FontWeight.Medium
else
FontWeight.Normal,
maxLines = 1,
overflow =
android.text.TextUtils.TruncateAt.END,
modifier =
Modifier.weight(
1f
),
enableLinks = false
)
}
} else {
AppleEmojiText(
text = baseDisplayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount > 0)
textColor.copy(alpha = 0.85f)
else secondaryTextColor,
fontWeight =
if (dialog.unreadCount > 0)
FontWeight.Medium
else FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.fillMaxWidth(),
enableLinks = false
)
}
}
}
}

View File

@@ -5,8 +5,9 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe
@@ -42,6 +43,8 @@ data class DialogUiModel(
val lastMessageRead: Int = 0, // Прочитано (0/1)
val lastMessageAttachmentType: String? =
null, // 📎 Тип attachment: "Photo", "File", или null
val lastMessageSenderPrefix: String? = null, // 👥 Для групп: "You" или имя отправителя
val lastMessageSenderKey: String? = null, // 👥 Для групп: public key отправителя
val draftText: String? = null // 📝 Черновик сообщения (как в Telegram)
)
@@ -66,6 +69,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao()
private val groupRepository = GroupRepository.getInstance(application)
private var currentAccount: String = ""
private var currentPrivateKey: String? = null
@@ -123,6 +127,104 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val TAG = "ChatsListVM"
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
private val groupJoinedMarker = "\$a=Group joined"
private val groupCreatedMarker = "\$a=Group created"
private fun isGroupKey(value: String): Boolean {
val normalized = value.trim().lowercase()
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private data class GroupLastSenderInfo(
val senderPrefix: String,
val senderKey: String
)
private suspend fun resolveGroupLastSenderInfo(
dialog: com.rosetta.messenger.database.DialogEntity
): GroupLastSenderInfo? {
if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null
val senderKey =
if (dialog.lastMessageFromMe == 1) {
currentAccount
} else {
val lastMessage =
try {
dialogDao.getLastMessageByDialogKey(
account = dialog.account,
dialogKey = dialog.opponentKey.trim()
)
} catch (_: Exception) {
null
}
lastMessage?.fromPublicKey.orEmpty()
}
if (senderKey.isBlank()) return null
if (senderKey == currentAccount) {
return GroupLastSenderInfo(senderPrefix = "You", senderKey = senderKey)
}
val senderName = resolveKnownDisplayName(senderKey)
if (senderName == null) {
loadUserInfoForDialog(senderKey)
}
return GroupLastSenderInfo(
senderPrefix = senderName ?: senderKey.take(7),
senderKey = senderKey
)
}
private suspend fun resolveKnownDisplayName(publicKey: String): String? {
if (publicKey.isBlank()) return null
if (publicKey == currentAccount) return "You"
val dialogName =
try {
val userDialog = dialogDao.getDialog(currentAccount, publicKey)
userDialog?.let {
extractDisplayName(
title = it.opponentTitle,
username = it.opponentUsername,
publicKey = publicKey
)
}
} catch (_: Exception) {
null
}
if (!dialogName.isNullOrBlank()) return dialogName
val cached = ProtocolManager.getCachedUserName(publicKey).orEmpty().trim()
if (cached.isNotBlank() && cached != publicKey) {
return cached
}
return null
}
private fun extractDisplayName(title: String, username: String, publicKey: String): String? {
val normalizedTitle = title.trim()
if (normalizedTitle.isNotEmpty() &&
normalizedTitle != publicKey &&
normalizedTitle != publicKey.take(7) &&
normalizedTitle != publicKey.take(8)
) {
return normalizedTitle
}
val normalizedUsername = username.trim()
if (normalizedUsername.isNotEmpty() &&
normalizedUsername != publicKey &&
normalizedUsername != publicKey.take(7) &&
normalizedUsername != publicKey.take(8)
) {
return normalizedUsername
}
return null
}
/** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) {
@@ -222,6 +324,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedLastMessage =
decryptedLastMessage
)
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel(
id = dialog.id,
@@ -249,7 +353,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// DialogEntity
// (денормализовано)
lastMessageAttachmentType =
attachmentType // 📎 Тип attachment
attachmentType, // 📎 Тип attachment
lastMessageSenderPrefix =
groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey =
groupLastSenderInfo?.senderKey
)
}
}
@@ -320,6 +428,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedLastMessage =
decryptedLastMessage
)
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel(
id = dialog.id,
@@ -346,7 +456,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType =
attachmentType // 📎 Тип attachment
attachmentType, // 📎 Тип attachment
lastMessageSenderPrefix =
groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey =
groupLastSenderInfo?.senderKey
)
}
}
@@ -404,7 +518,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (opponentKeys.isEmpty()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) }
val newKeys =
opponentKeys.filter { key ->
!subscribedOnlineKeys.contains(key) && !isGroupKey(key)
}
if (newKeys.isEmpty()) return // Все уже подписаны
// Добавляем в Set ДО отправки пакета чтобы избежать race condition
@@ -429,7 +546,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (encryptedLastMessage.isEmpty()) return ""
if (privateKey.isEmpty()) {
return if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage
val plainCandidate =
if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage
return formatPreviewText(plainCandidate)
}
val decrypted =
@@ -439,11 +558,23 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
null
}
return when {
val resolved = when {
decrypted != null -> decrypted
isLikelyEncryptedPayload(encryptedLastMessage) -> ""
else -> encryptedLastMessage
}
return formatPreviewText(resolved)
}
private fun formatPreviewText(value: String): String {
if (value.isBlank()) return value
val normalized = value.trim()
return when {
groupInviteRegex.matches(normalized) -> "Group Invite"
normalized == groupJoinedMarker -> "You joined the group"
normalized == groupCreatedMarker -> "Group created"
else -> value
}
}
private fun resolveAttachmentType(
@@ -611,6 +742,26 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
}
/** Выйти из группы и удалить локальный групповой диалог */
suspend fun leaveGroup(groupPublicKey: String): Boolean {
if (currentAccount.isEmpty()) return false
if (!isGroupKey(groupPublicKey)) return false
return try {
val left = groupRepository.leaveGroup(currentAccount, groupPublicKey)
if (left) {
_dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey }
_requests.value = _requests.value.filter { it.opponentKey != groupPublicKey }
_requestsCount.value = _requests.value.size
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
ChatViewModel.clearCacheForOpponent(groupPublicKey)
}
left
} catch (_: Exception) {
false
}
}
/** Заблокировать пользователя */
suspend fun blockUser(publicKey: String) {
if (currentAccount.isEmpty()) return
@@ -649,7 +800,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
*/
private fun loadUserInfoForDialog(publicKey: String) {
// 📁 Не запрашиваем информацию о самом себе (Saved Messages)
if (publicKey == currentAccount) {
if (publicKey == currentAccount || isGroupKey(publicKey)) {
return
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,248 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupSetupScreen(
isDarkTheme: Boolean,
accountPublicKey: String,
accountPrivateKey: String,
onBack: () -> Unit,
onGroupOpened: (SearchUser) -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var selectedTab by remember { mutableIntStateOf(0) }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var inviteString by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var errorText by remember { mutableStateOf<String?>(null) }
fun openGroup(dialogPublicKey: String, groupTitle: String) {
onGroupOpened(
SearchUser(
publicKey = dialogPublicKey,
title = groupTitle.ifBlank { "Group" },
username = "",
verified = 0,
online = 0
)
)
}
suspend fun createGroup() =
GroupRepository.getInstance(context).createGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
title = title.trim(),
description = description.trim()
)
suspend fun joinGroup() =
GroupRepository.getInstance(context).joinGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
inviteString = inviteString.trim()
)
fun mapError(status: GroupStatus, fallback: String): String {
return when (status) {
GroupStatus.BANNED -> "You are banned in this group"
GroupStatus.INVALID -> "Invite is invalid"
else -> fallback
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Groups", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
TextButton(onClick = onBack) {
Text("Back")
}
}
)
}
) { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.Top
) {
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = {
selectedTab = 0
errorText = null
},
text = { Text("Create") }
)
Tab(
selected = selectedTab == 1,
onClick = {
selectedTab = 1
errorText = null
},
text = { Text("Join") }
)
}
Spacer(modifier = Modifier.height(16.dp))
if (selectedTab == 0) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Group title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description (optional)") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
minLines = 3,
maxLines = 4
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isLoading) return@Button
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { createGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
openGroup(result.dialogPublicKey, result.title)
} else {
errorText =
mapError(
result.status,
result.error ?: "Cannot create group"
)
}
isLoading = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = title.trim().isNotEmpty() && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(strokeWidth = 2.dp)
} else {
Text("Create Group")
}
}
} else {
OutlinedTextField(
value = inviteString,
onValueChange = { inviteString = it },
label = { Text("Invite string") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 6,
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isLoading) return@Button
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { joinGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
openGroup(result.dialogPublicKey, result.title)
} else {
errorText =
mapError(
result.status,
result.error ?: "Cannot join group"
)
}
isLoading = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = inviteString.trim().isNotEmpty() && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(strokeWidth = 2.dp)
} else {
Text("Join Group")
}
}
}
if (!errorText.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = errorText ?: "",
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text =
if (selectedTab == 0) {
"Creates a new private group and joins it automatically."
} else {
"Paste a full invite string that starts with #group:."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
@@ -77,6 +78,31 @@ private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
private val whitespaceRegex = "\\s+".toRegex()
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
if (!isGroupStoredKey(storedKey)) return null
val encoded = storedKey.removePrefix("group:")
if (encoded.isBlank()) return null
return CryptoManager.decryptWithPassword(encoded, privateKey)
}
private fun decodeBase64Payload(data: String): ByteArray? {
val raw = data.trim()
if (raw.isBlank()) return null
val payload =
if (raw.startsWith("data:") && raw.contains(",")) {
raw.substringAfter(",")
} else {
raw
}
return try {
Base64.decode(payload, Base64.DEFAULT)
} catch (_: Exception) {
null
}
}
private fun shortDebugId(value: String): String {
if (value.isBlank()) return "empty"
val clean = value.trim()
@@ -1488,28 +1514,53 @@ fun FileAttachment(
downloadStatus = DownloadStatus.DOWNLOADING
// Streaming: скачиваем во temp file, не в память
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
downloadProgress = 0.5f
val success =
if (isGroupStoredKey(chachaKey)) {
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING
downloadStatus = DownloadStatus.DECRYPTING
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) {
false
} else {
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
val bytes = decrypted?.let { decodeBase64Payload(it) }
if (bytes != null) {
withContext(Dispatchers.IO) {
savedFile.parentFile?.mkdirs()
savedFile.writeBytes(bytes)
}
true
} else {
false
}
}
} else {
// Streaming: скачиваем во temp file, не в память
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
downloadProgress = 0.5f
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
downloadProgress = 0.6f
downloadStatus = DownloadStatus.DECRYPTING
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
// Пиковое потребление памяти ~128KB вместо ~200MB
val success = withContext(Dispatchers.IO) {
try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
)
} finally {
tempFile.delete()
}
}
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
downloadProgress = 0.6f
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
// Пиковое потребление памяти ~128KB вместо ~200MB
withContext(Dispatchers.IO) {
try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
)
} finally {
tempFile.delete()
}
}
}
downloadProgress = 0.95f
if (success) {
@@ -1860,19 +1911,28 @@ fun AvatarAttachment(
downloadStatus = DownloadStatus.DECRYPTING
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
// bytes в password
val decryptStartTime = System.currentTimeMillis()
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
if (isGroupStoredKey(chachaKey)) {
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) {
null
} else {
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
}
} else {
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
// Используем decryptAttachmentBlobWithPlainKey который правильно
// конвертирует bytes в password
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
}
val decryptTime = System.currentTimeMillis() - decryptStartTime
if (decrypted != null) {
@@ -2351,21 +2411,35 @@ private suspend fun processDownloadedImage(
onStatus(DownloadStatus.DECRYPTING)
// Расшифровываем ключ
val keyCandidates: List<ByteArray>
var keyCandidates: List<ByteArray> = emptyList()
var groupPassword: String? = null
try {
keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) {
throw IllegalArgumentException("empty key candidates")
}
logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}")
keyCandidates.forEachIndexed { idx, candidate ->
val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) }
logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
if (isGroupStoredKey(chachaKey)) {
groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) {
throw IllegalArgumentException("empty group password")
}
logPhotoDebug("Group key decrypt OK: id=$idShort, keyLen=${groupPassword.length}")
} else {
keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) {
throw IllegalArgumentException("empty key candidates")
}
logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}")
keyCandidates.forEachIndexed { idx, candidate ->
val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) }
logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
}
}
} catch (e: Exception) {
onError("Error")
onStatus(DownloadStatus.ERROR)
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
val keyPrefix =
when {
chachaKey.startsWith("sync:") -> "sync"
chachaKey.startsWith("group:") -> "group"
else -> "ecdh"
}
logPhotoDebug("Key decrypt FAILED: id=$idShort, keyType=$keyPrefix, keyLen=${chachaKey.length}, err=${e.javaClass.simpleName}: ${e.message?.take(80)}")
return
}
@@ -2374,17 +2448,26 @@ private suspend fun processDownloadedImage(
val decryptStartTime = System.currentTimeMillis()
var successKeyIdx = -1
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
if (attempt.decrypted != null) {
successKeyIdx = idx
decryptDebug = attempt
break
}
// Keep last trace for diagnostics if all fail.
decryptDebug = attempt
}
val decrypted = decryptDebug.decrypted
val decrypted =
if (groupPassword != null) {
val plain = CryptoManager.decryptWithPassword(encryptedContent, groupPassword!!)
decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(plain, emptyList())
plain
} else {
var value: String? = null
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
if (attempt.decrypted != null) {
successKeyIdx = idx
decryptDebug = attempt
value = attempt.decrypted
break
}
// Keep last trace for diagnostics if all fail.
decryptDebug = attempt
}
value
}
val decryptTime = System.currentTimeMillis() - decryptStartTime
onProgress(0.8f)
@@ -2428,7 +2511,12 @@ private suspend fun processDownloadedImage(
} else {
onError("Error")
onStatus(DownloadStatus.ERROR)
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
val keyPrefix =
when {
chachaKey.startsWith("sync:") -> "sync"
chachaKey.startsWith("group:") -> "group"
else -> "ecdh"
}
val firstKeySize = keyCandidates.firstOrNull()?.size ?: -1
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms, contentLen=${encryptedContent.length}, keyType=$keyPrefix, keyNonceSize=$firstKeySize, keyCandidates=${keyCandidates.size}")
decryptDebug.trace.take(96).forEachIndexed { index, line ->
@@ -2467,47 +2555,54 @@ internal suspend fun downloadAndDecryptImage(
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
)
val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) return@withContext null
val plainKeyAndNonce = keyCandidates.first()
logPhotoDebug(
"Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}"
)
logPhotoDebug(
"Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}"
)
// Primary path for image attachments
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
var decrypted: String? = null
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt =
MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
encryptedContent,
keyCandidate
val decrypted: String? =
if (isGroupStoredKey(chachaKey)) {
val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
} else {
val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) return@withContext null
val plainKeyAndNonce = keyCandidates.first()
logPhotoDebug(
"Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}"
)
logPhotoDebug(
"Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}"
)
if (attempt.decrypted != null) {
decryptDebug = attempt
decrypted = attempt.decrypted
logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
break
}
decryptDebug = attempt
}
// Fallback for legacy payloads
if (decrypted.isNullOrEmpty()) {
decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
}
decrypted =
try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
.takeIf { it.isNotEmpty() && it != encryptedContent }
} catch (_: Exception) {
null
// Primary path for image attachments
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
var value: String? = null
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt =
MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
encryptedContent,
keyCandidate
)
if (attempt.decrypted != null) {
decryptDebug = attempt
value = attempt.decrypted
logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
break
}
decryptDebug = attempt
}
}
// Fallback for legacy payloads
if (value.isNullOrEmpty()) {
decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
}
value =
try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
.takeIf { it.isNotEmpty() && it != encryptedContent }
} catch (_: Exception) {
null
}
}
value
}
if (decrypted.isNullOrEmpty()) return@withContext null

View File

@@ -1,8 +1,14 @@
package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap
import android.widget.Toast
import com.rosetta.messenger.R
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.PersonAdd
import android.graphics.BitmapFactory
import androidx.compose.animation.*
import androidx.compose.animation.core.*
@@ -41,19 +47,25 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.*
@@ -69,6 +81,7 @@ import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.abs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
@@ -281,6 +294,9 @@ fun MessageBubble(
isSavedMessages: Boolean = false,
privateKey: String = "",
senderPublicKey: String = "",
senderName: String = "",
showGroupSenderLabel: Boolean = false,
isGroupSenderAdmin: Boolean = false,
currentUserPublicKey: String = "",
avatarRepository: AvatarRepository? = null,
onLongClick: () -> Unit = {},
@@ -291,6 +307,7 @@ fun MessageBubble(
onDelete: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onGroupInviteOpen: (SearchUser) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {}
) {
// Swipe-to-reply state
@@ -366,6 +383,22 @@ fun MessageBubble(
message.attachments.isEmpty() &&
message.text.isNotBlank()
val groupActionSystemText =
remember(message.text) { resolveGroupActionSystemText(message.text) }
val isGroupActionSystemMessage =
groupActionSystemText != null &&
message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.attachments.isEmpty()
if (isGroupActionSystemMessage) {
GroupActionSystemMessage(
text = groupActionSystemText.orEmpty(),
isDarkTheme = isDarkTheme
)
return
}
// Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp
val bubbleShape =
remember(message.isOutgoing, showTail, isSafeSystemMessage) {
@@ -719,6 +752,32 @@ fun MessageBubble(
)
} else {
Column {
if (showGroupSenderLabel &&
!message.isOutgoing &&
senderName.isNotBlank()
) {
Row(
modifier = Modifier.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = senderName,
color =
groupSenderLabelColor(
senderPublicKey,
isDarkTheme
),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (isGroupSenderAdmin) {
Spacer(modifier = Modifier.width(4.dp))
GroupAdminBadge(isDarkTheme = isDarkTheme)
}
}
}
// 🔥 Forwarded messages (multiple, desktop parity)
if (message.forwardedMessages.isNotEmpty()) {
ForwardedMessagesBubble(
@@ -974,81 +1033,124 @@ fun MessageBubble(
!hasImageWithCaption &&
message.text.isNotEmpty()
) {
// Telegram-style: текст + время с автоматическим
// переносом
TelegramStyleMessageContent(
textContent = {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp,
linkColor = linkColor,
enableLinks = linksEnabled,
onClick = textClickHandler,
onLongClick =
onLongClick // 🔥
// Long
// press
// для
// selection
if (isGroupInviteCode(message.text)) {
val displayStatus =
if (isSavedMessages) MessageStatus.READ
else message.status
GroupInviteInlineCard(
inviteText = message.text,
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
accountPublicKey = currentUserPublicKey,
accountPrivateKey = privateKey,
actionsEnabled = !isSelectionMode,
onOpenGroup = onGroupInviteOpen
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
if (message.isOutgoing) {
Spacer(modifier = Modifier.width(2.dp))
AnimatedMessageStatus(
status = displayStatus,
timeColor = statusColor,
isDarkTheme = isDarkTheme,
isOutgoing = message.isOutgoing,
timestamp = message.timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
},
timeContent = {
Row(
verticalAlignment =
Alignment
.CenterVertically,
horizontalArrangement =
Arrangement
.spacedBy(
2.dp
)
) {
Text(
text =
timeFormat
.format(
message.timestamp
),
color = timeColor,
fontSize = 11.sp,
fontStyle =
androidx.compose
.ui
.text
.font
.FontStyle
.Italic
)
if (message.isOutgoing) {
val displayStatus =
if (isSavedMessages
)
MessageStatus
.READ
else
message.status
AnimatedMessageStatus(
status =
displayStatus,
timeColor =
statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp =
message.timestamp
.time,
onRetry =
onRetry,
onDelete =
onDelete
)
}
}
}
)
} else {
// Telegram-style: текст + время с автоматическим
// переносом
TelegramStyleMessageContent(
textContent = {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp,
linkColor = linkColor,
enableLinks = linksEnabled,
onClick = textClickHandler,
onLongClick =
onLongClick // 🔥
// Long
// press
// для
// selection
)
},
timeContent = {
Row(
verticalAlignment =
Alignment
.CenterVertically,
horizontalArrangement =
Arrangement
.spacedBy(
2.dp
)
) {
Text(
text =
timeFormat
.format(
message.timestamp
),
color = timeColor,
fontSize = 11.sp,
fontStyle =
androidx.compose
.ui
.text
.font
.FontStyle
.Italic
)
if (message.isOutgoing) {
val displayStatus =
if (isSavedMessages
)
MessageStatus
.READ
else
message.status
AnimatedMessageStatus(
status =
displayStatus,
timeColor =
statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp =
message.timestamp
.time,
onRetry =
onRetry,
onDelete =
onDelete
)
}
}
}
)
}
}
}
}
@@ -1059,6 +1161,392 @@ fun MessageBubble(
}
}
private val GROUP_INVITE_REGEX = Regex("^#group:[A-Za-z0-9+/=:]+$")
private const val GROUP_ACTION_JOINED = "\$a=Group joined"
private const val GROUP_ACTION_CREATED = "\$a=Group created"
private fun resolveGroupActionSystemText(text: String): String? {
return when (text.trim()) {
GROUP_ACTION_JOINED -> "You joined the group"
GROUP_ACTION_CREATED -> "Group created"
else -> null
}
}
@Composable
private fun GroupActionSystemMessage(text: String, isDarkTheme: Boolean) {
val successColor = if (isDarkTheme) Color(0xFF7EE787) else Color(0xFF2E7D32)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
color = successColor,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
private fun isGroupInviteCode(text: String): Boolean {
val normalized = text.trim()
if (!normalized.startsWith("#group:")) return false
return GROUP_INVITE_REGEX.matches(normalized)
}
private fun groupSenderLabelColor(publicKey: String, isDarkTheme: Boolean): Color {
val paletteDark =
listOf(
Color(0xFF7ED957),
Color(0xFF6EC1FF),
Color(0xFFFF9F68),
Color(0xFFC38AFF),
Color(0xFFFF7AA2),
Color(0xFF4DD7C8)
)
val paletteLight =
listOf(
Color(0xFF2E7D32),
Color(0xFF1565C0),
Color(0xFFEF6C00),
Color(0xFF6A1B9A),
Color(0xFFC2185B),
Color(0xFF00695C)
)
val palette = if (isDarkTheme) paletteDark else paletteLight
val index = kotlin.math.abs(publicKey.hashCode()) % palette.size
return palette[index]
}
@Composable
private fun GroupAdminBadge(isDarkTheme: Boolean) {
var showInfo by remember { mutableStateOf(false) }
val iconTint = Color(0xFFF6C445)
val popupBackground = if (isDarkTheme) Color(0xFF2E2E31) else Color(0xFFF2F2F5)
val popupTextColor = if (isDarkTheme) Color(0xFFE3E3E6) else Color(0xFF2B2B2F)
Box {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_badge_down_filled),
contentDescription = "Admin",
tint = iconTint,
modifier =
Modifier.size(14.dp).clickable(
indication = null,
interactionSource =
remember { MutableInteractionSource() }
) { showInfo = true }
)
DropdownMenu(
expanded = showInfo,
onDismissRequest = { showInfo = false },
offset = DpOffset(x = 0.dp, y = 6.dp),
modifier =
Modifier.background(
color = popupBackground,
shape = RoundedCornerShape(14.dp)
)
) {
Text(
text = "This user is administrator of\nthis group.",
color = popupTextColor,
fontSize = 14.sp,
lineHeight = 20.sp,
modifier =
Modifier.padding(
horizontal = 14.dp,
vertical = 10.dp
)
)
}
}
}
@Composable
private fun GroupInviteInlineCard(
inviteText: String,
isOutgoing: Boolean,
isDarkTheme: Boolean,
accountPublicKey: String,
accountPrivateKey: String,
actionsEnabled: Boolean,
onOpenGroup: (SearchUser) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val groupRepository = remember { GroupRepository.getInstance(context) }
val normalizedInvite = remember(inviteText) { inviteText.trim() }
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
var status by remember(normalizedInvite) { mutableStateOf<GroupStatus>(GroupStatus.NOT_JOINED) }
var membersCount by remember(normalizedInvite) { mutableStateOf(0) }
var statusLoading by remember(normalizedInvite) { mutableStateOf(true) }
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
LaunchedEffect(normalizedInvite, accountPublicKey) {
if (parsedInvite == null) {
status = GroupStatus.INVALID
membersCount = 0
statusLoading = false
return@LaunchedEffect
}
statusLoading = true
val localGroupExists =
withContext(Dispatchers.IO) {
if (accountPublicKey.isBlank()) {
false
} else {
groupRepository.getGroup(accountPublicKey, parsedInvite.groupId) != null
}
}
val inviteInfo =
withContext(Dispatchers.IO) {
groupRepository.requestInviteInfo(parsedInvite.groupId)
}
membersCount = inviteInfo?.membersCount ?: 0
status =
when {
localGroupExists -> GroupStatus.JOINED
inviteInfo != null -> inviteInfo.status
else -> GroupStatus.NOT_JOINED
}
statusLoading = false
}
val title =
parsedInvite?.title?.trim().takeUnless { it.isNullOrBlank() }
?: "Group Invite"
val subtitle =
if (statusLoading) {
"Checking invite..."
} else {
when (status) {
GroupStatus.NOT_JOINED ->
if (membersCount > 0) {
"$membersCount members • Invite to join this group"
} else {
"Invite to join this group"
}
GroupStatus.JOINED ->
if (membersCount > 0) {
"$membersCount members • You are already a member"
} else {
"You are already a member of this group"
}
GroupStatus.BANNED -> "You are banned in this group"
GroupStatus.INVALID -> "This group invite is invalid"
}
}
val cardBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.16f)
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.06f)
} else {
Color.Black.copy(alpha = 0.03f)
}
val cardBorder =
if (isOutgoing) {
Color.White.copy(alpha = 0.22f)
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.12f)
} else {
Color.Black.copy(alpha = 0.08f)
}
val titleColor =
if (isOutgoing) Color.White
else if (isDarkTheme) Color.White
else Color(0xFF1A1A1A)
val subtitleColor =
if (isOutgoing) Color.White.copy(alpha = 0.82f)
else if (isDarkTheme) Color(0xFFA9AFBA)
else Color(0xFF70757F)
val accentColor =
when (status) {
GroupStatus.NOT_JOINED ->
if (isOutgoing) Color.White else Color(0xFF228BE6)
GroupStatus.JOINED ->
if (isOutgoing) Color(0xFFD9FAD6) else Color(0xFF34C759)
GroupStatus.BANNED, GroupStatus.INVALID ->
if (isOutgoing) Color(0xFFFFD7D7) else Color(0xFFFF3B30)
}
val actionLabel =
when (status) {
GroupStatus.NOT_JOINED -> "Join Group"
GroupStatus.JOINED -> "Open Group"
GroupStatus.INVALID -> "Invalid"
GroupStatus.BANNED -> "Banned"
}
val actionIcon =
when (status) {
GroupStatus.NOT_JOINED -> Icons.Default.PersonAdd
GroupStatus.JOINED -> Icons.Default.Check
GroupStatus.INVALID -> Icons.Default.Link
GroupStatus.BANNED -> Icons.Default.Block
}
val actionEnabled = actionsEnabled && !statusLoading && !actionLoading && status != GroupStatus.INVALID && status != GroupStatus.BANNED
fun openParsedGroup() {
val parsed = parsedInvite ?: return
onOpenGroup(
SearchUser(
publicKey = groupRepository.toGroupDialogPublicKey(parsed.groupId),
title = parsed.title.ifBlank { "Group" },
username = "",
verified = 0,
online = 0
)
)
}
fun handleAction() {
if (!actionEnabled) return
if (parsedInvite == null) return
if (status == GroupStatus.JOINED) {
openParsedGroup()
return
}
if (accountPublicKey.isBlank() || accountPrivateKey.isBlank()) {
Toast.makeText(context, "Account is not ready", Toast.LENGTH_SHORT).show()
return
}
scope.launch {
actionLoading = true
val joinResult =
withContext(Dispatchers.IO) {
groupRepository.joinGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
inviteString = normalizedInvite
)
}
actionLoading = false
if (joinResult.success) {
status = GroupStatus.JOINED
openParsedGroup()
} else {
status = joinResult.status
val errorMessage =
when (joinResult.status) {
GroupStatus.BANNED -> "You are banned in this group"
GroupStatus.INVALID -> "This invite is invalid"
else -> joinResult.error ?: "Failed to join group"
}
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = cardBackground,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.size(34.dp)
.clip(CircleShape)
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = titleColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
color = subtitleColor,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier =
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
enabled = actionEnabled,
onClick = ::handleAction
),
color =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else {
accentColor.copy(alpha = 0.14f)
},
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (actionLoading || statusLoading) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.8.dp,
color = accentColor
)
} else {
Icon(
imageVector = actionIcon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(12.dp)
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = actionLabel,
color = accentColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
}
@Composable
private fun SafeSystemMessageCard(text: String, timestamp: Date, isDarkTheme: Boolean) {
val contentColor = if (isDarkTheme) Color(0xFFE8E9EE) else Color(0xFF1E1F23)
@@ -2368,8 +2856,12 @@ fun KebabMenu(
onDismiss: () -> Unit,
isDarkTheme: Boolean,
isSavedMessages: Boolean,
isGroupChat: Boolean = false,
isSystemAccount: Boolean = false,
isBlocked: Boolean,
onGroupInfoClick: () -> Unit = {},
onSearchMembersClick: () -> Unit = {},
onLeaveGroupClick: () -> Unit = {},
onBlockClick: () -> Unit,
onUnblockClick: () -> Unit,
onDeleteClick: () -> Unit
@@ -2398,24 +2890,49 @@ fun KebabMenu(
dismissOnClickOutside = true
)
) {
if (!isSavedMessages && !isSystemAccount) {
KebabMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = { if (isBlocked) onUnblockClick() else onBlockClick() },
if (isGroupChat) {
ContextMenuItemWithVector(
icon = TablerIcons.Search,
text = "Search Members",
onClick = onSearchMembersClick,
tintColor = iconColor,
textColor = textColor
)
}
KebabMenuItem(
icon = TelegramIcons.Info,
text = "Group Info",
onClick = onGroupInfoClick,
tintColor = iconColor,
textColor = textColor
)
Divider(color = dividerColor)
KebabMenuItem(
icon = TelegramIcons.Leave,
text = "Leave Group",
onClick = onLeaveGroupClick,
tintColor = Color(0xFFFF3B30),
textColor = Color(0xFFFF3B30)
)
} else {
if (!isSavedMessages && !isSystemAccount) {
KebabMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = { if (isBlocked) onUnblockClick() else onBlockClick() },
tintColor = iconColor,
textColor = textColor
)
}
// Delete chat
KebabMenuItem(
icon = TelegramIcons.Delete,
text = "Delete Chat",
onClick = onDeleteClick,
tintColor = Color(0xFFFF3B30),
textColor = Color(0xFFFF3B30)
)
// Delete chat
KebabMenuItem(
icon = TelegramIcons.Delete,
text = "Delete Chat",
onClick = onDeleteClick,
tintColor = Color(0xFFFF3B30),
textColor = Color(0xFFFF3B30)
)
}
}
}
}

View File

@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.toSize
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.TransportManager
@@ -931,13 +932,22 @@ private suspend fun loadBitmapForViewerImage(
AttachmentDownloadDebugLogger.log(
"Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
AttachmentDownloadDebugLogger.log(
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
)
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
?: return null
if (image.chachaKey.startsWith("group:")) {
val groupPassword =
CryptoManager.decryptWithPassword(
image.chachaKey.removePrefix("group:"),
privateKey
) ?: return null
CryptoManager.decryptWithPassword(encryptedContent, groupPassword) ?: return null
} else {
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
AttachmentDownloadDebugLogger.log(
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
)
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
?: return null
}
val decodedBitmap = base64ToBitmapSafe(decrypted) ?: return null

View File

@@ -40,7 +40,9 @@ data class ChatMessage(
val replyData: ReplyData? = null,
val forwardedMessages: List<ReplyData> = emptyList(), // Multiple forwarded messages (desktop parity)
val attachments: List<MessageAttachment> = emptyList(),
val chachaKey: String = "" // Для расшифровки attachments
val chachaKey: String = "", // Для расшифровки attachments
val senderPublicKey: String = "",
val senderName: String = ""
)
/** Message delivery and read status */