Фикс: полноэкранный просмотр фото — 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+/=:]+$")
|
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_JOINED = "\$a=Group joined"
|
||||||
private const val GROUP_ACTION_CREATED = "\$a=Group created"
|
private const val GROUP_ACTION_CREATED = "\$a=Group created"
|
||||||
|
|
||||||
@@ -2274,7 +2293,7 @@ fun ReplyBubble(
|
|||||||
// Текст сообщения
|
// Текст сообщения
|
||||||
if (replyData.text.isNotEmpty()) {
|
if (replyData.text.isNotEmpty()) {
|
||||||
AppleEmojiText(
|
AppleEmojiText(
|
||||||
text = replyData.text,
|
text = truncateEmojiSafe(replyData.text, 100),
|
||||||
color = replyTextColor,
|
color = replyTextColor,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ data class ViewableImage(
|
|||||||
val width: Int = 0,
|
val width: Int = 0,
|
||||||
val height: Int = 0,
|
val height: Int = 0,
|
||||||
val caption: String = "",
|
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? =
|
fun getCachedBitmap(attachmentId: String): Bitmap? =
|
||||||
synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] }
|
synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] }
|
||||||
|
?: ImageBitmapCache.get("img_$attachmentId")
|
||||||
|
|
||||||
fun cacheBitmap(attachmentId: String, bitmap: Bitmap) {
|
fun cacheBitmap(attachmentId: String, bitmap: Bitmap) {
|
||||||
synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] = bitmap }
|
synchronized(imageBitmapCache) { imageBitmapCache[attachmentId] = bitmap }
|
||||||
@@ -974,73 +977,135 @@ private fun ZoomableImage(
|
|||||||
* 2) из локального encrypted attachment файла
|
* 2) из локального encrypted attachment файла
|
||||||
* 3) с transport (с последующим сохранением в локальный файл)
|
* 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(
|
private suspend fun loadBitmapForViewerImage(
|
||||||
context: Context,
|
context: Context,
|
||||||
image: ViewableImage,
|
image: ViewableImage,
|
||||||
privateKey: String
|
privateKey: String
|
||||||
): Bitmap? {
|
): Bitmap? {
|
||||||
return try {
|
val idShort = if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..."
|
||||||
// 0. Проверяем in-memory кэш (ReplyBubble / основной чат уже загрузили)
|
viewerLog(context, "=== LOAD START: id=$idShort ===")
|
||||||
val cached = ImageBitmapCache.get("img_${image.attachmentId}")
|
viewerLog(context, " blob.len=${image.blob.length}, preview.len=${image.preview.length}")
|
||||||
if (cached != null) return cached
|
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()) {
|
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 =
|
val localBlob =
|
||||||
AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey)
|
AttachmentFileManager.readAttachment(context, image.attachmentId, image.senderPublicKey, privateKey)
|
||||||
if (localBlob != null) {
|
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
|
// 2.5. Ждём bitmap из кеша
|
||||||
val downloadTag = getDownloadTag(image.preview)
|
viewerLog(context, " [2.5] awaitCached 3s...")
|
||||||
if (downloadTag.isEmpty()) return null
|
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"
|
val server = TransportManager.getTransportServer() ?: "unset"
|
||||||
AttachmentDownloadDebugLogger.log(
|
viewerLog(context, " [3] CDN download: server=$server")
|
||||||
"Viewer download start: id=$idShort, tag=$tagShort, server=$server"
|
|
||||||
)
|
|
||||||
|
|
||||||
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
|
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
|
||||||
AttachmentDownloadDebugLogger.log(
|
viewerLog(context, " [3] CDN response: ${encryptedContent.length} bytes")
|
||||||
"Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
|
if (encryptedContent.isEmpty()) {
|
||||||
)
|
viewerLog(context, " [3] CDN response EMPTY → FAIL")
|
||||||
val decrypted =
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
val decrypted: String? =
|
||||||
if (image.chachaKeyPlainHex.isNotEmpty()) {
|
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()
|
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.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
|
||||||
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
|
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
|
||||||
?: return null
|
|
||||||
} else if (image.chachaKey.startsWith("group:")) {
|
} else if (image.chachaKey.startsWith("group:")) {
|
||||||
val groupPassword =
|
viewerLog(context, " [3] decrypt via group key")
|
||||||
CryptoManager.decryptWithPassword(
|
val groupPassword = CryptoManager.decryptWithPassword(
|
||||||
image.chachaKey.removePrefix("group:"),
|
image.chachaKey.removePrefix("group:"), privateKey
|
||||||
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}"
|
|
||||||
)
|
)
|
||||||
|
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)
|
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(
|
AttachmentFileManager.saveAttachment(
|
||||||
context = context,
|
context = context,
|
||||||
blob = decrypted,
|
blob = decrypted,
|
||||||
@@ -1048,15 +1113,10 @@ private suspend fun loadBitmapForViewerImage(
|
|||||||
publicKey = image.senderPublicKey,
|
publicKey = image.senderPublicKey,
|
||||||
privateKey = privateKey
|
privateKey = privateKey
|
||||||
)
|
)
|
||||||
AttachmentDownloadDebugLogger.log("Viewer image ready: id=$idShort")
|
viewerLog(context, " saved locally → DONE")
|
||||||
|
|
||||||
decodedBitmap
|
decodedBitmap
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val idShort =
|
viewerLog(context, " EXCEPTION: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
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"}"
|
|
||||||
)
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1149,7 +1209,9 @@ fun extractImagesFromMessages(
|
|||||||
timestamp = message.timestamp,
|
timestamp = message.timestamp,
|
||||||
width = attachment.width,
|
width = attachment.width,
|
||||||
height = attachment.height,
|
height = attachment.height,
|
||||||
caption = message.text
|
caption = message.text,
|
||||||
|
transportTag = attachment.transportTag,
|
||||||
|
transportServer = attachment.transportServer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1234,9 @@ fun extractImagesFromMessages(
|
|||||||
width = attachment.width,
|
width = attachment.width,
|
||||||
height = attachment.height,
|
height = attachment.height,
|
||||||
caption = message.replyData.text,
|
caption = message.replyData.text,
|
||||||
chachaKeyPlainHex = message.replyData.chachaKeyPlainHex
|
chachaKeyPlainHex = message.replyData.chachaKeyPlainHex,
|
||||||
|
transportTag = attachment.transportTag,
|
||||||
|
transportServer = attachment.transportServer
|
||||||
)
|
)
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
|
|
||||||
@@ -1196,7 +1260,9 @@ fun extractImagesFromMessages(
|
|||||||
width = attachment.width,
|
width = attachment.width,
|
||||||
height = attachment.height,
|
height = attachment.height,
|
||||||
caption = fwd.text,
|
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 kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
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
|
* Message input bar and related components
|
||||||
* Extracted from ChatDetailScreen.kt for better organization
|
* Extracted from ChatDetailScreen.kt for better organization
|
||||||
@@ -683,9 +696,7 @@ fun MessageInputBar(
|
|||||||
} else if (msg.text.isEmpty() && hasImageAttachment) {
|
} else if (msg.text.isEmpty() && hasImageAttachment) {
|
||||||
"Photo"
|
"Photo"
|
||||||
} else {
|
} else {
|
||||||
val codePoints = msg.text.codePoints().limit(40).toArray()
|
truncateEmojiSafe(msg.text, 80)
|
||||||
val shortText = String(codePoints, 0, codePoints.size)
|
|
||||||
if (shortText.length < msg.text.length) "$shortText..." else shortText
|
|
||||||
}
|
}
|
||||||
} else "${panelReplyMessages.size} messages",
|
} else "${panelReplyMessages.size} messages",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
|
|||||||
Reference in New Issue
Block a user