feat: Enhance error handling for downloads and memory issues in ImageAttachment and TransportManager

This commit is contained in:
2026-02-25 19:43:07 +05:00
parent c1f9114251
commit a5fb90e072
4 changed files with 151 additions and 62 deletions

View File

@@ -573,6 +573,9 @@ object MessageCrypto {
decryptWithPBKDF2Key(encryptedData, pbkdf2Key)
} catch (_: Exception) {
null
} catch (_: OutOfMemoryError) {
System.gc()
null
}
}
@@ -607,6 +610,9 @@ object MessageCrypto {
result
} catch (e: Exception) {
null
} catch (_: OutOfMemoryError) {
System.gc()
null
}
}
@@ -629,6 +635,9 @@ object MessageCrypto {
decryptAttachmentBlobWithPlainKey(encryptedData, keyAndNonce)
} catch (e: Exception) {
null
} catch (_: OutOfMemoryError) {
System.gc()
null
}
}
@@ -763,6 +772,9 @@ object MessageCrypto {
result
} catch (e: Exception) {
null
} catch (_: OutOfMemoryError) {
System.gc()
null
}
}

View File

@@ -283,6 +283,14 @@ object TransportManager {
}
}
// Проверяем целостность скачанного контента
if (contentLength > 0 && totalRead != contentLength) {
throw IOException("Incomplete download: expected=$contentLength, got=$totalRead")
}
if (totalRead == 0L) {
throw IOException("Empty download: 0 bytes received")
}
// Читаем результат из файла
val content = tempFile.readText(Charsets.UTF_8)

View File

