feat: enhance forwarded messages display by enabling link support

This commit is contained in:
2026-02-12 08:15:11 +05:00
parent 6f195f4d09
commit f7ece6055e
7 changed files with 564 additions and 214 deletions

View File

@@ -888,6 +888,7 @@ fun MainScreen(
user = currentChatUser, user = currentChatUser,
currentUserPublicKey = accountPublicKey, currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey, currentUserPrivateKey = accountPrivateKey,
currentUserName = accountName,
onBack = { popChatAndChildren() }, onBack = { popChatAndChildren() },
onUserProfileClick = { user -> onUserProfileClick = { user ->
// Открываем профиль другого пользователя // Открываем профиль другого пользователя
@@ -971,7 +972,13 @@ fun MainScreen(
}, },
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
currentUserPublicKey = accountPublicKey, currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey currentUserPrivateKey = accountPrivateKey,
onWriteMessage = { chatUser ->
// Close profile and navigate to chat
navStack = navStack.filterNot {
it is Screen.OtherProfile || it is Screen.ChatDetail
} + Screen.ChatDetail(chatUser)
}
) )
} }
} }

View File

@@ -519,81 +519,27 @@ object MessageCrypto {
* @param encryptedData зашифрованный контент (ivBase64:ciphertextBase64) * @param encryptedData зашифрованный контент (ivBase64:ciphertextBase64)
* @param chachaKeyPlain Уже расшифрованный ChaCha ключ+nonce (56 bytes: 32 key + 24 nonce) * @param chachaKeyPlain Уже расшифрованный ChaCha ключ+nonce (56 bytes: 32 key + 24 nonce)
*/ */
/**
* Расшифровка аттачмента зашифрованного через encodeWithPassword (desktop parity)
*
* Десктоп: decodeWithPassword(keyPlain, data)
* 1. keyPlain = chachaDecryptedKey.toString('utf-8') — JS Buffer → UTF-8 string
* 2. PBKDF2(keyPlain, 'rosetta', {keySize: 256/32, iterations: 1000}) — SHA256
* 3. AES-CBC decrypt
* 4. pako.inflate → string
*
* Ровно то же самое делаем здесь.
*/
fun decryptAttachmentBlobWithPlainKey( fun decryptAttachmentBlobWithPlainKey(
encryptedData: String, encryptedData: String,
chachaKeyPlain: ByteArray chachaKeyPlain: ByteArray
): String? { ): String? {
// Один путь, как в десктопе: bytesToJsUtf8String → PBKDF2-SHA256 → AES-CBC → inflate
return try { return try {
val password = bytesToJsUtf8String(chachaKeyPlain)
// Desktop использует key.toString('binary') → encrypt → decrypt → toString('utf-8') → PBKDF2 val pbkdf2Key = generatePBKDF2Key(password)
// Это эквивалентно: raw bytes → Latin1 string → UTF-8 encode → шифрование → decryptWithPBKDF2Key(encryptedData, pbkdf2Key)
// расшифровка → UTF-8 decode → опять UTF-8 для PBKDF2 } catch (_: Exception) {
//
// Но crypto-js PBKDF2 принимает string и делает UTF-8 encode для получения password bytes
// Desktop: PBKDF2(string) → внутри делает UTF-8 encode этой string → использует эти bytes
//
// КРИТИЧНО: Desktop сохраняет chachaDecryptedKey.toString('utf-8') в БД
// И потом использует ЭТУ СТРОКУ напрямую как password для PBKDF2!
//
// Пробуем РАЗНЫЕ варианты и логируем результат
// Вариант 1: UTF-8 decode (как Node.js Buffer.toString('utf-8'))
val password1 = bytesToJsUtf8String(chachaKeyPlain)
val passwordBytes1 = password1.toByteArray(Charsets.UTF_8)
// Вариант 2: Latin1 decode (каждый byte = char 0-255)
val password2 = String(chachaKeyPlain, Charsets.ISO_8859_1)
val passwordBytes2 = password2.toByteArray(Charsets.UTF_8)
// Вариант 3: Raw bytes напрямую (без string conversion)
// Пробуем расшифровать с КАЖДЫМ вариантом
val pbkdf2Key1 = generatePBKDF2Key(password1)
val result1 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key1)
if (result1 != null) {
return result1
}
val pbkdf2Key2 = generatePBKDF2Key(password2)
val result2 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key2)
if (result2 != null) {
return result2
}
val pbkdf2Key3 = generatePBKDF2KeyFromBytes(chachaKeyPlain)
val result3 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key3)
if (result3 != null) {
return result3
}
// V4: Стандартный Java PBKDF2 (PBEKeySpec с char[]) - для совместимости с Android encryptReplyBlob
val pbkdf2Key4 = generatePBKDF2KeyJava(password2)
val result4 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key4)
if (result4 != null) {
return result4
}
// V5: Стандартный Java PBKDF2 с UTF-8 password
val pbkdf2Key5 = generatePBKDF2KeyJava(password1)
val result5 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key5)
if (result5 != null) {
return result5
}
// V6: Java CharsetDecoder with REPLACE для UTF-8 (может отличаться от bytesToJsUtf8String)
val decoder = java.nio.charset.StandardCharsets.UTF_8.newDecoder()
decoder.onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE)
decoder.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPLACE)
val password6 = decoder.decode(java.nio.ByteBuffer.wrap(chachaKeyPlain)).toString()
val passwordBytes6 = password6.toByteArray(Charsets.UTF_8)
val pbkdf2Key6 = generatePBKDF2Key(password6)
val result6 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key6)
if (result6 != null) {
return result6
}
null
} catch (e: Exception) {
null null
} }
} }

