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:
parent
11a4c7deda
commit
8a68ae3107
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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…"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue