10b57aabaa
Android: BitmapFactory + ExifInterface + JPEG quality 0.7 + 1920px downscale. iOS: UIImage.jpegData. JVM/JS/WASM: no-op. CameraPicker uses TakePicture ActivityResult + permission rationale. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
108 lines
4.4 KiB
Kotlin
108 lines
4.4 KiB
Kotlin
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)
|
|
}
|
|
}
|