View File

@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
/** /**
* Singleton manager for Protocol instance * Singleton manager for Protocol instance
@@ -34,6 +36,12 @@ object ProtocolManager {
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet()) private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow() val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
// 🔍 Global user info cache (like Desktop's InformationProvider.cachedUsers)
// publicKey → SearchUser (resolved via PacketSearch 0x03)
private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
// Pending resolves: publicKey → list of continuations waiting for the result
private val pendingResolves = ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>()
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
// 🚀 Флаг для включения UI логов (по умолчанию ВЫКЛЮЧЕНО - это вызывало ANR!) // 🚀 Флаг для включения UI логов (по умолчанию ВЫКЛЮЧЕНО - это вызывало ANR!)
@@ -167,6 +175,14 @@ object ProtocolManager {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val ownPublicKey = getProtocol().getPublicKey() val ownPublicKey = getProtocol().getPublicKey()
searchPacket.users.forEach { user -> searchPacket.users.forEach { user ->
// 🔍 Кэшируем всех пользователей (desktop parity: cachedUsers)
userInfoCache[user.publicKey] = user
// Resume pending resolves for this publicKey
pendingResolves.remove(user.publicKey)?.forEach { cont ->
try { cont.resume(user) } catch (_: Exception) {}
}
// Обновляем инфо в диалогах (для всех пользователей) // Обновляем инфо в диалогах (для всех пользователей)
messageRepository?.updateDialogUserInfo( messageRepository?.updateDialogUserInfo(
user.publicKey, user.publicKey,
@@ -188,6 +204,13 @@ object ProtocolManager {
} }
} }
} }
// Resume pending resolves that got empty response (no match)
if (searchPacket.search.isNotEmpty() && searchPacket.users.none { it.publicKey == searchPacket.search }) {
pendingResolves.remove(searchPacket.search)?.forEach { cont ->
try { cont.resume(null) } catch (_: Exception) {}
}
}
} }
// 🚀 Обработчик транспортного сервера (0x0F) // 🚀 Обработчик транспортного сервера (0x0F)
@@ -255,6 +278,104 @@ object ProtocolManager {
send(packet) send(packet)
} }
/**
* 🔍 Resolve publicKey → user title (like Desktop useUserInformation)
* Checks cache first, then sends PacketSearch and waits for response.
* Returns title or null on timeout/not found.
* @param timeoutMs max wait time for server response (default 3s)
*/
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? {
if (publicKey.isEmpty()) return null
// 1. Check in-memory cache (instant)
userInfoCache[publicKey]?.let { cached ->
val name = cached.title.ifEmpty { cached.username }
if (name.isNotEmpty()) return name
}
// 2. Send PacketSearch and wait for response via suspendCancellableCoroutine
val privateHash = try { getProtocol().getPrivateHash() } catch (_: Exception) { null }
?: return null
return try {
withTimeout(timeoutMs) {
suspendCancellableCoroutine { cont ->
// Register continuation in pending list
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
cont.invokeOnCancellation {
pendingResolves[publicKey]?.remove(cont)
if (pendingResolves[publicKey]?.isEmpty() == true) {
pendingResolves.remove(publicKey)
}
}
// Send search request
val packet = PacketSearch().apply {
this.privateKey = privateHash
this.search = publicKey
}
send(packet)
}
}?.let { user -> user.title.ifEmpty { user.username }.ifEmpty { null } }
} catch (_: Exception) {
// Timeout or cancellation — clean up
pendingResolves.remove(publicKey)
null
}
}
/**
* 🔍 Get cached user info (no network request)
*/
fun getCachedUserName(publicKey: String): String? {
val cached = userInfoCache[publicKey] ?: return null
return cached.title.ifEmpty { cached.username }.ifEmpty { null }
}
/**
* 🔍 Get full cached user info (no network request)
*/
fun getCachedUserInfo(publicKey: String): SearchUser? {
return userInfoCache[publicKey]
}
/**
* 🔍 Resolve publicKey → full SearchUser (with server request if needed)
*/
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? {
if (publicKey.isEmpty()) return null
// 1. Check in-memory cache
userInfoCache[publicKey]?.let { return it }
// 2. Send PacketSearch and wait
val privateHash = try { getProtocol().getPrivateHash() } catch (_: Exception) { null }
?: return null
return try {
withTimeout(timeoutMs) {
suspendCancellableCoroutine { cont ->
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
cont.invokeOnCancellation {
pendingResolves[publicKey]?.remove(cont)
if (pendingResolves[publicKey]?.isEmpty() == true) {
pendingResolves.remove(publicKey)
}
}
val packet = PacketSearch().apply {
this.privateKey = privateHash
this.search = publicKey
}
send(packet)
}
}
} catch (_: Exception) {
pendingResolves.remove(publicKey)
null
}
}
/** /**
* Send packet (simplified) * Send packet (simplified)
*/ */

