Фикс: полноэкранный просмотр фото — fallback на transportTag когда preview не содержит CDN тег. Логирование загрузки в rosettadev1. Глобальный ImageBitmapCache в viewer. Emoji-safe обрезка в reply.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user