From 854ba0cbc60ae47a5b29bbd073e5d8789b17c6df Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 22:35:49 +0300 Subject: [PATCH] android: compress images before media upload --- android/CHANGELOG.md | 8 ++ .../repository/NetworkMediaRepository.kt | 75 +++++++++++++++++-- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 3af99c4..12d2abd 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -639,3 +639,11 @@ - Added Android support for `GET /api/v1/chats/saved`. - Wired chats overflow `Saved` action to real backend request (instead of local title heuristic). - Saved chat is now upserted into local Room cache and opened via normal navigation flow. + +### Step 100 - Android image compression before upload +- Added pre-upload image compression in Android media pipeline (`NetworkMediaRepository`). +- For non-GIF images: + - decode + resize with max side `1920`, + - re-encode as `image/jpeg` with quality `82`, + - keep original bytes if compression does not reduce payload size. +- Upload request and attachment metadata now use actual prepared payload (`fileName`, `fileType`, `fileSize`), matching web behavior. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt index 8e2c52b..79539fe 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt @@ -1,5 +1,7 @@ package ru.daemonlord.messenger.data.media.repository +import android.graphics.Bitmap +import android.graphics.BitmapFactory import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -15,6 +17,8 @@ import ru.daemonlord.messenger.di.RefreshClient import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.media.repository.MediaRepository +import java.io.ByteArrayOutputStream +import kotlin.math.roundToInt import javax.inject.Inject import javax.inject.Singleton @@ -32,15 +36,20 @@ class NetworkMediaRepository @Inject constructor( bytes: ByteArray, ): AppResult = withContext(ioDispatcher) { try { + val uploadPayload = prepareUploadPayload( + fileName = fileName, + mimeType = mimeType, + bytes = bytes, + ) val uploadInfo = mediaApiService.requestUploadUrl( request = UploadUrlRequestDto( - fileName = fileName, - fileType = mimeType, - fileSize = bytes.size.toLong(), + fileName = uploadPayload.fileName, + fileType = uploadPayload.mimeType, + fileSize = uploadPayload.bytes.size.toLong(), ) ) - val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) + val body = uploadPayload.bytes.toRequestBody(uploadPayload.mimeType.toMediaTypeOrNull()) val uploadRequestBuilder = Request.Builder() .url(uploadInfo.uploadUrl) .put(body) @@ -61,8 +70,8 @@ class NetworkMediaRepository @Inject constructor( request = AttachmentCreateRequestDto( messageId = messageId, fileUrl = uploadInfo.fileUrl, - fileType = mimeType, - fileSize = bytes.size.toLong(), + fileType = uploadPayload.mimeType, + fileSize = uploadPayload.bytes.size.toLong(), ) ) AppResult.Success(Unit) @@ -70,4 +79,58 @@ class NetworkMediaRepository @Inject constructor( AppResult.Error(error.toAppError()) } } + + private fun prepareUploadPayload( + fileName: String, + mimeType: String, + bytes: ByteArray, + ): UploadPayload { + if (!mimeType.startsWith("image/", ignoreCase = true)) { + return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes) + } + if (mimeType.equals("image/gif", ignoreCase = true)) { + return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes) + } + val source = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() + ?: return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes) + val maxSide = 1920 + val width = source.width + val height = source.height + val scale = (maxSide.toFloat() / maxOf(width, height).toFloat()).coerceAtMost(1f) + val targetWidth = (width * scale).roundToInt().coerceAtLeast(1) + val targetHeight = (height * scale).roundToInt().coerceAtLeast(1) + + val scaled = if (targetWidth != width || targetHeight != height) { + Bitmap.createScaledBitmap(source, targetWidth, targetHeight, true) + } else { + source + } + val output = ByteArrayOutputStream() + val compressedOk = runCatching { + scaled.compress(Bitmap.CompressFormat.JPEG, 82, output) + }.getOrDefault(false) + if (scaled !== source) { + scaled.recycle() + } + source.recycle() + if (!compressedOk) { + return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes) + } + val compressedBytes = output.toByteArray() + if (compressedBytes.size >= bytes.size) { + return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes) + } + val baseName = fileName.substringBeforeLast('.').ifBlank { "image" } + return UploadPayload( + fileName = "$baseName-web.jpg", + mimeType = "image/jpeg", + bytes = compressedBytes, + ) + } + + private data class UploadPayload( + val fileName: String, + val mimeType: String, + val bytes: ByteArray, + ) }