feat: Implement message forwarding feature with chat selection and re-encryption logic

This commit is contained in:
k1ngsterr1
2026-01-16 03:29:32 +05:00
parent 4b2f5785ae
commit 81d2b744ba
6 changed files with 530 additions and 21 deletions

View File

@@ -341,7 +341,19 @@ fun MainScreen(
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme,
onBack = { selectedUser = null }
onBack = { selectedUser = null },
onNavigateToChat = { publicKey ->
// 📨 Forward: переход в выбранный чат
// Нужно получить SearchUser из публичного ключа
// Используем минимальные данные - остальное подгрузится в ChatDetailScreen
selectedUser = SearchUser(
title = "",
username = "",
publicKey = publicKey,
verified = 0,
online = 0
)
}
)
}
isSearchOpen -> {

View File

@@ -0,0 +1,108 @@
package com.rosetta.messenger.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 📨 Менеджер для пересылки сообщений (Forward)
*
* Логика как в десктопе:
* 1. Пользователь выбирает сообщения в чате
* 2. Нажимает Forward
* 3. Открывается список чатов
* 4. Выбирает чат куда переслать
* 5. Переходит в выбранный чат с сообщениями в Reply панели (как Forward)
*
* Singleton для передачи данных между экранами
*/
object ForwardManager {
/**
* Сообщение для пересылки
*/
data class ForwardMessage(
val messageId: String,
val text: String,
val timestamp: Long,
val isOutgoing: Boolean,
val senderPublicKey: String, // publicKey отправителя сообщения
val originalChatPublicKey: String // publicKey чата откуда пересылается
)
// Сообщения для пересылки
private val _forwardMessages = MutableStateFlow<List<ForwardMessage>>(emptyList())
val forwardMessages: StateFlow<List<ForwardMessage>> = _forwardMessages.asStateFlow()
// Флаг показа выбора чата
private val _showChatPicker = MutableStateFlow(false)
val showChatPicker: StateFlow<Boolean> = _showChatPicker.asStateFlow()
// Выбранный чат (publicKey собеседника)
private val _selectedChatPublicKey = MutableStateFlow<String?>(null)
val selectedChatPublicKey: StateFlow<String?> = _selectedChatPublicKey.asStateFlow()
/**
* Установить сообщения для пересылки и показать выбор чата
*/
fun setForwardMessages(
messages: List<ForwardMessage>,
showPicker: Boolean = true
) {
android.util.Log.d("ForwardManager", "📨 Setting forward messages: ${messages.size}")
_forwardMessages.value = messages
if (showPicker) {
_showChatPicker.value = true
}
}
/**
* Выбрать чат для пересылки
*/
fun selectChat(publicKey: String) {
android.util.Log.d("ForwardManager", "📨 Selected chat: $publicKey")
_selectedChatPublicKey.value = publicKey
_showChatPicker.value = false
}
/**
* Скрыть выбор чата (отмена)
*/
fun hideChatPicker() {
android.util.Log.d("ForwardManager", "📨 Hide chat picker")
_showChatPicker.value = false
}
/**
* Получить сообщения и очистить состояние
* Вызывается при открытии выбранного чата
*/
fun consumeForwardMessages(): List<ForwardMessage> {
val messages = _forwardMessages.value
android.util.Log.d("ForwardManager", "📨 Consuming forward messages: ${messages.size}")
return messages
}
/**
* Очистить все данные (после применения или отмены)
*/
fun clear() {
android.util.Log.d("ForwardManager", "📨 Clearing forward state")
_forwardMessages.value = emptyList()
_showChatPicker.value = false
_selectedChatPublicKey.value = null
}
/**
* Проверить есть ли сообщения для пересылки
*/
fun hasForwardMessages(): Boolean = _forwardMessages.value.isNotEmpty()
/**
* Проверить есть ли сообщения для конкретного чата
*/
fun hasForwardMessagesForChat(publicKey: String): Boolean {
val selectedKey = _selectedChatPublicKey.value
return selectedKey == publicKey && _forwardMessages.value.isNotEmpty()
}
}

View File

@@ -600,8 +600,11 @@ class MessageRepository private constructor(private val context: Context) {
}
/**
* Сериализация attachments в JSON с расшифровкой MESSAGES blob
* Для MESSAGES типа blob расшифровывается и сохраняется в preview (как в RN)
* Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД
* Для MESSAGES типа:
* 1. Расшифровываем blob с ChaCha ключом сообщения
* 2. Re-encrypt с приватным ключом (как в Desktop Архиве)
* 3. Сохраняем зашифрованный blob в БД
*/
private fun serializeAttachmentsWithDecryption(
attachments: List<MessageAttachment>,
@@ -614,9 +617,10 @@ class MessageRepository private constructor(private val context: Context) {
for (attachment in attachments) {
val jsonObj = JSONObject()
// Для MESSAGES типа расшифровываем blob
// Для MESSAGES типа расшифровываем и re-encrypt
if (attachment.type == AttachmentType.MESSAGES && attachment.blob.isNotEmpty()) {
try {
// 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
@@ -624,11 +628,14 @@ class MessageRepository private constructor(private val context: Context) {
)
if (decryptedBlob != null) {
// Сохраняем расшифрованный JSON в preview (как в RN)
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)
val reEncryptedBlob = CryptoManager.encryptWithPassword(decryptedBlob, privateKey)
// 3. Сохраняем ЗАШИФРОВАННЫЙ blob в БД
jsonObj.put("id", attachment.id)
jsonObj.put("blob", decryptedBlob) // Расшифрованный JSON
jsonObj.put("blob", reEncryptedBlob) // 🔒 Зашифрован приватным ключом!
jsonObj.put("type", attachment.type.value)
jsonObj.put("preview", decryptedBlob) // Для совместимости
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
} else {

View File

@@ -79,6 +79,7 @@ import android.content.Context
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.data.ForwardManager
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.delay
@@ -219,6 +220,7 @@ fun ChatDetailScreen(
isDarkTheme: Boolean,
onBack: () -> Unit,
onUserProfileClick: () -> Unit = {},
onNavigateToChat: (String) -> Unit = {}, // 📨 Callback для навигации в другой чат (Forward)
viewModel: ChatViewModel = viewModel()
) {
// 🔥 ОПТИМИЗАЦИЯ: Убрано логирование из композиции чтобы не засорять logcat
@@ -359,6 +361,20 @@ fun ChatDetailScreen(
// Состояние показа логов
var showLogs by remember { mutableStateOf(false) }
// 📨 Forward: показывать ли выбор чата
var showForwardPicker by remember { mutableStateOf(false) }
// 📨 Forward: список диалогов для выбора (загружаем из базы)
val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState()
// 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов
LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) {
if (currentUserPublicKey.isNotEmpty() && currentUserPrivateKey.isNotEmpty()) {
chatsListViewModel.setAccount(currentUserPublicKey, currentUserPrivateKey)
}
}
// 🚀 Собираем логи ТОЛЬКО когда они показываются - иначе каждый лог вызывает перекомпозицию!
val debugLogs = if (showLogs) {
com.rosetta.messenger.network.ProtocolManager.debugLogs.collectAsState().value
@@ -1099,7 +1115,7 @@ fun ChatDetailScreen(
}
}
// Forward button
// Forward button - открывает выбор чата (как в десктопе)
Box(
modifier = Modifier
.weight(1f)
@@ -1107,11 +1123,24 @@ fun ChatDetailScreen(
.clip(RoundedCornerShape(12.dp))
.background(PrimaryBlue.copy(alpha = 0.1f))
.clickable {
// 📨 Сохраняем сообщения в ForwardManager и показываем выбор чата
val selectedMsgs = messages
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
.sortedBy { it.timestamp }
viewModel.setForwardMessages(selectedMsgs)
val forwardMessages = selectedMsgs.map { msg ->
ForwardManager.ForwardMessage(
messageId = msg.id,
text = msg.text,
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
senderPublicKey = if (msg.isOutgoing) currentUserPublicKey else user.publicKey,
originalChatPublicKey = user.publicKey
)
}
ForwardManager.setForwardMessages(forwardMessages, showPicker = false)
selectedMessages = emptySet()
showForwardPicker = true
},
contentAlignment = Alignment.Center
) {
@@ -1580,6 +1609,25 @@ fun ChatDetailScreen(
}
)
}
// 📨 Forward Chat Picker BottomSheet
if (showForwardPicker) {
ForwardChatPickerBottomSheet(
dialogs = dialogsList,
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
onDismiss = {
showForwardPicker = false
ForwardManager.clear()
},
onChatSelected = { selectedPublicKey ->
showForwardPicker = false
// Переходим в выбранный чат
ForwardManager.selectChat(selectedPublicKey)
onNavigateToChat(selectedPublicKey)
}
)
}
}
/** 🚀 Анимация появления сообщения Telegram-style */

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.database.DialogEntity
import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
@@ -365,6 +366,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
readReceiptSentForCurrentDialog = false
isDialogActive = true // 🔥 Диалог активен!
// 📨 Проверяем ForwardManager - если есть сообщения для пересылки в этот чат
if (ForwardManager.hasForwardMessagesForChat(publicKey)) {
val forwardMessages = ForwardManager.consumeForwardMessages()
if (forwardMessages.isNotEmpty()) {
android.util.Log.d("ChatViewModel", "📨 Received ${forwardMessages.size} forward messages")
// Конвертируем ForwardMessage в ReplyMessage
_replyMessages.value = forwardMessages.map { fm ->
ReplyMessage(
messageId = fm.messageId,
text = fm.text,
timestamp = fm.timestamp,
isOutgoing = fm.isOutgoing,
publicKey = fm.senderPublicKey
)
}
_isForwardMode.value = true
// Очищаем ForwardManager после применения
ForwardManager.clear()
}
}
// Подписываемся на онлайн статус
subscribeToOnlineStatus()
@@ -716,12 +737,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (dataJson.isEmpty()) continue
// 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат "iv:ciphertext"
// Это старые сообщения (полученные до фикса) которые нельзя расшифровать
// Расшифровываем с приватным ключом (как в Desktop Архиве)
if (dataJson.contains(":") && dataJson.split(":").size == 2) {
android.util.Log.d("ReplyDebug", " - Blob is encrypted (old format), skipping...")
android.util.Log.d("ReplyDebug", " - Cannot decrypt old reply messages - they were saved before fix")
// Пропускаем старые зашифрованные сообщения
continue
android.util.Log.d("ReplyDebug", " - Blob is encrypted, decrypting with private key...")
val privateKey = myPrivateKey
if (privateKey != null) {
try {
dataJson = CryptoManager.decryptWithPassword(dataJson, privateKey) ?: dataJson
android.util.Log.d("ReplyDebug", " - Decrypted successfully, length: ${dataJson.length}")
} catch (e: Exception) {
android.util.Log.e("ReplyDebug", " - Failed to decrypt blob", e)
continue
}
} else {
android.util.Log.e("ReplyDebug", " - Cannot decrypt: private key is null")
continue
}
}
val messagesArray = JSONArray(dataJson)
@@ -939,7 +970,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Формируем attachments с reply (как в React Native)
val messageAttachments = mutableListOf<MessageAttachment>()
var replyBlobPlaintext = "" // Сохраняем plaintext для БД
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
if (replyMsgsToSend.isNotEmpty()) {
android.util.Log.d("ReplyDebug", "📤 [SEND] Creating reply attachment:")
@@ -959,16 +990,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
replyJsonArray.put(replyJson)
}
replyBlobPlaintext = replyJsonArray.toString() // 🔥 Сохраняем plaintext
val replyBlobPlaintext = replyJsonArray.toString()
android.util.Log.d("ReplyDebug", " - Reply blob plaintext length: ${replyBlobPlaintext.length}")
android.util.Log.d("ReplyDebug", " - Reply blob preview: ${replyBlobPlaintext.take(100)}")
android.util.Log.d("ReplyDebug", " - Encrypting reply blob with plainKeyAndNonce (${plainKeyAndNonce.size} bytes)")
// 🔥 Шифруем reply blob (для network transmission)
// 🔥 Шифруем reply blob (для network transmission) с ChaCha ключом
android.util.Log.d("ReplyDebug", " - Encrypting reply blob with plainKeyAndNonce (${plainKeyAndNonce.size} bytes)")
val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
android.util.Log.d("ReplyDebug", " - Encrypted blob length: ${encryptedReplyBlob.length}")
android.util.Log.d("ReplyDebug", " - Encrypted blob preview: ${encryptedReplyBlob.take(100)}")
// 🔥 Re-encrypt с приватным ключом для хранения в БД (как в Desktop Архиве)
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
android.util.Log.d("ReplyDebug", " - Re-encrypted for DB length: ${replyBlobForDatabase.length}")
val replyAttachmentId = "reply_${timestamp}"
messageAttachments.add(MessageAttachment(
id = replyAttachmentId,
@@ -1012,7 +1047,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
// 4. 💾 Сохранение в БД с attachments (plaintext blob для MESSAGES)
// 4. 💾 Сохранение в БД с attachments (зашифрованный blob для MESSAGES)
val attachmentsJson = if (messageAttachments.isNotEmpty()) {
JSONArray().apply {
messageAttachments.forEach { att ->
@@ -1020,8 +1055,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("id", att.id)
put("type", att.type.value)
put("preview", att.preview)
// 🔥 Для MESSAGES сохраняем plaintext, для остальных - как есть
put("blob", if (att.type == AttachmentType.MESSAGES) replyBlobPlaintext else att.blob)
// 🔥 Для MESSAGES сохраняем зашифрованный приватным ключом, для остальных - как есть
put("blob", if (att.type == AttachmentType.MESSAGES) replyBlobForDatabase else att.blob)
})
}
}.toString()

View File

@@ -0,0 +1,299 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Forward
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import java.text.SimpleDateFormat
import java.util.*
/**
* 📨 BottomSheet для выбора чата при Forward сообщений
*
* Логика как в десктопной версии:
* 1. Показывает список диалогов
* 2. При выборе диалога - переходит в чат с сообщениями в Reply панели
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ForwardChatPickerBottomSheet(
dialogs: List<DialogUiModel>,
isDarkTheme: Boolean,
currentUserPublicKey: String,
onDismiss: () -> Unit,
onChatSelected: (String) -> Unit
) {
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = false
)
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val forwardMessages by ForwardManager.forwardMessages.collectAsState()
val messagesCount = forwardMessages.size
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = backgroundColor,
dragHandle = {
// Кастомный handle
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.width(36.dp)
.height(5.dp)
.clip(RoundedCornerShape(2.5.dp))
.background(
if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD1D1D6)
)
)
Spacer(modifier = Modifier.height(16.dp))
}
},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Иконка и заголовок
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Forward,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "Forward to",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
Text(
text = "$messagesCount message${if (messagesCount > 1) "s" else ""} selected",
fontSize = 14.sp,
color = secondaryTextColor
)
}
}
// Кнопка закрытия
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = secondaryTextColor
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Divider(
color = dividerColor,
thickness = 0.5.dp
)
// Список диалогов
if (dialogs.isEmpty()) {
// Empty state
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No chats yet",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = secondaryTextColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Start a conversation first",
fontSize = 14.sp,
color = secondaryTextColor.copy(alpha = 0.7f)
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
) {
items(dialogs, key = { it.opponentKey }) { dialog ->
ForwardDialogItem(
dialog = dialog,
isDarkTheme = isDarkTheme,
isSavedMessages = dialog.opponentKey == currentUserPublicKey,
onClick = {
onChatSelected(dialog.opponentKey)
}
)
// Сепаратор между диалогами
if (dialog != dialogs.last()) {
Divider(
modifier = Modifier.padding(start = 76.dp),
color = dividerColor,
thickness = 0.5.dp
)
}
}
}
}
// Нижний padding
Spacer(modifier = Modifier.height(16.dp))
}
}
}
/**
* Элемент диалога в списке выбора для Forward
*/
@Composable
private fun ForwardDialogItem(
dialog: DialogUiModel,
isDarkTheme: Boolean,
isSavedMessages: Boolean = false,
onClick: () -> Unit
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val avatarColors = remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme)
}
val displayName = remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) {
when {
isSavedMessages -> "Saved Messages"
dialog.opponentTitle.isNotEmpty() -> dialog.opponentTitle
else -> dialog.opponentKey.take(8)
}
}
val initials = remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) {
when {
isSavedMessages -> "📁"
dialog.opponentTitle.isNotEmpty() -> {
dialog.opponentTitle
.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
}
else -> dialog.opponentKey.take(2).uppercase()
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (isSavedMessages) PrimaryBlue.copy(alpha = 0.15f)
else avatarColors.backgroundColor
),
contentAlignment = Alignment.Center
) {
Text(
text = initials,
color = if (isSavedMessages) PrimaryBlue else avatarColors.textColor,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp
)
}
Spacer(modifier = Modifier.width(12.dp))
// Info
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = displayName,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (isSavedMessages) "Your personal notes" else dialog.lastMessage.ifEmpty { "No messages" },
fontSize = 14.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// Online indicator
if (!isSavedMessages && dialog.isOnline == 1) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(Color(0xFF34C759))
)
}
}
}