@@ -424,6 +424,7 @@ fun ChatDetailScreen(
val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState()
@Suppress("UNUSED_VARIABLE")
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
val rawIsOnline by viewModel.opponentOnline.collectAsState()
// If typing, the user is obviously online — never show "offline" while typing
@@ -432,7 +433,6 @@ fun ChatDetailScreen(
// <20>🔥 Reply/Forward state
val replyMessages by viewModel.replyMessages.collectAsState()
val hasReply = replyMessages.isNotEmpty()
val isForwardMode by viewModel.isForwardMode.collectAsState()
// Avatar-сообщения не должны попадать в selection ни при каких условиях.
@@ -653,6 +653,7 @@ fun ChatDetailScreen(
}
// Аватар - используем publicKey для консистентности цвета везде
@Suppress("UNUSED_VARIABLE")
val avatarColors =
getAvatarColor(
if (isSavedMessages) "SavedMessages" else user.publicKey,
@@ -890,9 +891,9 @@ fun ChatDetailScreen(
)
.offset(
x =
(-4).dp,
2.dp,
y =
6.dp
2.dp
)
.size(
if (totalUnreadFromOthers >
@@ -907,7 +908,7 @@ fun ChatDetailScreen(
)
.background(
Color(
0xFF3B82F6
0xFFFF3B30
)
),
contentAlignment =

View File

@@ -756,6 +756,7 @@ fun ImageAttachment(
}
var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) }
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
val preview = getPreview(attachment.preview)
val downloadTag = getDownloadTag(attachment.preview)
@@ -952,75 +953,76 @@ fun ImageAttachment(
try {
downloadStatus = DownloadStatus.DOWNLOADING
// Скачиваем зашифрованный контент
// Скачиваем зашифрованный контент (с одним авто-ретраем)
val startTime = System.currentTimeMillis()
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
val encryptedContent: String
try {
encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
} catch (e: Exception) {
// Один авто-ретрай через 1с
logPhotoDebug("CDN download failed, retrying: id=$idShort, reason=${e.message}")
kotlinx.coroutines.delay(1000)
try {
val retryResult = TransportManager.downloadFile(attachment.id, downloadTag)
@Suppress("NAME_SHADOWING")
val encryptedContent = retryResult
logPhotoDebug("CDN retry OK: id=$idShort")
// Продолжаем с retryResult
processDownloadedImage(
encryptedContent = encryptedContent,
chachaKey = chachaKey,
privateKey = privateKey,
attachment = attachment,
senderPublicKey = senderPublicKey,
context = context,
cacheKey = cacheKey,
idShort = idShort,
onProgress = { downloadProgress = it },
onStatus = { downloadStatus = it },
onBitmap = { imageBitmap = it },
onError = { label -> errorLabel = label }
)
return@launch
} catch (retryEx: Exception) {
// CDN ошибка (файл истёк или сервер недоступен)
val isExpired = retryEx.message?.contains("404") == true || retryEx.message?.contains("410") == true
errorLabel = if (isExpired) "Expired" else "Error"
downloadStatus = DownloadStatus.ERROR
logPhotoDebug("CDN retry also failed: id=$idShort, reason=${retryEx.message}")
return@launch
}
}
val downloadTime = System.currentTimeMillis() - startTime
logPhotoDebug(
"CDN download OK: id=$idShort, bytes=${encryptedContent.length}, time=${downloadTime}ms"
)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
logPhotoDebug(
"Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
processDownloadedImage(
encryptedContent = encryptedContent,
chachaKey = chachaKey,
privateKey = privateKey,
attachment = attachment,
senderPublicKey = senderPublicKey,
context = context,
cacheKey = cacheKey,
idShort = idShort,
onProgress = { downloadProgress = it },
onStatus = { downloadStatus = it },
onBitmap = { imageBitmap = it },
onError = { label -> errorLabel = label }
)
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
// bytes в password
val decryptStartTime = System.currentTimeMillis()
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
val decryptTime = System.currentTimeMillis() - decryptStartTime
downloadProgress = 0.8f
if (decrypted != null) {
var decodedBitmap: Bitmap? = null
var saved = false
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
withContext(Dispatchers.IO) {
decodedBitmap = base64ToBitmap(decrypted)
if (decodedBitmap != null) {
imageBitmap = decodedBitmap
ImageBitmapCache.put(cacheKey, decodedBitmap!!)
// 💾 Сохраняем в файловую систему (как в Desktop)
saved =
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = attachment.id,
publicKey = senderPublicKey,
privateKey = privateKey
)
}
}
if (decodedBitmap != null) {
downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED
logPhotoDebug("Image ready: id=$idShort, saved=$saved")
} else {
downloadStatus = DownloadStatus.ERROR
logPhotoDebug("Image decode FAILED: id=$idShort")
}
} else {
downloadStatus = DownloadStatus.ERROR
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
}
} catch (e: Exception) {
e.printStackTrace()
downloadStatus = DownloadStatus.ERROR
errorLabel = "Error"
logPhotoDebug(
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
} catch (e: OutOfMemoryError) {
System.gc()
downloadStatus = DownloadStatus.ERROR
errorLabel = "Error"
logPhotoDebug("Image OOM: id=$idShort")
}
}
} else {
@@ -1373,7 +1375,7 @@ fun ImageAttachment(
)
}
Spacer(modifier = Modifier.height(8.dp))
Text("Expired", fontSize = 12.sp, color = Color.White)
Text(errorLabel, fontSize = 12.sp, color = Color.White)
}
}
else -> {}
@@ -2315,6 +2317,72 @@ private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
}
}
/**
* Process downloaded encrypted content: decrypt → decode → cache → save.
* Extracted to avoid duplication between first attempt and retry.
*/
private suspend fun processDownloadedImage(
encryptedContent: String,
chachaKey: String,
privateKey: String,
attachment: MessageAttachment,
senderPublicKey: String,
context: android.content.Context,
cacheKey: String,
idShort: String,
onProgress: (Float) -> Unit,
onStatus: (DownloadStatus) -> Unit,
onBitmap: (Bitmap?) -> Unit,
onError: (String) -> Unit
) {
onProgress(0.5f)
onStatus(DownloadStatus.DECRYPTING)
// Расшифровываем ключ
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
logPhotoDebug("Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}")
// Расшифровываем контент
val decryptStartTime = System.currentTimeMillis()
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
val decryptTime = System.currentTimeMillis() - decryptStartTime
onProgress(0.8f)
if (decrypted != null) {
var decodedBitmap: Bitmap? = null
var saved = false
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
withContext(Dispatchers.IO) {
decodedBitmap = base64ToBitmap(decrypted)
if (decodedBitmap != null) {
onBitmap(decodedBitmap)
ImageBitmapCache.put(cacheKey, decodedBitmap!!)
saved = AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = attachment.id,
publicKey = senderPublicKey,
privateKey = privateKey
)
}
}
if (decodedBitmap != null) {
onProgress(1f)
onStatus(DownloadStatus.DOWNLOADED)
logPhotoDebug("Image ready: id=$idShort, saved=$saved")
} else {
onError("Error")
onStatus(DownloadStatus.ERROR)
logPhotoDebug("Image decode FAILED: id=$idShort")
}
} else {
onError("Error")
onStatus(DownloadStatus.ERROR)
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
}
}
/**
* CDN download + decrypt + cache + save.
* Shared between ReplyBubble and ForwardedImagePreview.