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:
Trey T
2026-04-18 13:35:54 -05:00
parent 3069ec41de
commit 10b57aabaa
10 changed files with 599 additions and 0 deletions

View File

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