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 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-24 18:10:24 -05:00
parent 11a4c7deda
commit 8a68ae3107
2 changed files with 90 additions and 5 deletions

View File

@ -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)
}

View File

@ -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<Uri?>(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…"
)