Фикс: полноэкранный просмотр фото — fallback на transportTag когда preview не содержит CDN тег. Логирование загрузки в rosettadev1. Глобальный ImageBitmapCache в viewer. Emoji-safe обрезка в reply.

This commit is contained in:
2026-04-08 14:47:57 +05:00
parent 1e259f52ee
commit 0427e2ba17
3 changed files with 150 additions and 54 deletions

View File

@@ -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,

View File

@@ -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 =
if (image.chachaKeyPlainHex.isNotEmpty()) {
// Desktop/iOS parity: используем готовый plainKeyAndNonce
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}"
)
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
?: return null
viewerLog(context, " [3] CDN response: ${encryptedContent.length} bytes")
if (encryptedContent.isEmpty()) {
viewerLog(context, " [3] CDN response EMPTY → FAIL")
return null
}
val decodedBitmap = base64ToBitmapSafe(decrypted) ?: return null
// Decrypt
val decrypted: String? =
if (image.chachaKeyPlainHex.isNotEmpty()) {
viewerLog(context, " [3] decrypt via chachaKeyPlainHex (${image.chachaKeyPlainHex.length} hex chars)")
val plainKey = image.chachaKeyPlainHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
} else if (image.chachaKey.startsWith("group:")) {
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)
} else {
viewerLog(context, " [3] NO chachaKey available → FAIL")
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
)
}
}

View File

@@ -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,