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 65f9bf5..95fcd46 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 @@ -1351,6 +1351,25 @@ fun MessageBubble( } private val GROUP_INVITE_REGEX = Regex("^#group:[A-Za-z0-9+/=:]+$") + +/** + * Truncate text safely without breaking :emoji_xxx: codes. + * If the truncation point falls inside an emoji code, move it to before that code. + */ +private fun truncateEmojiSafe(text: String, maxLen: Int): String { + if (text.length <= maxLen) return text + var cutAt = maxLen + // Check if we're inside an :emoji_xxx: tag + val lastColon = text.lastIndexOf(':', cutAt - 1) + if (lastColon >= 0) { + val sub = text.substring(lastColon) + // If there's an opening :emoji_ but no closing : within our range, we're mid-tag + if (sub.startsWith(":emoji_") && !sub.substring(1).contains(':')) { + cutAt = lastColon + } + } + return text.substring(0, cutAt).trimEnd() + "..." +} private const val GROUP_ACTION_JOINED = "\$a=Group joined" private const val GROUP_ACTION_CREATED = "\$a=Group created" @@ -2274,7 +2293,7 @@ fun ReplyBubble( // Текст сообщения if (replyData.text.isNotEmpty()) { AppleEmojiText( - text = replyData.text, + text = truncateEmojiSafe(replyData.text, 100), color = replyTextColor, fontSize = 14.sp, maxLines = 2, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index c68511f..40fba9d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -122,7 +122,9 @@ data class ViewableImage( val width: Int = 0, val height: Int = 0, val caption: String = "", - val chachaKeyPlainHex: String = "" + val chachaKeyPlainHex: String = "", + val transportTag: String = "", + val transportServer: String = "" ) /** @@ -237,6 +239,7 @@ fun ImageViewerScreen( fun getCachedBitmap(attachmentId: String): Bitmap? = synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] } + ?: ImageBitmapCache.get("img_$attachmentId") fun cacheBitmap(attachmentId: String, bitmap: Bitmap) { synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] = bitmap } @@ -974,73 +977,135 @@ private fun ZoomableImage( * 2) из локального encrypted attachment файла * 3) с transport (с последующим сохранением в локальный файл) */ +private fun viewerLog(context: Context, msg: String) { + val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) + val line = "$ts [ViewerImage] $msg" + android.util.Log.d("ViewerImage", msg) + try { + val dir = java.io.File(context.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val f = java.io.File(dir, "rosettadev1.txt") + f.appendText("$line\n") + } catch (_: Exception) {} +} + private suspend fun loadBitmapForViewerImage( context: Context, image: ViewableImage, privateKey: String ): Bitmap? { - return try { - // 0. Проверяем in-memory кэш (ReplyBubble / основной чат уже загрузили) - val cached = ImageBitmapCache.get("img_${image.attachmentId}") - if (cached != null) return cached + val idShort = if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..." + viewerLog(context, "=== LOAD START: id=$idShort ===") + viewerLog(context, " blob.len=${image.blob.length}, preview.len=${image.preview.length}") + viewerLog(context, " chachaKey.len=${image.chachaKey.length}, chachaKeyPlainHex.len=${image.chachaKeyPlainHex.length}") + viewerLog(context, " senderPK=${image.senderPublicKey.take(12)}..., privateKey.len=${privateKey.length}") + viewerLog(context, " width=${image.width}, height=${image.height}") - // 1. Если blob уже есть в сообщении + return try { + // 0. In-memory кэш + val cached = ImageBitmapCache.get("img_${image.attachmentId}") + if (cached != null) { + viewerLog(context, " [0] HIT ImageBitmapCache → OK (${cached.width}x${cached.height})") + return cached + } + viewerLog(context, " [0] MISS ImageBitmapCache") + + // 1. Blob в сообщении if (image.blob.isNotEmpty()) { - base64ToBitmapSafe(image.blob)?.let { return it } + viewerLog(context, " [1] blob present (${image.blob.length} chars), decoding...") + val bmp = base64ToBitmapSafe(image.blob) + if (bmp != null) { + viewerLog(context, " [1] blob decode → OK (${bmp.width}x${bmp.height})") + return bmp + } + viewerLog(context, " [1] blob decode → FAILED") + } else { + viewerLog(context, " [1] blob empty, skip") } - // 2. Пробуем прочитать из локального encrypted cache + // 2. Локальный encrypted cache + viewerLog(context, " [2] readAttachment(id=$idShort, sender=${image.senderPublicKey.take(12)}...)") val localBlob = AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey) if (localBlob != null) { - base64ToBitmapSafe(localBlob)?.let { return it } + viewerLog(context, " [2] local file found (${localBlob.length} chars), decoding...") + val bmp = base64ToBitmapSafe(localBlob) + if (bmp != null) { + viewerLog(context, " [2] local decode → OK (${bmp.width}x${bmp.height})") + return bmp + } + viewerLog(context, " [2] local decode → FAILED") + } else { + viewerLog(context, " [2] local file NOT found") } - // 3. Скачиваем и расшифровываем с transport - val downloadTag = getDownloadTag(image.preview) - if (downloadTag.isEmpty()) return null + // 2.5. Ждём bitmap из кеша + viewerLog(context, " [2.5] awaitCached 3s...") + val awaitedFromCache = ImageBitmapCache.awaitCached("img_${image.attachmentId}", 3000) + if (awaitedFromCache != null) { + viewerLog(context, " [2.5] await → OK (${awaitedFromCache.width}x${awaitedFromCache.height})") + return awaitedFromCache + } + viewerLog(context, " [2.5] await → timeout, not found") + + // 3. CDN download + var downloadTag = getDownloadTag(image.preview) + if (downloadTag.isEmpty() && image.transportTag.isNotEmpty()) { + downloadTag = image.transportTag + } + viewerLog(context, " [3] downloadTag='${downloadTag.take(16)}...', transportTag='${image.transportTag.take(16)}...', preview='${image.preview.take(30)}...'") + if (downloadTag.isEmpty()) { + viewerLog(context, " [3] downloadTag EMPTY → FAIL") + return null + } - val idShort = - if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..." - val tagShort = if (downloadTag.length <= 8) downloadTag else "${downloadTag.take(8)}..." val server = TransportManager.getTransportServer() ?: "unset" - AttachmentDownloadDebugLogger.log( - "Viewer download start: id=$idShort, tag=$tagShort, server=$server" - ) + viewerLog(context, " [3] CDN download: server=$server") val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag) - AttachmentDownloadDebugLogger.log( - "Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}" - ) - val decrypted = + viewerLog(context, " [3] CDN response: ${encryptedContent.length} bytes") + if (encryptedContent.isEmpty()) { + viewerLog(context, " [3] CDN response EMPTY → FAIL") + return null + } + + // Decrypt + val decrypted: String? = if (image.chachaKeyPlainHex.isNotEmpty()) { - // Desktop/iOS parity: используем готовый plainKeyAndNonce + viewerLog(context, " [3] decrypt via chachaKeyPlainHex (${image.chachaKeyPlainHex.length} hex chars)") val plainKey = image.chachaKeyPlainHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - AttachmentDownloadDebugLogger.log( - "Viewer using chacha_key_plain: id=$idShort, keySize=${plainKey.size}" - ) MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey) ?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() } - ?: return null } else if (image.chachaKey.startsWith("group:")) { - val groupPassword = - CryptoManager.decryptWithPassword( - image.chachaKey.removePrefix("group:"), - privateKey - ) ?: return null - CryptoManager.decryptWithPassword(encryptedContent, groupPassword) ?: return null - } else { - val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey) - AttachmentDownloadDebugLogger.log( - "Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}" + viewerLog(context, " [3] decrypt via group key") + val groupPassword = CryptoManager.decryptWithPassword( + image.chachaKey.removePrefix("group:"), privateKey ) + if (groupPassword != null) CryptoManager.decryptWithPassword(encryptedContent, groupPassword) else null + } else if (image.chachaKey.isNotEmpty()) { + viewerLog(context, " [3] decrypt via chachaKey (${image.chachaKey.length} chars)") + val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey) + viewerLog(context, " [3] decryptKeyFromSender → keySize=${decryptedKeyAndNonce.size}") MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce) - ?: return null + } else { + viewerLog(context, " [3] NO chachaKey available → FAIL") + null } - val decodedBitmap = base64ToBitmapSafe(decrypted) ?: return null + if (decrypted == null) { + viewerLog(context, " [3] decrypt → NULL → FAIL") + return null + } + viewerLog(context, " [3] decrypted OK (${decrypted.length} chars)") - // Сохраняем локально для следующих открытий/свайпов + val decodedBitmap = base64ToBitmapSafe(decrypted) + if (decodedBitmap == null) { + viewerLog(context, " [3] base64→bitmap → FAILED") + return null + } + viewerLog(context, " [3] bitmap OK (${decodedBitmap.width}x${decodedBitmap.height})") + + // Сохраняем локально AttachmentFileManager.saveAttachment( context = context, blob = decrypted, @@ -1048,15 +1113,10 @@ private suspend fun loadBitmapForViewerImage( publicKey = image.senderPublicKey, privateKey = privateKey ) - AttachmentDownloadDebugLogger.log("Viewer image ready: id=$idShort") - + viewerLog(context, " saved locally → DONE") decodedBitmap } catch (e: Exception) { - val idShort = - if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..." - AttachmentDownloadDebugLogger.log( - "Viewer image ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" - ) + viewerLog(context, " EXCEPTION: ${e.javaClass.simpleName}: ${e.message}") null } } @@ -1149,7 +1209,9 @@ fun extractImagesFromMessages( timestamp = message.timestamp, width = attachment.width, height = attachment.height, - caption = message.text + caption = message.text, + transportTag = attachment.transportTag, + transportServer = attachment.transportServer ) } @@ -1172,7 +1234,9 @@ fun extractImagesFromMessages( width = attachment.width, height = attachment.height, caption = message.replyData.text, - chachaKeyPlainHex = message.replyData.chachaKeyPlainHex + chachaKeyPlainHex = message.replyData.chachaKeyPlainHex, + transportTag = attachment.transportTag, + transportServer = attachment.transportServer ) } ?: emptyList() @@ -1196,7 +1260,9 @@ fun extractImagesFromMessages( width = attachment.width, height = attachment.height, caption = fwd.text, - chachaKeyPlainHex = fwd.chachaKeyPlainHex + chachaKeyPlainHex = fwd.chachaKeyPlainHex, + transportTag = attachment.transportTag, + transportServer = attachment.transportServer ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 7753baa..29da07e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -62,6 +62,19 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import java.util.Locale +private fun truncateEmojiSafe(text: String, maxLen: Int): String { + if (text.length <= maxLen) return text + var cutAt = maxLen + val lastColon = text.lastIndexOf(':', cutAt - 1) + if (lastColon >= 0) { + val sub = text.substring(lastColon) + if (sub.startsWith(":emoji_") && !sub.substring(1).contains(':')) { + cutAt = lastColon + } + } + return text.substring(0, cutAt).trimEnd() + "..." +} + /** * Message input bar and related components * Extracted from ChatDetailScreen.kt for better organization @@ -683,9 +696,7 @@ fun MessageInputBar( } else if (msg.text.isEmpty() && hasImageAttachment) { "Photo" } else { - val codePoints = msg.text.codePoints().limit(40).toArray() - val shortText = String(codePoints, 0, codePoints.size) - if (shortText.length < msg.text.length) "$shortText..." else shortText + truncateEmojiSafe(msg.text, 80) } } else "${panelReplyMessages.size} messages", fontSize = 13.sp,