View File

@@ -102,6 +102,7 @@ fun ChatDetailScreen(
onUserProfileClick: (SearchUser) -> Unit = {}, onUserProfileClick: (SearchUser) -> Unit = {},
currentUserPublicKey: String, currentUserPublicKey: String,
currentUserPrivateKey: String, currentUserPrivateKey: String,
currentUserName: String = "",
totalUnreadFromOthers: Int = 0, totalUnreadFromOthers: Int = 0,
isDarkTheme: Boolean, isDarkTheme: Boolean,
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
@@ -1369,10 +1370,49 @@ fun ChatDetailScreen(
val forwardMessages = val forwardMessages =
selectedMsgs selectedMsgs
.map { .flatMap {
msg msg
-> ->
// Re-forward: preserve original senders
if (msg.forwardedMessages.isNotEmpty()) {
msg.forwardedMessages.map { fwd ->
ForwardManager ForwardManager
.ForwardMessage(
messageId =
fwd.messageId,
text =
fwd.text,
timestamp =
msg.timestamp
.time,
isOutgoing =
fwd.isFromMe,
senderPublicKey =
fwd.senderPublicKey.ifEmpty {
if (fwd.isFromMe) currentUserPublicKey else user.publicKey
},
originalChatPublicKey =
user.publicKey,
senderName =
fwd.forwardedFromName.ifEmpty { fwd.senderName.ifEmpty { "User" } },
attachments =
fwd.attachments
.filter {
it.type !=
AttachmentType
.MESSAGES
}
.map {
attachment ->
attachment.copy(
localUri =
""
)
}
)
}
} else {
listOf(ForwardManager
.ForwardMessage( .ForwardMessage(
messageId = messageId =
msg.id, msg.id,
@@ -1392,7 +1432,7 @@ fun ChatDetailScreen(
originalChatPublicKey = originalChatPublicKey =
user.publicKey, user.publicKey,
senderName = senderName =
if (msg.isOutgoing) "You" if (msg.isOutgoing) currentUserName.ifEmpty { "You" }
else user.title.ifEmpty { user.username.ifEmpty { "User" } }, else user.title.ifEmpty { user.username.ifEmpty { "User" } },
attachments = attachments =
msg.attachments msg.attachments
@@ -1408,7 +1448,8 @@ fun ChatDetailScreen(
"" ""
) )
} }
) ))
}
} }
ForwardManager ForwardManager
.setForwardMessages( .setForwardMessages(
@@ -1965,6 +2006,15 @@ fun ChatDetailScreen(
onImageViewerChanged( onImageViewerChanged(
true true
) )
},
onForwardedSenderClick = { senderPublicKey ->
// Open profile of the forwarded message sender
scope.launch {
val resolvedUser = viewModel.resolveUserForProfile(senderPublicKey)
if (resolvedUser != null) {
onUserProfileClick(resolvedUser)
}
}
} }
) )
} }

