diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 44fc54d..4a5ac38 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -888,6 +888,7 @@ fun MainScreen( user = currentChatUser, currentUserPublicKey = accountPublicKey, currentUserPrivateKey = accountPrivateKey, + currentUserName = accountName, onBack = { popChatAndChildren() }, onUserProfileClick = { user -> // Открываем профиль другого пользователя @@ -971,7 +972,13 @@ fun MainScreen( }, avatarRepository = avatarRepository, 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) + } ) } } diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index f96887e..3ca712f 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -519,81 +519,27 @@ object MessageCrypto { * @param encryptedData зашифрованный контент (ivBase64:ciphertextBase64) * @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( encryptedData: String, chachaKeyPlain: ByteArray ): String? { + // Один путь, как в десктопе: bytesToJsUtf8String → PBKDF2-SHA256 → AES-CBC → inflate return try { - - // Desktop использует key.toString('binary') → encrypt → decrypt → toString('utf-8') → PBKDF2 - // Это эквивалентно: raw bytes → Latin1 string → UTF-8 encode → шифрование → - // расшифровка → UTF-8 decode → опять UTF-8 для PBKDF2 - // - // Но 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) { + val password = bytesToJsUtf8String(chachaKeyPlain) + val pbkdf2Key = generatePBKDF2Key(password) + decryptWithPBKDF2Key(encryptedData, pbkdf2Key) + } catch (_: Exception) { null } } diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 6892899..0c8fba9 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.text.SimpleDateFormat import java.util.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume /** * Singleton manager for Protocol instance @@ -34,6 +36,12 @@ object ProtocolManager { private val _typingUsers = MutableStateFlow>(emptySet()) val typingUsers: StateFlow> = _typingUsers.asStateFlow() + // 🔍 Global user info cache (like Desktop's InformationProvider.cachedUsers) + // publicKey → SearchUser (resolved via PacketSearch 0x03) + private val userInfoCache = ConcurrentHashMap() + // Pending resolves: publicKey → list of continuations waiting for the result + private val pendingResolves = ConcurrentHashMap>>() + private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) // 🚀 Флаг для включения UI логов (по умолчанию ВЫКЛЮЧЕНО - это вызывало ANR!) @@ -167,6 +175,14 @@ object ProtocolManager { scope.launch(Dispatchers.IO) { val ownPublicKey = getProtocol().getPublicKey() 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( 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) @@ -255,6 +278,104 @@ object ProtocolManager { 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) */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 061bb8e..a3f5dfd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -102,6 +102,7 @@ fun ChatDetailScreen( onUserProfileClick: (SearchUser) -> Unit = {}, currentUserPublicKey: String, currentUserPrivateKey: String, + currentUserName: String = "", totalUnreadFromOthers: Int = 0, isDarkTheme: Boolean, avatarRepository: AvatarRepository? = null, @@ -1369,46 +1370,86 @@ fun ChatDetailScreen( val forwardMessages = selectedMsgs - .map { + .flatMap { msg -> - ForwardManager - .ForwardMessage( - messageId = - msg.id, - text = - msg.text, - timestamp = - msg.timestamp - .time, - isOutgoing = - msg.isOutgoing, - senderPublicKey = - if (msg.isOutgoing + // Re-forward: preserve original senders + if (msg.forwardedMessages.isNotEmpty()) { + msg.forwardedMessages.map { fwd -> + 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 = + "" + ) + } ) - currentUserPublicKey - else + } + } else { + listOf(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, - originalChatPublicKey = - user.publicKey, - senderName = - if (msg.isOutgoing) "You" - else user.title.ifEmpty { user.username.ifEmpty { "User" } }, - attachments = - msg.attachments - .filter { - it.type != - AttachmentType - .MESSAGES - } - .map { - attachment -> - attachment.copy( - localUri = - "" - ) - } - ) + senderName = + if (msg.isOutgoing) currentUserName.ifEmpty { "You" } + else user.title.ifEmpty { user.username.ifEmpty { "User" } }, + attachments = + msg.attachments + .filter { + it.type != + AttachmentType + .MESSAGES + } + .map { + attachment -> + attachment.copy( + localUri = + "" + ) + } + )) + } } ForwardManager .setForwardMessages( @@ -1965,6 +2006,15 @@ fun ChatDetailScreen( onImageViewerChanged( true ) + }, + onForwardedSenderClick = { senderPublicKey -> + // Open profile of the forwarded message sender + scope.launch { + val resolvedUser = viewModel.resolveUserForProfile(senderPublicKey) + if (resolvedUser != null) { + onUserProfileClick(resolvedUser) + } + } } ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 5785c87..778bc9d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -1299,7 +1299,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val senderDisplayName = fwdSenderName.ifEmpty { 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( @@ -1310,7 +1328,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isForwarded = true, forwardedFromName = senderDisplayName, attachments = fwdAttachments, - senderPublicKey = if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "", + senderPublicKey = fwdPublicKey.ifEmpty { if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "" }, recipientPrivateKey = myPrivateKey ?: "" )) } @@ -1326,9 +1344,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val replyText = replyMessage.optString("message", "") val replyMessageIdFromJson = replyMessage.optString("message_id", "") val replyTimestamp = replyMessage.optLong("timestamp", 0L) - val isForwarded = replyMessage.optBoolean("forwarded", false) 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) val replyAttachmentsFromJson = mutableListOf() 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 = ReplyData( messageId = realMessageId, - senderName = - if (isReplyFromMe) "You" - else - opponentTitle.ifEmpty { - opponentUsername.ifEmpty { "User" } - }, + senderName = resolvedSenderName, text = replyText, isFromMe = isReplyFromMe, isForwarded = isForwarded, - forwardedFromName = if (isForwarded) senderNameFromJson.ifEmpty { - if (isReplyFromMe) "You" - else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } } - } else "", + forwardedFromName = forwardFromDisplay, attachments = originalAttachments, - senderPublicKey = + senderPublicKey = replyPublicKey.ifEmpty { if (isReplyFromMe) myPublicKey ?: "" - else opponentKey ?: "", + else opponentKey ?: "" + }, 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) } 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) { // Удаляем старое сообщение @@ -1593,26 +1725,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val replyAttachments = _messages.value.find { it.id == firstReply.messageId }?.attachments ?: 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( messageId = firstReply.messageId, - senderName = - if (firstReply.isOutgoing) "You" - else - opponentTitle.ifEmpty { - opponentUsername.ifEmpty { "User" } - }, + senderName = firstReplySenderName, text = firstReply.text, isFromMe = firstReply.isOutgoing, isForwarded = isForward, forwardedFromName = - if (isForward) firstReply.senderName.ifEmpty { - if (firstReply.isOutgoing) "You" - else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } } - } else "", + if (isForward) firstReplySenderName else "", attachments = replyAttachments, - senderPublicKey = + senderPublicKey = firstReply.publicKey.ifEmpty { if (firstReply.isOutgoing) myPublicKey ?: "" - else opponentKey ?: "", + else opponentKey ?: "" + }, recipientPrivateKey = myPrivateKey ?: "" ) } else null diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index efbb909..c865b41 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -260,7 +260,8 @@ fun MessageBubble( onReplyClick: (String) -> Unit = {}, onRetry: () -> Unit = {}, onDelete: () -> Unit = {}, - onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> } + onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, + onForwardedSenderClick: (senderPublicKey: String) -> Unit = {} ) { // Swipe-to-reply state var swipeOffset by remember { mutableStateOf(0f) } @@ -649,7 +650,8 @@ fun MessageBubble( isDarkTheme = isDarkTheme, chachaKey = message.chachaKey, privateKey = privateKey, - onImageClick = onImageClick + onImageClick = onImageClick, + onForwardedSenderClick = onForwardedSenderClick ) Spacer(modifier = Modifier.height(4.dp)) } @@ -662,7 +664,8 @@ fun MessageBubble( chachaKey = message.chachaKey, privateKey = privateKey, onClick = { onReplyClick(reply.messageId) }, - onImageClick = onImageClick + onImageClick = onImageClick, + onForwardedSenderClick = onForwardedSenderClick ) Spacer(modifier = Modifier.height(4.dp)) } @@ -1095,7 +1098,8 @@ fun ReplyBubble( chachaKey: String = "", privateKey: String = "", 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 backgroundColor = @@ -1220,32 +1224,24 @@ fun ReplyBubble( imageAttachment.id, downloadTag ) if (encryptedContent.isNotEmpty()) { - // Расшифровываем: нужен chachaKey сообщения-контейнера - val keyToUse = chachaKey.ifEmpty { replyData.recipientPrivateKey } - val privKey = privateKey.ifEmpty { replyData.recipientPrivateKey } + // Desktop: decryptKeyFromSender → decodeWithPassword var decrypted: String? = null - // Способ 1: chachaKey + privateKey → ECDH → decrypt - if (chachaKey.isNotEmpty() && privKey.isNotEmpty()) { + if (chachaKey.isNotEmpty() && privateKey.isNotEmpty()) { try { val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender( - chachaKey, privKey - ) - decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( - 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 + 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( + encryptedContent, plainKeyAndNonce + ) + } } catch (_: Exception) {} } @@ -1296,7 +1292,12 @@ fun ReplyBubble( ) { // Заголовок (имя отправителя / Forwarded from) 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 = "Forwarded from ", color = nameColor, @@ -1431,7 +1432,8 @@ fun ForwardedMessagesBubble( isDarkTheme: Boolean, chachaKey: String = "", privateKey: String = "", - onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> } + onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, + onForwardedSenderClick: (senderPublicKey: String) -> Unit = {} ) { val backgroundColor = if (isOutgoing) Color.Black.copy(alpha = 0.1f) @@ -1488,8 +1490,13 @@ fun ForwardedMessagesBubble( senderColorMap[fwd.forwardedFromName] ?: PrimaryBlue } - // "Forwarded from [Name]" - Row(verticalAlignment = Alignment.CenterVertically) { + // "Forwarded from [Name]" — clickable to open profile + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable(enabled = fwd.senderPublicKey.isNotEmpty()) { + onForwardedSenderClick(fwd.senderPublicKey) + } + ) { Text( text = "Forwarded from ", color = nameColor.copy(alpha = 0.7f), @@ -1508,22 +1515,7 @@ fun ForwardedMessagesBubble( ) } - // Message text - 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) + // Attachments (images) — before text (text acts as caption) val imageAttachments = fwd.attachments.filter { it.type == AttachmentType.IMAGE } if (imageAttachments.isNotEmpty()) { 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 private fun ForwardedImagePreview( attachment: MessageAttachment, @@ -1555,55 +1562,102 @@ private fun ForwardedImagePreview( onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit ) { val context = androidx.compose.ui.platform.LocalContext.current - var imageBitmap by remember { mutableStateOf(null) } - var blurPreviewBitmap by remember { mutableStateOf(null) } + val cacheKey = "img_${attachment.id}" - // Load blur preview first + var imageBitmap by remember(attachment.id) { + mutableStateOf(ImageBitmapCache.get(cacheKey)) + } + var blurPreviewBitmap by remember(attachment.id) { mutableStateOf(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) { - if (attachment.preview.isNotEmpty()) { - try { - val bitmap = BlurHash.decode(attachment.preview, 32, 32) - if (bitmap != null) blurPreviewBitmap = bitmap - } catch (_: Exception) {} + if (blurhashPreview.isNotEmpty()) { + withContext(Dispatchers.IO) { + try { + val bitmap = BlurHash.decode(blurhashPreview, 32, 32) + if (bitmap != null) blurPreviewBitmap = bitmap + } 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) { + // Skip if already loaded + val cached = ImageBitmapCache.get(cacheKey) + if (cached != null) { imageBitmap = cached; return@LaunchedEffect } + withContext(Dispatchers.IO) { + // Try local file cache first try { - // Try loading from file cache first - val cached = AttachmentFileManager.readAttachment( + val localBlob = AttachmentFileManager.readAttachment( context, attachment.id, senderPublicKey, recipientPrivateKey ) - if (cached != null) { - val bytes = Base64.decode(cached, Base64.DEFAULT) - BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { - imageBitmap = it + if (localBlob != null) { + val bitmap = base64ToBitmap(localBlob) + if (bitmap != null) { + imageBitmap = bitmap + ImageBitmapCache.put(cacheKey, bitmap) return@withContext } } + } catch (_: Exception) {} - // Try decrypting from blob - if (attachment.blob.isNotEmpty()) { - val keyToUse = chachaKey.ifEmpty { recipientPrivateKey } - val privKey = privateKey.ifEmpty { recipientPrivateKey } - val decrypted = MessageCrypto.decryptAttachmentBlob( - attachment.blob, keyToUse, privKey - ) - if (decrypted != null) { - val bytes = Base64.decode(decrypted, Base64.DEFAULT) - BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { - imageBitmap = it - ImageBitmapCache.put("img_${attachment.id}", it) - AttachmentFileManager.saveAttachment( - context, decrypted, attachment.id, - senderPublicKey, recipientPrivateKey - ) + // CDN download — exactly like desktop useAttachment.ts + if (downloadTag.isNotEmpty() && chachaKey.isNotEmpty() && privateKey.isNotEmpty()) { + try { + val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) + if (encryptedContent.isNotEmpty()) { + // Desktop: decryptKeyFromSender → plainKeyAndNonce → decodeWithPassword + val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) + // decryptReplyBlob = exact same as desktop decodeWithPassword: + // bytesToJsUtf8String(plainKeyAndNonce) → PBKDF2(password,'rosetta',SHA256,1000) → AES-CBC → inflate + val decrypted = MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce) + if (decrypted.isNotEmpty() && decrypted != encryptedContent) { + val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted + val bitmap = base64ToBitmap(base64Data) + if (bitmap != null) { + imageBitmap = bitmap + ImageBitmapCache.put(cacheKey, bitmap) + AttachmentFileManager.saveAttachment( + 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 + ) + } } } - } - } catch (_: Exception) {} + } catch (_: Exception) {} + } + } + + // 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 } + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index f4dca40..2b4a8ec 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -172,7 +172,8 @@ fun OtherProfileScreen( avatarRepository: AvatarRepository? = null, currentUserPublicKey: String = "", currentUserPrivateKey: String = "", - backgroundBlurColorId: String = "avatar" + backgroundBlurColorId: String = "avatar", + onWriteMessage: (SearchUser) -> Unit = {} ) { var isBlocked by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) } @@ -541,6 +542,47 @@ fun OtherProfileScreen( 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 // ═══════════════════════════════════════════════════════════