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)
|
decryptWithPBKDF2Key(encryptedData, pbkdf2Key)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
|
} catch (_: OutOfMemoryError) {
|
||||||
|
System.gc()
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,6 +610,9 @@ object MessageCrypto {
|
|||||||
result
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
|
} catch (_: OutOfMemoryError) {
|
||||||
|
System.gc()
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +635,9 @@ object MessageCrypto {
|
|||||||
decryptAttachmentBlobWithPlainKey(encryptedData, keyAndNonce)
|
decryptAttachmentBlobWithPlainKey(encryptedData, keyAndNonce)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
|
} catch (_: OutOfMemoryError) {
|
||||||
|
System.gc()
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,6 +772,9 @@ object MessageCrypto {
|
|||||||
result
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
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)
|
val content = tempFile.readText(Charsets.UTF_8)
|
||||||
|
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ fun ChatDetailScreen(
|
|||||||
val messages by viewModel.messages.collectAsState()
|
val messages by viewModel.messages.collectAsState()
|
||||||
val inputText by viewModel.inputText.collectAsState()
|
val inputText by viewModel.inputText.collectAsState()
|
||||||
val isTyping by viewModel.opponentTyping.collectAsState()
|
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
||||||
val rawIsOnline by viewModel.opponentOnline.collectAsState()
|
val rawIsOnline by viewModel.opponentOnline.collectAsState()
|
||||||
// If typing, the user is obviously online — never show "offline" while typing
|
// If typing, the user is obviously online — never show "offline" while typing
|
||||||
@@ -432,7 +433,6 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
// <20>🔥 Reply/Forward state
|
// <20>🔥 Reply/Forward state
|
||||||
val replyMessages by viewModel.replyMessages.collectAsState()
|
val replyMessages by viewModel.replyMessages.collectAsState()
|
||||||
val hasReply = replyMessages.isNotEmpty()
|
|
||||||
val isForwardMode by viewModel.isForwardMode.collectAsState()
|
val isForwardMode by viewModel.isForwardMode.collectAsState()
|
||||||
|
|
||||||
// Avatar-сообщения не должны попадать в selection ни при каких условиях.
|
// Avatar-сообщения не должны попадать в selection ни при каких условиях.
|
||||||
@@ -653,6 +653,7 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Аватар - используем publicKey для консистентности цвета везде
|
// Аватар - используем publicKey для консистентности цвета везде
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val avatarColors =
|
val avatarColors =
|
||||||
getAvatarColor(
|
getAvatarColor(
|
||||||
if (isSavedMessages) "SavedMessages" else user.publicKey,
|
if (isSavedMessages) "SavedMessages" else user.publicKey,
|
||||||
@@ -890,9 +891,9 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
.offset(
|
.offset(
|
||||||
x =
|
x =
|
||||||
(-4).dp,
|
2.dp,
|
||||||
y =
|
y =
|
||||||
6.dp
|
2.dp
|
||||||
)
|
)
|
||||||
.size(
|
.size(
|
||||||
if (totalUnreadFromOthers >
|
if (totalUnreadFromOthers >
|
||||||
@@ -907,7 +908,7 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
.background(
|
.background(
|
||||||
Color(
|
Color(
|
||||||
0xFF3B82F6
|
0xFFFF3B30
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
contentAlignment =
|
contentAlignment =
|
||||||
|
|||||||
@@ -756,6 +756,7 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) }
|
var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) }
|
||||||
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
|
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
|
||||||
|
var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
|
||||||
|
|
||||||
val preview = getPreview(attachment.preview)
|
val preview = getPreview(attachment.preview)
|
||||||
val downloadTag = getDownloadTag(attachment.preview)
|
val downloadTag = getDownloadTag(attachment.preview)
|
||||||
@@ -952,75 +953,76 @@ fun ImageAttachment(
|
|||||||
try {
|
try {
|
||||||
downloadStatus = DownloadStatus.DOWNLOADING
|
downloadStatus = DownloadStatus.DOWNLOADING
|
||||||
|
|
||||||
// Скачиваем зашифрованный контент
|
// Скачиваем зашифрованный контент (с одним авто-ретраем)
|
||||||
val startTime = System.currentTimeMillis()
|
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
|
val downloadTime = System.currentTimeMillis() - startTime
|
||||||
logPhotoDebug(
|
logPhotoDebug(
|
||||||
"CDN download OK: id=$idShort, bytes=${encryptedContent.length}, time=${downloadTime}ms"
|
"CDN download OK: id=$idShort, bytes=${encryptedContent.length}, time=${downloadTime}ms"
|
||||||
)
|
)
|
||||||
downloadProgress = 0.5f
|
|
||||||
|
|
||||||
downloadStatus = DownloadStatus.DECRYPTING
|
processDownloadedImage(
|
||||||
|
encryptedContent = encryptedContent,
|
||||||
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
chachaKey = chachaKey,
|
||||||
// Сначала расшифровываем его, получаем raw bytes
|
privateKey = privateKey,
|
||||||
val decryptedKeyAndNonce =
|
attachment = attachment,
|
||||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
senderPublicKey = senderPublicKey,
|
||||||
logPhotoDebug(
|
|
||||||
"Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Используем 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,
|
context = context,
|
||||||
blob = decrypted,
|
cacheKey = cacheKey,
|
||||||
attachmentId = attachment.id,
|
idShort = idShort,
|
||||||
publicKey = senderPublicKey,
|
onProgress = { downloadProgress = it },
|
||||||
privateKey = privateKey
|
onStatus = { downloadStatus = it },
|
||||||
|
onBitmap = { imageBitmap = it },
|
||||||
|
onError = { label -> errorLabel = label }
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
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) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
downloadStatus = DownloadStatus.ERROR
|
downloadStatus = DownloadStatus.ERROR
|
||||||
|
errorLabel = "Error"
|
||||||
logPhotoDebug(
|
logPhotoDebug(
|
||||||
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
"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 {
|
} else {
|
||||||
@@ -1373,7 +1375,7 @@ fun ImageAttachment(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text("Expired", fontSize = 12.sp, color = Color.White)
|
Text(errorLabel, fontSize = 12.sp, color = Color.White)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
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.
|
* CDN download + decrypt + cache + save.
|
||||||
* Shared between ReplyBubble and ForwardedImagePreview.
|
* Shared between ReplyBubble and ForwardedImagePreview.
|
||||||
|
|||||||
Reference in New Issue
Block a user