View File

@@ -1299,7 +1299,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val senderDisplayName = fwdSenderName.ifEmpty { val senderDisplayName = fwdSenderName.ifEmpty {
if (fwdIsFromMe) "You" if (fwdIsFromMe) "You"
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } } else if (fwdPublicKey.isNotEmpty()) {
// 1. Try local DB (existing dialog)
val dbName = try {
val dialog = dialogDao.getDialog(account, fwdPublicKey)
dialog?.opponentTitle?.ifEmpty { dialog.opponentUsername }?.ifEmpty { null }
} catch (_: Exception) { null }
// 2. Try ProtocolManager cache (previously resolved)
val cachedName = dbName ?: ProtocolManager.getCachedUserName(fwdPublicKey)
// 3. Server resolve via PacketSearch (like desktop useUserInformation)
val serverName = if (cachedName == null) {
try {
ProtocolManager.resolveUserName(fwdPublicKey, 3000)
} catch (_: Exception) { null }
} else null
cachedName ?: serverName ?: "User"
} else "User"
} }
forwardedList.add(ReplyData( forwardedList.add(ReplyData(
@@ -1310,7 +1328,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isForwarded = true, isForwarded = true,
forwardedFromName = senderDisplayName, forwardedFromName = senderDisplayName,
attachments = fwdAttachments, attachments = fwdAttachments,
senderPublicKey = if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "", senderPublicKey = fwdPublicKey.ifEmpty { if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "" },
recipientPrivateKey = myPrivateKey ?: "" recipientPrivateKey = myPrivateKey ?: ""
)) ))
} }
@@ -1326,9 +1344,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyText = replyMessage.optString("message", "") val replyText = replyMessage.optString("message", "")
val replyMessageIdFromJson = replyMessage.optString("message_id", "") val replyMessageIdFromJson = replyMessage.optString("message_id", "")
val replyTimestamp = replyMessage.optLong("timestamp", 0L) val replyTimestamp = replyMessage.optLong("timestamp", 0L)
val isForwarded = replyMessage.optBoolean("forwarded", false)
val senderNameFromJson = replyMessage.optString("senderName", "") val senderNameFromJson = replyMessage.optString("senderName", "")
// 🔥 Detect forward: explicit flag OR publicKey belongs to a third party
// Desktop doesn't send "forwarded" flag, but if publicKey differs from
// both myPublicKey and opponentKey — it's a forwarded message from someone else
val isFromThirdParty = replyPublicKey.isNotEmpty() &&
replyPublicKey != myPublicKey &&
replyPublicKey != opponentKey
val isForwarded = replyMessage.optBoolean("forwarded", false) || isFromThirdParty
// 📸 Парсим attachments из JSON reply (как в Desktop) // 📸 Парсим attachments из JSON reply (как в Desktop)
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>() val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
try { try {
@@ -1410,28 +1435,62 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
// 🔥 Resolve sender name by publicKey (like desktop useUserInformation)
// Always resolve from replyPublicKey, not hardcode opponentTitle
val resolvedSenderName = if (isReplyFromMe) {
"You"
} else if (replyPublicKey == opponentKey) {
// Reply to opponent's message in this chat — use known opponent info
opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
} else if (replyPublicKey.isNotEmpty()) {
// Third-party publicKey — resolve like desktop useUserInformation
senderNameFromJson.ifEmpty {
// 1. Try local DB (existing dialog with this user)
val dbName = try {
val fwdDialog = dialogDao.getDialog(account, replyPublicKey)
fwdDialog?.opponentTitle?.ifEmpty { fwdDialog.opponentUsername }?.ifEmpty { null }
} catch (_: Exception) { null }
// 2. Try ProtocolManager cache
val cachedName = dbName ?: ProtocolManager.getCachedUserName(replyPublicKey)
// 3. Server resolve via PacketSearch (like desktop useUserInformation)
val serverName = if (cachedName == null) {
try {
ProtocolManager.resolveUserName(replyPublicKey, 3000)
} catch (_: Exception) { null }
} else null
cachedName ?: serverName ?: "User"
}
} else {
senderNameFromJson.ifEmpty { "User" }
}
// For forwarded messages, determine the forwardedFromName
val forwardFromDisplay = if (isForwarded) resolvedSenderName else ""
val result = val result =
ReplyData( ReplyData(
messageId = realMessageId, messageId = realMessageId,
senderName = senderName = resolvedSenderName,
if (isReplyFromMe) "You"
else
opponentTitle.ifEmpty {
opponentUsername.ifEmpty { "User" }
},
text = replyText, text = replyText,
isFromMe = isReplyFromMe, isFromMe = isReplyFromMe,
isForwarded = isForwarded, isForwarded = isForwarded,
forwardedFromName = if (isForwarded) senderNameFromJson.ifEmpty { forwardedFromName = forwardFromDisplay,
if (isReplyFromMe) "You"
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
} else "",
attachments = originalAttachments, attachments = originalAttachments,
senderPublicKey = senderPublicKey = replyPublicKey.ifEmpty {
if (isReplyFromMe) myPublicKey ?: "" if (isReplyFromMe) myPublicKey ?: ""
else opponentKey ?: "", else opponentKey ?: ""
},
recipientPrivateKey = myPrivateKey ?: "" recipientPrivateKey = myPrivateKey ?: ""
) )
// 🔥 If this is a forwarded message (from third party), return as forwardedMessages list
// so it renders with "Forwarded from" header (like multi-forward)
if (isForwarded) {
return ParsedReplyResult(
replyData = result,
forwardedMessages = listOf(result)
)
}
return ParsedReplyResult(replyData = result) return ParsedReplyResult(replyData = result)
} else {} } else {}
} else {} } else {}
@@ -1528,6 +1587,79 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
/**
* Resolve a publicKey to a SearchUser for profile navigation.
* Tries: local DB → ProtocolManager cache → server resolve.
*/
suspend fun resolveUserForProfile(publicKey: String): SearchUser? {
if (publicKey.isEmpty()) return null
// If it's the current opponent, we already have info
if (publicKey == opponentKey) {
return SearchUser(
title = opponentTitle,
username = opponentUsername,
publicKey = publicKey,
verified = 0,
online = 0
)
}
val account = myPublicKey ?: return null
// 1. Try local DB
try {
val dialog = dialogDao.getDialog(account, publicKey)
if (dialog != null) {
val t = dialog.opponentTitle.ifEmpty { dialog.opponentUsername }
if (t.isNotEmpty()) {
return SearchUser(
title = dialog.opponentTitle,
username = dialog.opponentUsername,
publicKey = publicKey,
verified = 0,
online = 0
)
}
}
} catch (_: Exception) {}
// 2. Try ProtocolManager cache
val cached = ProtocolManager.getCachedUserInfo(publicKey)
if (cached != null) {
return SearchUser(
title = cached.title,
username = cached.username,
publicKey = publicKey,
verified = cached.verified,
online = 0
)
}
// 3. Server resolve
try {
val resolved = ProtocolManager.resolveUserInfo(publicKey, 3000)
if (resolved != null) {
return SearchUser(
title = resolved.title,
username = resolved.username,
publicKey = publicKey,
verified = resolved.verified,
online = 0
)
}
} catch (_: Exception) {}
// 4. Fallback: minimal user info
return SearchUser(
title = "User",
username = "",
publicKey = publicKey,
verified = 0,
online = 0
)
}
/** 🔥 Повторить отправку сообщения (для ошибки) */ /** 🔥 Повторить отправку сообщения (для ошибки) */
fun retryMessage(message: ChatMessage) { fun retryMessage(message: ChatMessage) {
// Удаляем старое сообщение // Удаляем старое сообщение
@@ -1593,26 +1725,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyAttachments = val replyAttachments =
_messages.value.find { it.id == firstReply.messageId }?.attachments _messages.value.find { it.id == firstReply.messageId }?.attachments
?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES } ?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES }
// 🔥 Use actual senderName from ForwardMessage (preserves real author)
val firstReplySenderName = if (firstReply.isOutgoing) "You"
else firstReply.senderName.ifEmpty {
opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
}
ReplyData( ReplyData(
messageId = firstReply.messageId, messageId = firstReply.messageId,
senderName = senderName = firstReplySenderName,
if (firstReply.isOutgoing) "You"
else
opponentTitle.ifEmpty {
opponentUsername.ifEmpty { "User" }
},
text = firstReply.text, text = firstReply.text,
isFromMe = firstReply.isOutgoing, isFromMe = firstReply.isOutgoing,
isForwarded = isForward, isForwarded = isForward,
forwardedFromName = forwardedFromName =
if (isForward) firstReply.senderName.ifEmpty { if (isForward) firstReplySenderName else "",
if (firstReply.isOutgoing) "You"
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
} else "",
attachments = replyAttachments, attachments = replyAttachments,
senderPublicKey = senderPublicKey = firstReply.publicKey.ifEmpty {
if (firstReply.isOutgoing) myPublicKey ?: "" if (firstReply.isOutgoing) myPublicKey ?: ""
else opponentKey ?: "", else opponentKey ?: ""
},
recipientPrivateKey = myPrivateKey ?: "" recipientPrivateKey = myPrivateKey ?: ""
) )
} else null } else null

