feat: Enhance error handling for downloads and memory issues in ImageAttachment and TransportManager
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user