diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 2135d24..53faeff 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index c56f5a2..c8ded91 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 3020ddf..e7f686f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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( // �🔥 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 = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index e3b1a92..5d9334a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -756,6 +756,7 @@ fun ImageAttachment( } var blurhashBitmap by remember(attachment.id) { mutableStateOf(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.