View File

@@ -260,7 +260,8 @@ fun MessageBubble(
onReplyClick: (String) -> Unit = {}, onReplyClick: (String) -> Unit = {},
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> } onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}
) { ) {
// Swipe-to-reply state // Swipe-to-reply state
var swipeOffset by remember { mutableStateOf(0f) } var swipeOffset by remember { mutableStateOf(0f) }
@@ -649,7 +650,8 @@ fun MessageBubble(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
chachaKey = message.chachaKey, chachaKey = message.chachaKey,
privateKey = privateKey, privateKey = privateKey,
onImageClick = onImageClick onImageClick = onImageClick,
onForwardedSenderClick = onForwardedSenderClick
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} }
@@ -662,7 +664,8 @@ fun MessageBubble(
chachaKey = message.chachaKey, chachaKey = message.chachaKey,
privateKey = privateKey, privateKey = privateKey,
onClick = { onReplyClick(reply.messageId) }, onClick = { onReplyClick(reply.messageId) },
onImageClick = onImageClick onImageClick = onImageClick,
onForwardedSenderClick = onForwardedSenderClick
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} }
@@ -1095,7 +1098,8 @@ fun ReplyBubble(
chachaKey: String = "", chachaKey: String = "",
privateKey: String = "", privateKey: String = "",
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> } onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val backgroundColor = val backgroundColor =
@@ -1220,32 +1224,24 @@ fun ReplyBubble(
imageAttachment.id, downloadTag imageAttachment.id, downloadTag
) )
if (encryptedContent.isNotEmpty()) { if (encryptedContent.isNotEmpty()) {
// Расшифровываем: нужен chachaKey сообщения-контейнера // Desktop: decryptKeyFromSender → decodeWithPassword
val keyToUse = chachaKey.ifEmpty { replyData.recipientPrivateKey }
val privKey = privateKey.ifEmpty { replyData.recipientPrivateKey }
var decrypted: String? = null var decrypted: String? = null
// Способ 1: chachaKey + privateKey → ECDH → decrypt if (chachaKey.isNotEmpty() && privateKey.isNotEmpty()) {
if (chachaKey.isNotEmpty() && privKey.isNotEmpty()) {
try { try {
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender( val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(
chachaKey, privKey chachaKey, privateKey
) )
// decryptReplyBlob = desktop decodeWithPassword
decrypted = try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
.takeIf { it.isNotEmpty() && it != encryptedContent }
} catch (_: Exception) { null }
if (decrypted == null) {
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent, plainKeyAndNonce encryptedContent, plainKeyAndNonce
) )
} catch (_: Exception) {}
} }
// Способ 2: senderPublicKey + recipientPrivateKey
if (decrypted == null && replyData.senderPublicKey.isNotEmpty() && replyData.recipientPrivateKey.isNotEmpty()) {
try {
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(
replyData.senderPublicKey, replyData.recipientPrivateKey
)
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent, plainKeyAndNonce
)
} catch (_: Exception) {} } catch (_: Exception) {}
} }
@@ -1296,7 +1292,12 @@ fun ReplyBubble(
) { ) {
// Заголовок (имя отправителя / Forwarded from) // Заголовок (имя отправителя / Forwarded from)
if (replyData.isForwarded && replyData.forwardedFromName.isNotEmpty()) { if (replyData.isForwarded && replyData.forwardedFromName.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(enabled = replyData.senderPublicKey.isNotEmpty()) {
onForwardedSenderClick(replyData.senderPublicKey)
}
) {
Text( Text(
text = "Forwarded from ", text = "Forwarded from ",
color = nameColor, color = nameColor,
@@ -1431,7 +1432,8 @@ fun ForwardedMessagesBubble(
isDarkTheme: Boolean, isDarkTheme: Boolean,
chachaKey: String = "", chachaKey: String = "",
privateKey: String = "", privateKey: String = "",
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> } onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}
) { ) {
val backgroundColor = val backgroundColor =
if (isOutgoing) Color.Black.copy(alpha = 0.1f) if (isOutgoing) Color.Black.copy(alpha = 0.1f)
@@ -1488,8 +1490,13 @@ fun ForwardedMessagesBubble(
senderColorMap[fwd.forwardedFromName] ?: PrimaryBlue senderColorMap[fwd.forwardedFromName] ?: PrimaryBlue
} }
// "Forwarded from [Name]" // "Forwarded from [Name]" — clickable to open profile
Row(verticalAlignment = Alignment.CenterVertically) { Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(enabled = fwd.senderPublicKey.isNotEmpty()) {
onForwardedSenderClick(fwd.senderPublicKey)
}
) {
Text( Text(
text = "Forwarded from ", text = "Forwarded from ",
color = nameColor.copy(alpha = 0.7f), color = nameColor.copy(alpha = 0.7f),
@@ -1508,22 +1515,7 @@ fun ForwardedMessagesBubble(
) )
} }
// Message text // Attachments (images) — before text (text acts as caption)
if (fwd.text.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
val textColor = if (isOutgoing) Color.White.copy(alpha = 0.9f)
else if (isDarkTheme) Color.White else Color.Black
AppleEmojiText(
text = fwd.text,
color = textColor,
fontSize = 14.sp,
maxLines = 50,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
}
// Attachments (images)
val imageAttachments = fwd.attachments.filter { it.type == AttachmentType.IMAGE } val imageAttachments = fwd.attachments.filter { it.type == AttachmentType.IMAGE }
if (imageAttachments.isNotEmpty()) { if (imageAttachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@@ -1538,13 +1530,28 @@ fun ForwardedMessagesBubble(
) )
} }
} }
// Message text (below image, like a caption)
if (fwd.text.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
val textColor = if (isOutgoing) Color.White.copy(alpha = 0.9f)
else if (isDarkTheme) Color.White else Color.Black
AppleEmojiText(
text = fwd.text,
color = textColor,
fontSize = 14.sp,
maxLines = 50,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = true
)
}
} }
} }
} }
} }
} }
/** Image preview inside a forwarded message */ /** Image preview inside a forwarded message — decrypts exactly like desktop */
@Composable @Composable
private fun ForwardedImagePreview( private fun ForwardedImagePreview(
attachment: MessageAttachment, attachment: MessageAttachment,
@@ -1555,49 +1562,87 @@ private fun ForwardedImagePreview(
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
var imageBitmap by remember { mutableStateOf<Bitmap?>(null) } val cacheKey = "img_${attachment.id}"
var blurPreviewBitmap by remember { mutableStateOf<Bitmap?>(null) }
// Load blur preview first var imageBitmap by remember(attachment.id) {
mutableStateOf(ImageBitmapCache.get(cacheKey))
}
var blurPreviewBitmap by remember(attachment.id) { mutableStateOf<android.graphics.Bitmap?>(null) }
// Extract CDN tag and blurhash from preview (format: "UUID::blurhash")
val downloadTag = remember(attachment.preview) { getDownloadTag(attachment.preview) }
val blurhashPreview = remember(attachment.preview) { getPreview(attachment.preview) }
// Load blur preview
LaunchedEffect(attachment.id) { LaunchedEffect(attachment.id) {
if (attachment.preview.isNotEmpty()) { if (blurhashPreview.isNotEmpty()) {
withContext(Dispatchers.IO) {
try { try {
val bitmap = BlurHash.decode(attachment.preview, 32, 32) val bitmap = BlurHash.decode(blurhashPreview, 32, 32)
if (bitmap != null) blurPreviewBitmap = bitmap if (bitmap != null) blurPreviewBitmap = bitmap
} catch (_: Exception) {} } catch (_: Exception) {}
} }
} }
}
// Decrypt and load full image // Main image loading — Desktop pipeline:
// 1. decryptKeyFromSender(chachaKey, privateKey) → plainKeyAndNonce
// 2. password = plainKeyAndNonce.toString('utf-8') (bytesToJsUtf8String)
// 3. decodeWithPassword(password, encryptedBlob) = PBKDF2 + AES-CBC + inflate
LaunchedEffect(attachment.id) { LaunchedEffect(attachment.id) {
// Skip if already loaded
val cached = ImageBitmapCache.get(cacheKey)
if (cached != null) { imageBitmap = cached; return@LaunchedEffect }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Try local file cache first
try { try {
// Try loading from file cache first val localBlob = AttachmentFileManager.readAttachment(
val cached = AttachmentFileManager.readAttachment(
context, attachment.id, senderPublicKey, recipientPrivateKey context, attachment.id, senderPublicKey, recipientPrivateKey
) )
if (cached != null) { if (localBlob != null) {
val bytes = Base64.decode(cached, Base64.DEFAULT) val bitmap = base64ToBitmap(localBlob)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { if (bitmap != null) {
imageBitmap = it imageBitmap = bitmap
ImageBitmapCache.put(cacheKey, bitmap)
return@withContext return@withContext
} }
} }
} catch (_: Exception) {}
// Try decrypting from blob // CDN download — exactly like desktop useAttachment.ts
if (attachment.blob.isNotEmpty()) { if (downloadTag.isNotEmpty() && chachaKey.isNotEmpty() && privateKey.isNotEmpty()) {
val keyToUse = chachaKey.ifEmpty { recipientPrivateKey } try {
val privKey = privateKey.ifEmpty { recipientPrivateKey } val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
val decrypted = MessageCrypto.decryptAttachmentBlob( if (encryptedContent.isNotEmpty()) {
attachment.blob, keyToUse, privKey // Desktop: decryptKeyFromSender → plainKeyAndNonce → decodeWithPassword
) val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
if (decrypted != null) { // decryptReplyBlob = exact same as desktop decodeWithPassword:
val bytes = Base64.decode(decrypted, Base64.DEFAULT) // bytesToJsUtf8String(plainKeyAndNonce) → PBKDF2(password,'rosetta',SHA256,1000) → AES-CBC → inflate
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { val decrypted = MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
imageBitmap = it if (decrypted.isNotEmpty() && decrypted != encryptedContent) {
ImageBitmapCache.put("img_${attachment.id}", it) val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted
val bitmap = base64ToBitmap(base64Data)
if (bitmap != null) {
imageBitmap = bitmap
ImageBitmapCache.put(cacheKey, bitmap)
AttachmentFileManager.saveAttachment( AttachmentFileManager.saveAttachment(
context, decrypted, attachment.id, context, base64Data, attachment.id,
senderPublicKey, recipientPrivateKey
)
return@withContext
}
}
// Fallback: try decryptAttachmentBlobWithPlainKey (same logic, different entry point)
val decrypted2 = MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKeyAndNonce)
if (decrypted2 != null) {
val base64Data = if (decrypted2.contains(",")) decrypted2.substringAfter(",") else decrypted2
val bitmap = base64ToBitmap(base64Data)
if (bitmap != null) {
imageBitmap = bitmap
ImageBitmapCache.put(cacheKey, bitmap)
AttachmentFileManager.saveAttachment(
context, base64Data, attachment.id,
senderPublicKey, recipientPrivateKey senderPublicKey, recipientPrivateKey
) )
} }
@@ -1607,6 +1652,15 @@ private fun ForwardedImagePreview(
} }
} }
// Retry from cache (another composable may have loaded it)
if (imageBitmap == null) {
repeat(5) {
kotlinx.coroutines.delay(400)
ImageBitmapCache.get(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect }
}
}
}
val imgWidth = attachment.width.takeIf { it > 0 } ?: 300 val imgWidth = attachment.width.takeIf { it > 0 } ?: 300
val imgHeight = attachment.height.takeIf { it > 0 } ?: 200 val imgHeight = attachment.height.takeIf { it > 0 } ?: 200
val aspectRatio = (imgWidth.toFloat() / imgHeight.toFloat()).coerceIn(0.5f, 2.5f) val aspectRatio = (imgWidth.toFloat() / imgHeight.toFloat()).coerceIn(0.5f, 2.5f)

View File

@@ -172,7 +172,8 @@ fun OtherProfileScreen(
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
currentUserPrivateKey: String = "", currentUserPrivateKey: String = "",
backgroundBlurColorId: String = "avatar" backgroundBlurColorId: String = "avatar",
onWriteMessage: (SearchUser) -> Unit = {}
) { ) {
var isBlocked by remember { mutableStateOf(false) } var isBlocked by remember { mutableStateOf(false) }
var showAvatarMenu by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) }
@@ -541,6 +542,47 @@ fun OtherProfileScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// ═══════════════════════════════════════════════════════════
// ✉️ WRITE MESSAGE BUTTON
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Button(
onClick = { onWriteMessage(user) },
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White
),
elevation = ButtonDefaults.buttonElevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp
)
) {
Icon(
TablerIcons.MessageCircle2,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Write Message",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 📚 SHARED CONTENT // 📚 SHARED CONTENT
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════