android: compress images before media upload
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 22:35:49 +03:00
parent bd1229fe5a
commit 854ba0cbc6
2 changed files with 77 additions and 6 deletions

View File

@@ -639,3 +639,11 @@
- Added Android support for `GET /api/v1/chats/saved`. - Added Android support for `GET /api/v1/chats/saved`.
- Wired chats overflow `Saved` action to real backend request (instead of local title heuristic). - 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. - 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.

View File

@@ -1,5 +1,7 @@
package ru.daemonlord.messenger.data.media.repository package ru.daemonlord.messenger.data.media.repository
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull 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.AppError
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -32,15 +36,20 @@ class NetworkMediaRepository @Inject constructor(
bytes: ByteArray, bytes: ByteArray,
): AppResult<Unit> = withContext(ioDispatcher) { ): AppResult<Unit> = withContext(ioDispatcher) {
try { try {
val uploadPayload = prepareUploadPayload(
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
)
val uploadInfo = mediaApiService.requestUploadUrl( val uploadInfo = mediaApiService.requestUploadUrl(
request = UploadUrlRequestDto( request = UploadUrlRequestDto(
fileName = fileName, fileName = uploadPayload.fileName,
fileType = mimeType, fileType = uploadPayload.mimeType,
fileSize = bytes.size.toLong(), fileSize = uploadPayload.bytes.size.toLong(),
) )
) )
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) val body = uploadPayload.bytes.toRequestBody(uploadPayload.mimeType.toMediaTypeOrNull())
val uploadRequestBuilder = Request.Builder() val uploadRequestBuilder = Request.Builder()
.url(uploadInfo.uploadUrl) .url(uploadInfo.uploadUrl)
.put(body) .put(body)
@@ -61,8 +70,8 @@ class NetworkMediaRepository @Inject constructor(
request = AttachmentCreateRequestDto( request = AttachmentCreateRequestDto(
messageId = messageId, messageId = messageId,
fileUrl = uploadInfo.fileUrl, fileUrl = uploadInfo.fileUrl,
fileType = mimeType, fileType = uploadPayload.mimeType,
fileSize = bytes.size.toLong(), fileSize = uploadPayload.bytes.size.toLong(),
) )
) )
AppResult.Success(Unit) AppResult.Success(Unit)
@@ -70,4 +79,58 @@ class NetworkMediaRepository @Inject constructor(
AppResult.Error(error.toAppError()) 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,
)
} }