P6 Stream V: ImageCompression (expect/actual) + CameraPicker polish
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>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user