feat: Implement message forwarding feature with chat selection and re-encryption logic
This commit is contained in:
@@ -341,7 +341,19 @@ fun MainScreen(
|
|||||||
currentUserPublicKey = accountPublicKey,
|
currentUserPublicKey = accountPublicKey,
|
||||||
currentUserPrivateKey = accountPrivateKey,
|
currentUserPrivateKey = accountPrivateKey,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onBack = { selectedUser = null }
|
onBack = { selectedUser = null },
|
||||||
|
onNavigateToChat = { publicKey ->
|
||||||
|
// 📨 Forward: переход в выбранный чат
|
||||||
|
// Нужно получить SearchUser из публичного ключа
|
||||||
|
// Используем минимальные данные - остальное подгрузится в ChatDetailScreen
|
||||||
|
selectedUser = SearchUser(
|
||||||
|
title = "",
|
||||||
|
username = "",
|
||||||
|
publicKey = publicKey,
|
||||||
|
verified = 0,
|
||||||
|
online = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
isSearchOpen -> {
|
isSearchOpen -> {
|
||||||
|
|||||||
108
app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt
Normal file
108
app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -600,8 +600,11 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сериализация attachments в JSON с расшифровкой MESSAGES blob
|
* Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД
|
||||||
* Для MESSAGES типа blob расшифровывается и сохраняется в preview (как в RN)
|
* Для MESSAGES типа:
|
||||||
|
* 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||||
|
* 2. Re-encrypt с приватным ключом (как в Desktop Архиве)
|
||||||
|
* 3. Сохраняем зашифрованный blob в БД
|
||||||
*/
|
*/
|
||||||
private fun serializeAttachmentsWithDecryption(
|
private fun serializeAttachmentsWithDecryption(
|
||||||
attachments: List<MessageAttachment>,
|
attachments: List<MessageAttachment>,
|
||||||
@@ -614,9 +617,10 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
for (attachment in attachments) {
|
for (attachment in attachments) {
|
||||||
val jsonObj = JSONObject()
|
val jsonObj = JSONObject()
|
||||||
|
|
||||||
// Для MESSAGES типа расшифровываем blob
|
// Для MESSAGES типа расшифровываем и re-encrypt
|
||||||
if (attachment.type == AttachmentType.MESSAGES && attachment.blob.isNotEmpty()) {
|
if (attachment.type == AttachmentType.MESSAGES && attachment.blob.isNotEmpty()) {
|
||||||
try {
|
try {
|
||||||
|
// 1. Расшифровываем с ChaCha ключом сообщения
|
||||||
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
|
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
|
||||||
attachment.blob,
|
attachment.blob,
|
||||||
encryptedKey,
|
encryptedKey,
|
||||||
@@ -624,11 +628,14 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (decryptedBlob != null) {
|
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("id", attachment.id)
|
||||||
jsonObj.put("blob", decryptedBlob) // Расшифрованный JSON
|
jsonObj.put("blob", reEncryptedBlob) // 🔒 Зашифрован приватным ключом!
|
||||||
jsonObj.put("type", attachment.type.value)
|
jsonObj.put("type", attachment.type.value)
|
||||||
jsonObj.put("preview", decryptedBlob) // Для совместимости
|
jsonObj.put("preview", attachment.preview)
|
||||||
jsonObj.put("width", attachment.width)
|
jsonObj.put("width", attachment.width)
|
||||||
jsonObj.put("height", attachment.height)
|
jsonObj.put("height", attachment.height)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import android.content.Context
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import com.airbnb.lottie.compose.*
|
import com.airbnb.lottie.compose.*
|
||||||
|
import com.rosetta.messenger.data.ForwardManager
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -219,6 +220,7 @@ fun ChatDetailScreen(
|
|||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onUserProfileClick: () -> Unit = {},
|
onUserProfileClick: () -> Unit = {},
|
||||||
|
onNavigateToChat: (String) -> Unit = {}, // 📨 Callback для навигации в другой чат (Forward)
|
||||||
viewModel: ChatViewModel = viewModel()
|
viewModel: ChatViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
// 🔥 ОПТИМИЗАЦИЯ: Убрано логирование из композиции чтобы не засорять logcat
|
// 🔥 ОПТИМИЗАЦИЯ: Убрано логирование из композиции чтобы не засорять logcat
|
||||||
@@ -359,6 +361,20 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
// Состояние показа логов
|
// Состояние показа логов
|
||||||
var showLogs by remember { mutableStateOf(false) }
|
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) {
|
val debugLogs = if (showLogs) {
|
||||||
com.rosetta.messenger.network.ProtocolManager.debugLogs.collectAsState().value
|
com.rosetta.messenger.network.ProtocolManager.debugLogs.collectAsState().value
|
||||||
@@ -1099,7 +1115,7 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward button
|
// Forward button - открывает выбор чата (как в десктопе)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -1107,11 +1123,24 @@ fun ChatDetailScreen(
|
|||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(PrimaryBlue.copy(alpha = 0.1f))
|
.background(PrimaryBlue.copy(alpha = 0.1f))
|
||||||
.clickable {
|
.clickable {
|
||||||
|
// 📨 Сохраняем сообщения в ForwardManager и показываем выбор чата
|
||||||
val selectedMsgs = messages
|
val selectedMsgs = messages
|
||||||
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
||||||
.sortedBy { it.timestamp }
|
.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()
|
selectedMessages = emptySet()
|
||||||
|
showForwardPicker = true
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
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 */
|
/** 🚀 Анимация появления сообщения Telegram-style */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.crypto.MessageCrypto
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
|
import com.rosetta.messenger.data.ForwardManager
|
||||||
import com.rosetta.messenger.database.DialogEntity
|
import com.rosetta.messenger.database.DialogEntity
|
||||||
import com.rosetta.messenger.database.MessageEntity
|
import com.rosetta.messenger.database.MessageEntity
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
@@ -365,6 +366,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
readReceiptSentForCurrentDialog = false
|
readReceiptSentForCurrentDialog = false
|
||||||
isDialogActive = true // 🔥 Диалог активен!
|
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()
|
subscribeToOnlineStatus()
|
||||||
@@ -716,12 +737,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
if (dataJson.isEmpty()) continue
|
if (dataJson.isEmpty()) continue
|
||||||
|
|
||||||
// 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат "iv:ciphertext"
|
// 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат "iv:ciphertext"
|
||||||
// Это старые сообщения (полученные до фикса) которые нельзя расшифровать
|
// Расшифровываем с приватным ключом (как в Desktop Архиве)
|
||||||
if (dataJson.contains(":") && dataJson.split(":").size == 2) {
|
if (dataJson.contains(":") && dataJson.split(":").size == 2) {
|
||||||
android.util.Log.d("ReplyDebug", " - Blob is encrypted (old format), skipping...")
|
android.util.Log.d("ReplyDebug", " - Blob is encrypted, decrypting with private key...")
|
||||||
android.util.Log.d("ReplyDebug", " - Cannot decrypt old reply messages - they were saved before fix")
|
val privateKey = myPrivateKey
|
||||||
// Пропускаем старые зашифрованные сообщения
|
if (privateKey != null) {
|
||||||
continue
|
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)
|
val messagesArray = JSONArray(dataJson)
|
||||||
@@ -939,7 +970,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// 🔥 Формируем attachments с reply (как в React Native)
|
// 🔥 Формируем attachments с reply (как в React Native)
|
||||||
val messageAttachments = mutableListOf<MessageAttachment>()
|
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||||
var replyBlobPlaintext = "" // Сохраняем plaintext для БД
|
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
|
||||||
|
|
||||||
if (replyMsgsToSend.isNotEmpty()) {
|
if (replyMsgsToSend.isNotEmpty()) {
|
||||||
android.util.Log.d("ReplyDebug", "📤 [SEND] Creating reply attachment:")
|
android.util.Log.d("ReplyDebug", "📤 [SEND] Creating reply attachment:")
|
||||||
@@ -959,16 +990,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
replyJsonArray.put(replyJson)
|
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 plaintext length: ${replyBlobPlaintext.length}")
|
||||||
android.util.Log.d("ReplyDebug", " - Reply blob preview: ${replyBlobPlaintext.take(100)}")
|
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)
|
val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
|
||||||
android.util.Log.d("ReplyDebug", " - Encrypted blob length: ${encryptedReplyBlob.length}")
|
android.util.Log.d("ReplyDebug", " - Encrypted blob length: ${encryptedReplyBlob.length}")
|
||||||
android.util.Log.d("ReplyDebug", " - Encrypted blob preview: ${encryptedReplyBlob.take(100)}")
|
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}"
|
val replyAttachmentId = "reply_${timestamp}"
|
||||||
messageAttachments.add(MessageAttachment(
|
messageAttachments.add(MessageAttachment(
|
||||||
id = replyAttachmentId,
|
id = replyAttachmentId,
|
||||||
@@ -1012,7 +1047,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 💾 Сохранение в БД с attachments (plaintext blob для MESSAGES)
|
// 4. 💾 Сохранение в БД с attachments (зашифрованный blob для MESSAGES)
|
||||||
val attachmentsJson = if (messageAttachments.isNotEmpty()) {
|
val attachmentsJson = if (messageAttachments.isNotEmpty()) {
|
||||||
JSONArray().apply {
|
JSONArray().apply {
|
||||||
messageAttachments.forEach { att ->
|
messageAttachments.forEach { att ->
|
||||||
@@ -1020,8 +1055,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
put("id", att.id)
|
put("id", att.id)
|
||||||
put("type", att.type.value)
|
put("type", att.type.value)
|
||||||
put("preview", att.preview)
|
put("preview", att.preview)
|
||||||
// 🔥 Для MESSAGES сохраняем plaintext, для остальных - как есть
|
// 🔥 Для MESSAGES сохраняем зашифрованный приватным ключом, для остальных - как есть
|
||||||
put("blob", if (att.type == AttachmentType.MESSAGES) replyBlobPlaintext else att.blob)
|
put("blob", if (att.type == AttachmentType.MESSAGES) replyBlobForDatabase else att.blob)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}.toString()
|
}.toString()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user