From 8a68ae310777c8d0f1916ba5e9f00e2f6e3f9ddb Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 18:10:24 -0500 Subject: [PATCH] feat(chat): compress gallery/camera photos before encryption (EXIF-safe, skip GIFs) Downscale to 1600px + JPEG 80% on the gallery/camera send path only; keyboard GIFs/stickers/Bitmoji stay untouched to preserve animation/transparency. Applies EXIF rotation and falls back to the original bytes on any failure. Co-Authored-By: Claude Opus 4.8 --- .../app/closer/core/media/MediaCompressor.kt | 81 +++++++++++++++++++ .../ui/messages/components/ChatComponents.kt | 14 ++-- 2 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/app/closer/core/media/MediaCompressor.kt diff --git a/app/src/main/java/app/closer/core/media/MediaCompressor.kt b/app/src/main/java/app/closer/core/media/MediaCompressor.kt new file mode 100644 index 00000000..7b4dd553 --- /dev/null +++ b/app/src/main/java/app/closer/core/media/MediaCompressor.kt @@ -0,0 +1,81 @@ +package app.closer.core.media + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/** + * Downscales + JPEG-compresses photos from the gallery/camera before they're encrypted and + * uploaded, to cut upload time and storage. Deliberately leaves **animated GIFs and already-small + * images untouched** (so keyboard GIFs/stickers keep their animation + transparency — those never + * reach this path anyway) and applies **EXIF orientation** so photos aren't sent sideways. + * + * Never throws: on any failure it returns the original bytes, so a photo is never lost. + */ +object MediaCompressor { + private const val MAX_DIM = 1600 + private const val JPEG_QUALITY = 80 + private const val SKIP_BELOW_BYTES = 200 * 1024 + + fun compressPhoto(input: ByteArray): ByteArray = runCatching { + if (isGif(input)) return input + + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(input, 0, input.size, bounds) + val w = bounds.outWidth + val h = bounds.outHeight + if (w <= 0 || h <= 0) return input + // Already small — leave it (also preserves small stickers/PNGs picked from the gallery). + if (w <= MAX_DIM && h <= MAX_DIM && input.size <= SKIP_BELOW_BYTES) return input + + val opts = BitmapFactory.Options().apply { inSampleSize = sampleSize(w, h, MAX_DIM) } + var bmp = BitmapFactory.decodeByteArray(input, 0, input.size, opts) ?: return input + + // Exact-fit downscale on the long edge. + val longEdge = maxOf(bmp.width, bmp.height) + if (longEdge > MAX_DIM) { + val scale = MAX_DIM.toFloat() / longEdge + val scaled = Bitmap.createScaledBitmap(bmp, (bmp.width * scale).toInt(), (bmp.height * scale).toInt(), true) + if (scaled != bmp) bmp.recycle() + bmp = scaled + } + + // Re-apply EXIF rotation (lost when decoding to a bitmap). + val rotation = orientationDegrees(input) + if (rotation != 0f) { + val m = Matrix().apply { postRotate(rotation) } + val rotated = Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true) + if (rotated != bmp) bmp.recycle() + bmp = rotated + } + + val out = ByteArrayOutputStream() + bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + bmp.recycle() + val result = out.toByteArray() + if (result.isNotEmpty() && result.size < input.size) result else input + }.getOrDefault(input) + + private fun isGif(b: ByteArray): Boolean = + b.size >= 3 && b[0] == 'G'.code.toByte() && b[1] == 'I'.code.toByte() && b[2] == 'F'.code.toByte() + + private fun sampleSize(w: Int, h: Int, target: Int): Int { + var s = 1 + val longEdge = maxOf(w, h) + while (longEdge / (s * 2) >= target) s *= 2 + return s + } + + private fun orientationDegrees(input: ByteArray): Float = runCatching { + val exif = ExifInterface(ByteArrayInputStream(input)) + when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90f + ExifInterface.ORIENTATION_ROTATE_180 -> 180f + ExifInterface.ORIENTATION_ROTATE_270 -> 270f + else -> 0f + } + }.getOrDefault(0f) +} diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt index 7d187ae5..0dd1336b 100644 --- a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import app.closer.core.media.MediaCompressor import app.closer.domain.model.QuestionMessage import coil.ImageLoader import coil.compose.AsyncImage @@ -352,10 +353,13 @@ fun ChatComposer( val context = LocalContext.current val scope = rememberCoroutineScope() - fun readAndSend(uri: Uri) { + // compress = true for real photos (gallery/camera); false for keyboard GIFs/stickers/Bitmoji + // (compressing those would kill animation/transparency). + fun readAndSend(uri: Uri, compress: Boolean) { scope.launch { val bytes = withContext(Dispatchers.IO) { - runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() + val raw = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() + raw?.let { if (compress) MediaCompressor.compressPhoto(it) else it } } bytes?.takeIf { it.isNotEmpty() }?.let(onSendImage) } @@ -363,12 +367,12 @@ fun ChatComposer( val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() - ) { uri: Uri? -> uri?.let { readAndSend(it) } } + ) { uri: Uri? -> uri?.let { readAndSend(it, compress = true) } } var pendingCameraUri by remember { mutableStateOf(null) } val cameraLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.TakePicture() - ) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it) } } + ) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it, compress = true) } } fun launchCamera() { val file = File(context.cacheDir, "chat_capture_${System.currentTimeMillis()}.jpg") @@ -487,7 +491,7 @@ fun ChatComposer( RichContentTextField( value = value, onValueChange = onValueChange, - onImageReceived = { uri -> readAndSend(uri) }, + onImageReceived = { uri -> readAndSend(uri, compress = false) }, modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), hint = "Message…" )