package com.tt.honeyDue.util import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import androidx.exifinterface.media.ExifInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import kotlin.math.roundToInt /** * Android implementation of [ImageCompression]. * * Pipeline: * 1. Decode input bytes → [Bitmap] via [BitmapFactory]. * 2. Read EXIF orientation via [ExifInterface] (from a secondary stream, * since `ExifInterface` consumes it). * 3. Apply orientation rotation/flip into the bitmap via [Matrix]. * 4. If the long edge exceeds [maxEdgePx], downscale preserving aspect. * 5. Re-encode as JPEG at `quality * 100`. * * The output JPEG carries no EXIF orientation tag (the rotation is baked * into pixels), matching the iOS `UIImage.jpegData(compressionQuality:)` * behaviour where the output is always upright. */ actual object ImageCompression { actual suspend fun compress( input: ByteArray, maxEdgePx: Int, quality: Float ): ByteArray = withContext(Dispatchers.Default) { // --- decode --------------------------------------------------------- val decoded = BitmapFactory.decodeByteArray(input, 0, input.size) ?: return@withContext input // not a decodable image — pass through // --- read EXIF orientation ----------------------------------------- val orientation = try { ExifInterface(ByteArrayInputStream(input)) .getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL ) } catch (_: Throwable) { ExifInterface.ORIENTATION_NORMAL } // --- apply EXIF orientation ---------------------------------------- val oriented = applyExifOrientation(decoded, orientation) // --- downscale if needed ------------------------------------------- val scaled = downscaleIfNeeded(oriented, maxEdgePx) // --- encode JPEG --------------------------------------------------- val clampedQuality = quality.coerceIn(0f, 1f) val jpegQuality = (clampedQuality * 100f).roundToInt() val out = ByteArrayOutputStream() scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out) // Free intermediate bitmaps if we allocated new ones. if (scaled !== decoded) scaled.recycle() if (oriented !== decoded && oriented !== scaled) oriented.recycle() decoded.recycle() out.toByteArray() } /** * Apply an EXIF orientation value to a bitmap, returning a new bitmap with * the rotation/flip baked in. If orientation is normal/undefined, the * original bitmap is returned. */ private fun applyExifOrientation(src: Bitmap, orientation: Int): Bitmap { val matrix = Matrix() when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.postRotate(90f); matrix.postScale(-1f, 1f) } ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.postRotate(270f); matrix.postScale(-1f, 1f) } else -> return src // NORMAL or UNDEFINED: nothing to do. } return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true) } /** * Downscale [src] so its longest edge is at most [maxEdgePx], preserving * aspect ratio. Returns the input unchanged if it already fits. */ private fun downscaleIfNeeded(src: Bitmap, maxEdgePx: Int): Bitmap { val longEdge = maxOf(src.width, src.height) if (longEdge <= maxEdgePx || maxEdgePx <= 0) return src val scale = maxEdgePx.toFloat() / longEdge.toFloat() val targetW = (src.width * scale).roundToInt().coerceAtLeast(1) val targetH = (src.height * scale).roundToInt().coerceAtLeast(1) return Bitmap.createScaledBitmap(src, targetW, targetH, /* filter = */ true) } }