From b6f266faa4a94103ecd629e6eac8d16641f9be72 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 20 Feb 2026 05:39:27 +0500 Subject: [PATCH] feat: Enhance background color animation on theme change in SwipeableDialogItem --- .../messenger/network/TransportManager.kt | 17 +-- .../messenger/ui/chats/ChatDetailScreen.kt | 21 +++ .../messenger/ui/chats/ChatViewModel.kt | 135 +++++++++++++++++- .../messenger/ui/chats/ChatsListScreen.kt | 7 +- .../chats/components/ChatDetailComponents.kt | 9 ++ .../com/rosetta/messenger/utils/MediaUtils.kt | 117 ++++++++++++--- 6 files changed, 272 insertions(+), 34 deletions(-) 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 64fe588..243c920 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -64,7 +64,6 @@ object TransportManager { */ private suspend fun getActiveServer(): String { transportServer?.let { return it } - requestTransportServer() repeat(40) { // 10s total val server = transportServer @@ -73,7 +72,6 @@ object TransportManager { } delay(250) } - throw IOException("Transport server is not set") } @@ -111,17 +109,16 @@ object TransportManager { */ suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) { val server = getActiveServer() + ProtocolManager.addLog("📤 Upload start: id=${id.take(8)}, server=$server") // Добавляем в список загрузок _uploading.value = _uploading.value + TransportState(id, 0) try { withRetry { - // 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content])) val contentBytes = content.toByteArray(Charsets.UTF_8) val totalSize = contentBytes.size.toLong() - // 🔥 RequestBody с отслеживанием прогресса загрузки val progressRequestBody = object : RequestBody() { override fun contentType() = "application/octet-stream".toMediaType() override fun contentLength() = totalSize @@ -129,7 +126,7 @@ object TransportManager { override fun writeTo(sink: okio.BufferedSink) { val source = okio.Buffer().write(contentBytes) var uploaded = 0L - val bufferSize = 8 * 1024L // 8 KB chunks + val bufferSize = 8 * 1024L while (true) { val read = source.read(sink.buffer, bufferSize) @@ -138,7 +135,6 @@ object TransportManager { uploaded += read sink.flush() - // Обновляем прогресс val progress = ((uploaded * 100) / totalSize).toInt() _uploading.value = _uploading.value.map { if (it.id == id) it.copy(progress = progress) else it @@ -175,8 +171,6 @@ object TransportManager { val responseBody = response.body?.string() ?: throw IOException("Empty response") - - // Parse JSON response to get tag val tag = org.json.JSONObject(responseBody).getString("t") // Обновляем прогресс до 100% @@ -184,8 +178,15 @@ object TransportManager { if (it.id == id) it.copy(progress = 100) else it } + ProtocolManager.addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}") + tag } + } catch (e: Exception) { + ProtocolManager.addLog( + "❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" + ) + throw e } finally { // Удаляем из списка загрузок _uploading.value = _uploading.value.filter { it.id != id } 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 ba49184..095e1ca 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 @@ -69,6 +69,7 @@ import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.AttachmentType +import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.components.* @@ -355,11 +356,18 @@ fun ChatDetailScreen( } } + DisposableEffect(Unit) { + ProtocolManager.enableUILogs(true) + onDispose { ProtocolManager.enableUILogs(false) } + } + // Состояние выпадающего меню var showMenu by remember { mutableStateOf(false) } + var showDebugLogs by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } var showBlockConfirm by remember { mutableStateOf(false) } var showUnblockConfirm by remember { mutableStateOf(false) } + val debugLogs by ProtocolManager.debugLogs.collectAsState() // Наблюдаем за статусом блокировки в реальном времени через Flow val isBlocked by database.blacklistDao() @@ -1178,6 +1186,10 @@ fun ChatDetailScreen( isSystemAccount, isBlocked = isBlocked, + onLogsClick = { + showMenu = false + showDebugLogs = true + }, onBlockClick = { showMenu = false @@ -2480,4 +2492,13 @@ fun ChatDetailScreen( recipientName = user.title ) } + + if (showDebugLogs) { + DebugLogsBottomSheet( + logs = debugLogs, + isDarkTheme = isDarkTheme, + onDismiss = { showDebugLogs = false }, + onClearLogs = { ProtocolManager.clearLogs() } + ) + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index b0754f5..4c7201f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -446,6 +446,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { updateCacheFromCurrentMessages() } + private fun shortPhotoId(value: String, limit: Int = 8): String { + val trimmed = value.trim() + if (trimmed.isEmpty()) return "unknown" + return if (trimmed.length <= limit) trimmed else trimmed.take(limit) + } + + private fun logPhotoPipeline(messageId: String, message: String) { + ProtocolManager.addLog("📸 IMG ${shortPhotoId(messageId)} | $message") + } + + private fun logPhotoPipelineError(messageId: String, stage: String, throwable: Throwable) { + val reason = throwable.message ?: "unknown" + ProtocolManager.addLog( + "❌ IMG ${shortPhotoId(messageId)} | $stage failed: ${throwable.javaClass.simpleName}: $reason" + ) + } + /** 🔄 Очистить localUri в attachments сообщения (после успешной отправки) */ private fun updateMessageAttachments(messageId: String, localUri: String?) { _messages.value = @@ -2240,6 +2257,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val context = getApplication() if (recipient == null || sender == null || privateKey == null) { + ProtocolManager.addLog( + "❌ IMG send aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})" + ) return } @@ -2248,10 +2268,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val text = caption.trim() val attachmentId = "img_$timestamp" + logPhotoPipeline( + messageId, + "start: uri=${imageUri.lastPathSegment ?: "unknown"}, captionLen=${text.length}, attachment=${shortPhotoId(attachmentId, 12)}" + ) + // 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла) // Это предотвращает "расширение" пузырька при первом показе val (imageWidth, imageHeight) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) + logPhotoPipeline(messageId, "dimensions: ${imageWidth}x$imageHeight") // 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ // Используем URI напрямую для отображения (без конвертации в base64) @@ -2282,11 +2308,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) addMessageSafely(optimisticMessage) _inputText.value = "" + logPhotoPipeline(messageId, "optimistic UI added") // 2. 🔄 В фоне, независимо от жизненного цикла экрана: // сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет. backgroundUploadScope.launch { try { + logPhotoPipeline(messageId, "persist optimistic message in DB") val optimisticAttachmentsJson = JSONArray() .apply { @@ -2323,16 +2351,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { timestamp = timestamp, opponentPublicKey = recipient ) + logPhotoPipeline(messageId, "optimistic dialog updated") } catch (_: Exception) { + logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)") } try { + val convertStartedAt = System.currentTimeMillis() val (width, height) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) val imageBase64 = com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri) if (imageBase64 == null) { + logPhotoPipeline(messageId, "base64 conversion returned null") if (!isCleared) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) @@ -2340,14 +2372,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } return@launch } + logPhotoPipeline( + messageId, + "base64 ready: len=${imageBase64.length}, elapsed=${System.currentTimeMillis() - convertStartedAt}ms" + ) val blurhash = com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri) + logPhotoPipeline(messageId, "blurhash ready: len=${blurhash.length}") if (!isCleared) { withContext(Dispatchers.Main) { updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height) } + logPhotoPipeline(messageId, "optimistic payload updated in UI") } sendImageMessageInternal( @@ -2362,7 +2400,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { sender = sender, privateKey = privateKey ) - } catch (_: Exception) { + logPhotoPipeline(messageId, "pipeline completed") + } catch (e: Exception) { + logPhotoPipelineError(messageId, "prepare+convert", e) if (!isCleared) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) @@ -2415,27 +2455,53 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) { try { val context = getApplication() + val pipelineStartedAt = System.currentTimeMillis() + logPhotoPipeline( + messageId, + "internal send start: base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}, captionLen=${caption.length}" + ) // Шифрование текста + val encryptStartedAt = System.currentTimeMillis() val encryptResult = MessageCrypto.encryptForSending(caption, recipient) val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey val plainKeyAndNonce = encryptResult.plainKeyAndNonce val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey) + logPhotoPipeline( + messageId, + "text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms" + ) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) // 🚀 Шифруем изображение с ChaCha ключом для Transport Server + val blobEncryptStartedAt = System.currentTimeMillis() val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce) + logPhotoPipeline( + messageId, + "blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms" + ) val attachmentId = "img_$timestamp" + logPhotoPipeline( + messageId, + "attachment prepared: id=${shortPhotoId(attachmentId, 12)}, size=${width}x$height" + ) // 📤 Загружаем на Transport Server val isSavedMessages = (sender == recipient) var uploadTag = "" if (!isSavedMessages) { + logPhotoPipeline(messageId, "upload start: attachment=${shortPhotoId(attachmentId, 12)}") uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) + logPhotoPipeline( + messageId, + "upload done: tag=${shortPhotoId(uploadTag, 12)}" + ) + } else { + logPhotoPipeline(messageId, "saved-messages mode: upload skipped") } // Preview содержит tag::blurhash @@ -2467,16 +2533,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Отправляем пакет if (!isSavedMessages) { ProtocolManager.send(packet) + logPhotoPipeline(messageId, "packet sent to protocol") + } else { + logPhotoPipeline(messageId, "saved-messages mode: packet send skipped") } // 💾 Сохраняем изображение в файл локально - AttachmentFileManager.saveAttachment( + val savedLocally = + AttachmentFileManager.saveAttachment( context = context, blob = imageBase64, attachmentId = attachmentId, publicKey = sender, privateKey = privateKey ) + logPhotoPipeline(messageId, "local file cache saved=$savedLocally") // Сохраняем в БД val attachmentsJson = @@ -2504,19 +2575,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } else { updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson) } + logPhotoPipeline(messageId, "db status+attachments updated") withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) // Также очищаем localUri в UI updateMessageAttachments(messageId, null) } + logPhotoPipeline(messageId, "ui status switched to SENT") saveDialog( lastMessage = if (caption.isNotEmpty()) caption else "photo", timestamp = timestamp, opponentPublicKey = recipient ) + logPhotoPipeline( + messageId, + "dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms" + ) } catch (e: Exception) { + logPhotoPipelineError(messageId, "internal-send", e) withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } } } @@ -2687,7 +2765,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { opponentPublicKey = recipient ) } catch (e: Exception) { - withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } } finally { isSending = false } @@ -2719,10 +2797,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } val context = getApplication() + val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8) + ProtocolManager.addLog( + "📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}" + ) backgroundUploadScope.launch { val preparedImages = - imageUris.mapNotNull { uri -> + imageUris.mapIndexedNotNull { index, uri -> val (width, height) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions( context, @@ -2733,12 +2815,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { context, uri ) - ?: return@mapNotNull null + ?: run { + ProtocolManager.addLog( + "❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed" + ) + return@mapIndexedNotNull null + } val blurhash = com.rosetta.messenger.utils.MediaUtils.generateBlurhash( context, uri ) + ProtocolManager.addLog( + "📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}" + ) ImageData( base64 = imageBase64, blurhash = blurhash, @@ -2747,7 +2837,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) } - if (preparedImages.isEmpty()) return@launch + if (preparedImages.isEmpty()) { + ProtocolManager.addLog( + "❌ IMG-GROUP $groupDebugId | no prepared images, send canceled" + ) + return@launch + } + ProtocolManager.addLog( + "📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}" + ) withContext(Dispatchers.Main) { sendImageGroup(preparedImages, caption) @@ -2786,6 +2884,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() val text = caption.trim() + logPhotoPipeline( + messageId, + "group start: count=${images.size}, captionLen=${text.length}" + ) // Создаём attachments для всех изображений val attachmentsList = @@ -2816,12 +2918,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { backgroundUploadScope.launch { try { + val groupStartedAt = System.currentTimeMillis() // Шифрование текста val encryptResult = MessageCrypto.encryptForSending(text, recipient) val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey val plainKeyAndNonce = encryptResult.plainKeyAndNonce val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey) + logPhotoPipeline( + messageId, + "group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}" + ) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) @@ -2831,6 +2938,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { for ((index, imageData) in images.withIndex()) { val attachmentId = "img_${timestamp}_$index" + logPhotoPipeline( + messageId, + "group item#$index start: id=${shortPhotoId(attachmentId, 12)}, size=${imageData.width}x${imageData.height}" + ) // Шифруем изображение с ChaCha ключом val encryptedImageBlob = @@ -2844,6 +2955,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } else { imageData.blurhash } + logPhotoPipeline( + messageId, + "group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}" + ) // Сохраняем в файл локально AttachmentFileManager.saveAttachment( @@ -2897,6 +3012,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val isSavedMessages = (sender == recipient) if (!isSavedMessages) { ProtocolManager.send(packet) + logPhotoPipeline(messageId, "group packet sent") } // Сохраняем в БД @@ -2925,8 +3041,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { timestamp = timestamp, opponentPublicKey = recipient ) + logPhotoPipeline( + messageId, + "group completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms" + ) } catch (e: Exception) { - withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } + logPhotoPipelineError(messageId, "group-send", e) + withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } } finally { isSending = false } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index d028add..5a34c15 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -2544,10 +2544,15 @@ fun SwipeableDialogItem( } else { if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) } + var previousTheme by remember { mutableStateOf(isDarkTheme) } + val themeJustChanged = previousTheme != isDarkTheme + SideEffect { previousTheme = isDarkTheme } val backgroundColor by animateColorAsState( targetValue = targetBackgroundColor, - animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing), + animationSpec = + if (themeJustChanged) snap() + else tween(durationMillis = 260, easing = FastOutSlowInEasing), label = "pinnedBackground" ) var offsetX by remember { mutableStateOf(0f) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index ca28e72..f5a3ff6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -1863,6 +1863,7 @@ fun KebabMenu( isSavedMessages: Boolean, isSystemAccount: Boolean = false, isBlocked: Boolean, + onLogsClick: () -> Unit, onBlockClick: () -> Unit, onUnblockClick: () -> Unit, onDeleteClick: () -> Unit @@ -1891,6 +1892,14 @@ fun KebabMenu( dismissOnClickOutside = true ) ) { + KebabMenuItem( + icon = TelegramIcons.Info, + text = "Debug Logs", + onClick = onLogsClick, + tintColor = iconColor, + textColor = textColor + ) + if (!isSavedMessages && !isSystemAccount) { KebabMenuItem( icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block, diff --git a/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt index 1336f22..402c675 100644 --- a/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt +++ b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt @@ -3,10 +3,13 @@ package com.rosetta.messenger.utils import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.ImageDecoder import android.graphics.Matrix import android.net.Uri +import android.os.Build import android.util.Base64 import androidx.exifinterface.media.ExifInterface +import com.rosetta.messenger.network.ProtocolManager import com.vanniktech.blurhash.BlurHash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -28,6 +31,10 @@ object MediaUtils { // Android ограничение: файл + base64 + шифрование = ~3x памяти // 20 МБ файл = ~60 МБ RAM, безопасно для большинства устройств const val MAX_FILE_SIZE_MB = 20 + + private fun logImage(message: String) { + ProtocolManager.addLog("🧪 IMG-UTIL | $message") + } /** * Конвертировать изображение из Uri в Base64 PNG @@ -35,8 +42,12 @@ object MediaUtils { */ suspend fun uriToBase64Image(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) { try { + val uriInfo = "${uri.scheme ?: "unknown"}:${uri.lastPathSegment ?: "unknown"}" + logImage("encode start: $uriInfo") + // Читаем EXIF ориентацию val orientation = getExifOrientation(context, uri) + logImage("orientation=$orientation") val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } @@ -45,47 +56,117 @@ object MediaUtils { } ?: return@withContext null if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) { + logImage("bounds decode failed, trying direct decode fallback") + } + + val sourceWidth = boundsOptions.outWidth.coerceAtLeast(1) + val sourceHeight = boundsOptions.outHeight.coerceAtLeast(1) + val initialSample = + calculateInSampleSize( + sourceWidth, + sourceHeight, + MAX_IMAGE_SIZE * 2 + ) + + var bitmap: Bitmap? = null + var sample = initialSample.coerceAtLeast(1) + + repeat(2) { attempt -> + if (bitmap != null) return@repeat + val decodeOptions = + BitmapFactory.Options().apply { + inSampleSize = sample + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + + bitmap = + context.contentResolver.openInputStream(uri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeOptions) + } + + if (bitmap == null) { + bitmap = context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, decodeOptions) + } + } + + if (bitmap == null) { + logImage("decode attempt ${attempt + 1} failed (sample=$sample)") + sample *= 2 + } + } + + if (bitmap == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + bitmap = decodeWithImageDecoder(context, uri) + if (bitmap != null) { + logImage("decoded via ImageDecoder fallback") + } + } + + if (bitmap == null) { + logImage("decode failed after all fallbacks") return@withContext null } - val decodeOptions = - BitmapFactory.Options().apply { - inSampleSize = - calculateInSampleSize( - boundsOptions.outWidth, - boundsOptions.outHeight, - MAX_IMAGE_SIZE * 2 - ) - inPreferredConfig = Bitmap.Config.ARGB_8888 + val decodedBitmap = bitmap + ?: run { + logImage("decode failed: bitmap is null after fallbacks") + return@withContext null } - var bitmap = - context.contentResolver.openInputStream(uri)?.use { inputStream -> - BitmapFactory.decodeStream(inputStream, null, decodeOptions) - } ?: return@withContext null // Применяем EXIF ориентацию (поворот/отражение) - bitmap = applyExifOrientation(bitmap, orientation) + val orientedBitmap = applyExifOrientation(decodedBitmap, orientation) // Масштабируем если слишком большое - val scaledBitmap = scaleDownBitmap(bitmap, MAX_IMAGE_SIZE) - if (scaledBitmap != bitmap) { - bitmap.recycle() + val scaledBitmap = scaleDownBitmap(orientedBitmap, MAX_IMAGE_SIZE) + if (scaledBitmap != orientedBitmap) { + orientedBitmap.recycle() } // Конвертируем в PNG Base64 val outputStream = ByteArrayOutputStream() - scaledBitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream) + val compressed = scaledBitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream) + if (!compressed) { + logImage("bitmap compress failed") + scaledBitmap.recycle() + return@withContext null + } val bytes = outputStream.toByteArray() val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP) scaledBitmap.recycle() + logImage("encode success: outBytes=${bytes.size}") base64 } catch (e: Exception) { + logImage("encode exception: ${e.javaClass.simpleName}: ${e.message ?: "unknown"}") null } catch (e: OutOfMemoryError) { + logImage("encode OOM: ${e.message ?: "unknown"}") + null + } + } + + private fun decodeWithImageDecoder(context: Context, uri: Uri): Bitmap? { + return try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null + val source = ImageDecoder.createSource(context.contentResolver, uri) + ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + val size = info.size + val maxDimension = maxOf(size.width, size.height) + if (maxDimension > MAX_IMAGE_SIZE * 2) { + val ratio = (MAX_IMAGE_SIZE * 2).toFloat() / maxDimension.toFloat() + val targetW = (size.width * ratio).toInt().coerceAtLeast(1) + val targetH = (size.height * ratio).toInt().coerceAtLeast(1) + decoder.setTargetSize(targetW, targetH) + } + } + } catch (_: Exception) { + null + } catch (_: OutOfMemoryError) { null } }