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

View File

@@ -0,0 +1,59 @@
package com.tt.honeyDue.util
import com.tt.honeyDue.ui.components.CameraPermissionDecision
import com.tt.honeyDue.ui.components.decideCameraPermissionFlow
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure-logic tests for the camera-permission decision function used by
* [CameraPicker].
*
* The function decides what to do when the user taps "Take photo":
* - already granted → [CameraPermissionDecision.Launch]
* - denied but rationale shown → [CameraPermissionDecision.ShowRationale]
* - hard-denied / never asked → [CameraPermissionDecision.Request]
*
* UI for the actual dialog + launcher is exercised manually; this isolates
* the branching logic so regressions are caught by unit tests.
*/
class CameraPermissionStateTest {
@Test
fun granted_leadsToLaunch() {
val decision = decideCameraPermissionFlow(
isGranted = true,
shouldShowRationale = false
)
assertEquals(CameraPermissionDecision.Launch, decision)
}
@Test
fun notGranted_withRationale_leadsToShowRationale() {
val decision = decideCameraPermissionFlow(
isGranted = false,
shouldShowRationale = true
)
assertEquals(CameraPermissionDecision.ShowRationale, decision)
}
@Test
fun notGranted_withoutRationale_leadsToRequest() {
val decision = decideCameraPermissionFlow(
isGranted = false,
shouldShowRationale = false
)
assertEquals(CameraPermissionDecision.Request, decision)
}
@Test
fun granted_takesPrecedenceOverRationaleFlag() {
// Even if the system flags a rationale, we should launch when permission
// is already granted.
val decision = decideCameraPermissionFlow(
isGranted = true,
shouldShowRationale = true
)
assertEquals(CameraPermissionDecision.Launch, decision)
}
}

View File

@@ -0,0 +1,187 @@
package com.tt.honeyDue.util
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlin.math.max
/**
* Unit tests for [ImageCompression] on Android.
*
* Mirrors iOS `ImageCompression.swift` semantics:
* - JPEG quality 0.7
* - Long edge downscaled to max 1920px (aspect preserved)
* - EXIF orientation applied into pixels, result has normalized orientation
*
* Uses Robolectric so real [Bitmap] / [BitmapFactory] / [ExifInterface]
* plumbing is available under JVM unit tests.
*/
@RunWith(RobolectricTestRunner::class)
class ImageCompressionAndroidTest {
// ---- helpers ------------------------------------------------------------
/** Create a solid-color [Bitmap] of the requested size. */
private fun makeBitmap(width: Int, height: Int, color: Int = Color.RED): Bitmap {
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
canvas.drawColor(color)
return bmp
}
/** Encode a bitmap to a JPEG [ByteArray] at max quality (100). */
private fun toJpegBytes(bmp: Bitmap, quality: Int = 100): ByteArray {
val baos = ByteArrayOutputStream()
bmp.compress(Bitmap.CompressFormat.JPEG, quality, baos)
return baos.toByteArray()
}
/** Decode bytes to get final (width, height) of the encoded JPEG. */
private fun dimensionsOf(bytes: ByteArray): Pair<Int, Int> {
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
return opts.outWidth to opts.outHeight
}
/** Read EXIF orientation tag from encoded bytes. */
private fun orientationOf(bytes: ByteArray): Int {
val exif = ExifInterface(ByteArrayInputStream(bytes))
return exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
}
// ---- tests --------------------------------------------------------------
@Test
fun compress_largeImage_returnsSmallerByteArray() = runTest {
// Start with a reasonably large (and thus reasonably compressible) image.
val src = toJpegBytes(makeBitmap(2400, 1600, Color.BLUE), quality = 100)
val out = ImageCompression.compress(src)
assertTrue(
"Expected compressed output to be strictly smaller than input " +
"(src=${src.size}, out=${out.size})",
out.size < src.size
)
}
@Test
fun compress_downscalesLongEdge_to1920_byDefault() = runTest {
val src = toJpegBytes(makeBitmap(3000, 1500))
val out = ImageCompression.compress(src)
val (w, h) = dimensionsOf(out)
assertTrue(
"Long edge must be <= 1920 (got ${max(w, h)})",
max(w, h) <= 1920
)
// Aspect preserved: 3000x1500 → 2:1 → 1920x960.
assertEquals("Width should match downscaled target", 1920, w)
assertEquals("Height should preserve 2:1 aspect", 960, h)
}
@Test
fun compress_respectsCustomMaxEdgePx() = runTest {
val src = toJpegBytes(makeBitmap(1200, 800))
val out = ImageCompression.compress(src, maxEdgePx = 500)
val (w, h) = dimensionsOf(out)
assertTrue(
"Long edge must be <= 500 (got w=$w, h=$h)",
max(w, h) <= 500
)
}
@Test
fun compress_smallImage_isStillRecompressed_atLowerQuality() = runTest {
// Tiny bitmap, encoded at MAX quality so JPEG is relatively fat.
val src = toJpegBytes(makeBitmap(400, 300), quality = 100)
val out = ImageCompression.compress(src, maxEdgePx = 1920, quality = 0.7f)
// Dimensions should NOT be upscaled.
val (w, h) = dimensionsOf(out)
assertEquals(400, w)
assertEquals(300, h)
// Re-encoded at quality 0.7 → bytes should be smaller than the
// quality-100 input for a non-trivial bitmap.
assertTrue(
"Expected re-compressed (q=0.7) output to be smaller than src " +
"(src=${src.size}, out=${out.size})",
out.size < src.size
)
}
@Test
fun compress_normalizesExifOrientation() = runTest {
// Build an image and tag it with EXIF Orientation=6 (rotate 90° CW).
val src = toJpegBytes(makeBitmap(1000, 500))
val tagged = run {
val baos = ByteArrayOutputStream()
baos.write(src)
val bytes = baos.toByteArray()
// Write EXIF into the JPEG via a temp file-backed approach:
// easiest = write to a temp file, set attribute, read back.
val tmp = java.io.File.createTempFile("exif_", ".jpg")
tmp.writeBytes(bytes)
val exif = ExifInterface(tmp.absolutePath)
exif.setAttribute(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_ROTATE_90.toString()
)
exif.saveAttributes()
val result = tmp.readBytes()
tmp.delete()
result
}
// Sanity: tagged input actually carries orientation=6.
assertEquals(
ExifInterface.ORIENTATION_ROTATE_90,
orientationOf(tagged)
)
val out = ImageCompression.compress(tagged)
// After compression, orientation should be normalized
// (applied into pixels), so the tag should be NORMAL (1) or missing.
val outOrientation = orientationOf(out)
assertTrue(
"Expected normalized orientation (NORMAL or UNDEFINED), got $outOrientation",
outOrientation == ExifInterface.ORIENTATION_NORMAL ||
outOrientation == ExifInterface.ORIENTATION_UNDEFINED
)
}
@Test
fun compress_preservesImageUsability() = runTest {
val src = toJpegBytes(makeBitmap(800, 600, Color.GREEN))
val out = ImageCompression.compress(src)
// Result must be decodable back into a Bitmap.
val decoded = BitmapFactory.decodeByteArray(out, 0, out.size)
assertNotNull("Compressed output must be a valid JPEG", decoded)
assertTrue(decoded!!.width > 0)
assertTrue(decoded.height > 0)
}
}

View File

@@ -0,0 +1,46 @@
package com.tt.honeyDue.ui.components
/**
* Shared camera-picker helpers (commonMain).
*
* The actual `rememberCameraPicker` composable lives at
* `com.tt.honeyDue.platform.rememberCameraPicker` (expect/actual per
* platform). This file captures the pure logic that decides *what* to do
* when a user taps "Take photo" so it can be unit-tested without touching
* the ActivityResult contract or iOS `UIImagePickerController`.
*
* Mirrors the iOS reference `CameraPickerView.swift`, which simply falls
* back to the photo library if the camera source type is unavailable. On
* Android we add a permission-rationale step to match Google's UX
* guidelines, then delegate to the platform picker.
*/
/** The three possible outcomes of "user tapped Take photo". */
enum class CameraPermissionDecision {
/** Camera permission already granted — launch the picker immediately. */
Launch,
/** Permission denied previously; show a rationale dialog before asking again. */
ShowRationale,
/** First request (or "don't ask again" cleared) — prompt the OS directly. */
Request,
}
/**
* Pure decision function: given the current permission state, return the
* next UI action.
*
* @param isGranted `true` if the camera permission is currently held.
* @param shouldShowRationale Android's `shouldShowRequestPermissionRationale`
* flag — `true` when the user has previously denied the permission but
* hasn't selected "don't ask again".
*/
fun decideCameraPermissionFlow(
isGranted: Boolean,
shouldShowRationale: Boolean,
): CameraPermissionDecision = when {
isGranted -> CameraPermissionDecision.Launch
shouldShowRationale -> CameraPermissionDecision.ShowRationale
else -> CameraPermissionDecision.Request
}

View File

@@ -0,0 +1,41 @@
package com.tt.honeyDue.util
/**
* Cross-platform image compression matching the iOS helper
* `iosApp/iosApp/Helpers/ImageCompression.swift`.
*
* Contract:
* - Input is a raw encoded-image [ByteArray] (JPEG, PNG, HEIC…).
* - Output is a JPEG-encoded [ByteArray] at [quality] (default 0.7), with
* the long edge clamped to [maxEdgePx] (default 1920px) while preserving
* aspect ratio.
* - EXIF orientation is applied into the pixel data; the output image is
* always in the canonical "upright" orientation (no orientation tag, or
* `ORIENTATION_NORMAL`).
*
* Platforms:
* - Android → `BitmapFactory` + `ExifInterface` + `Matrix` + JPEG compress.
* - iOS → `UIImage(data:)` + `UIImage.jpegData(compressionQuality:)`.
* `UIImage` normalizes EXIF automatically during decode.
* - JVM / JS / WASM → no-op pass-through (web/desktop do not run this path
* in production; returning the input keeps common code simple).
*
* This replaces the size-capped `ImageCompressor` helper for new call sites
* that want to match iOS (quality-based, not size-based) semantics.
*/
expect object ImageCompression {
/**
* Compress [input] to JPEG, optionally downscaling.
*
* @param input encoded image bytes (JPEG/PNG/HEIC supported by the platform).
* @param maxEdgePx maximum long-edge size in pixels (aspect preserved).
* Defaults to `1920` to match iOS.
* @param quality JPEG quality in `[0.0f, 1.0f]`. Defaults to `0.7f` to match iOS.
* @return compressed JPEG bytes, or the original input on decode failure.
*/
suspend fun compress(
input: ByteArray,
maxEdgePx: Int = 1920,
quality: Float = 0.7f
): ByteArray
}

View File

@@ -0,0 +1,105 @@
package com.tt.honeyDue.util
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.useContents
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.CoreGraphics.CGRectMake
import platform.CoreGraphics.CGSizeMake
import platform.Foundation.NSData
import platform.Foundation.create
import platform.UIKit.UIGraphicsBeginImageContextWithOptions
import platform.UIKit.UIGraphicsEndImageContext
import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
import platform.UIKit.UIImage
import platform.UIKit.UIImageJPEGRepresentation
import platform.posix.memcpy
import kotlin.math.max
/**
* iOS implementation of [ImageCompression].
*
* Matches the iOS reference helper at
* `iosApp/iosApp/Helpers/ImageCompression.swift`:
* - decode bytes into a [UIImage]
* - optionally downscale so the long edge ≤ `maxEdgePx`, aspect preserved
* - JPEG-encode at `quality` via [UIImageJPEGRepresentation]
*
* `UIImage(data:)` preserves EXIF via `imageOrientation`; drawing the
* image into a graphics context — or calling `UIImageJPEGRepresentation`
* directly — bakes that orientation into the output pixels so the result
* is always upright (no orientation tag round-tripped).
*/
@OptIn(ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class)
actual object ImageCompression {
actual suspend fun compress(
input: ByteArray,
maxEdgePx: Int,
quality: Float
): ByteArray = withContext(Dispatchers.Default) {
// --- wrap input in NSData -----------------------------------------
val nsData: NSData = input.usePinned { pinned ->
NSData.create(
bytes = pinned.addressOf(0),
length = input.size.toULong()
)
}
// --- decode UIImage -----------------------------------------------
val decoded = UIImage.imageWithData(nsData) ?: return@withContext input
// --- downscale if needed ------------------------------------------
val resized = downscaleIfNeeded(decoded, maxEdgePx)
// --- encode JPEG (orientation is baked into pixels) ---------------
val clampedQuality = quality.coerceIn(0f, 1f).toDouble()
val jpeg = UIImageJPEGRepresentation(resized, clampedQuality)
?: return@withContext input
jpeg.toByteArray()
}
/**
* Downscale a [UIImage] so its long edge is at most [maxEdgePx].
* Aspect ratio is preserved. Returns the original image if it already
* fits. Drawing through `UIGraphicsBeginImageContextWithOptions` also
* normalizes `imageOrientation` into the pixel data.
*/
private fun downscaleIfNeeded(image: UIImage, maxEdgePx: Int): UIImage {
if (maxEdgePx <= 0) return image
val (srcW, srcH) = image.size.useContents { width to height }
val longEdge = max(srcW, srcH)
if (longEdge <= maxEdgePx) return image
val scale = maxEdgePx.toDouble() / longEdge
val targetW = (srcW * scale).coerceAtLeast(1.0)
val targetH = (srcH * scale).coerceAtLeast(1.0)
// `scale = 1.0` keeps the output at target pixel dimensions
// regardless of screen scale (important in unit tests / server uploads).
UIGraphicsBeginImageContextWithOptions(
size = CGSizeMake(targetW, targetH),
opaque = false,
scale = 1.0
)
image.drawInRect(CGRectMake(0.0, 0.0, targetW, targetH))
val output = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return output ?: image
}
/** Copy an [NSData] buffer into a Kotlin [ByteArray]. */
private fun NSData.toByteArray(): ByteArray {
val len = length.toInt()
if (len == 0) return ByteArray(0)
return ByteArray(len).apply {
usePinned { pinned ->
memcpy(pinned.addressOf(0), this@toByteArray.bytes, this@toByteArray.length)
}
}
}
}

View File

@@ -0,0 +1,17 @@
package com.tt.honeyDue.util
/**
* JS / Web no-op implementation of [ImageCompression].
*
* Web targets are not a production upload path today. Returning the input
* unchanged keeps the `commonMain` API callable from Kotlin/JS without
* pulling in a canvas-based resizer; add one here if/when web uploads
* ship.
*/
actual object ImageCompression {
actual suspend fun compress(
input: ByteArray,
maxEdgePx: Int,
quality: Float
): ByteArray = input
}

View File

@@ -0,0 +1,18 @@
package com.tt.honeyDue.util
/**
* JVM / Desktop no-op implementation of [ImageCompression].
*
* Image compression on Desktop is not exercised by the app today — uploads
* happen from mobile only. Returning the input unchanged keeps call sites
* in common code compiling and functional (the server accepts the raw
* bytes at worst). Replace with an ImageIO/TwelveMonkeys pipeline if this
* path is ever needed.
*/
actual object ImageCompression {
actual suspend fun compress(
input: ByteArray,
maxEdgePx: Int,
quality: Float
): ByteArray = input
}

View File

@@ -0,0 +1,16 @@
package com.tt.honeyDue.util
/**
* WASM / Web no-op implementation of [ImageCompression].
*
* WASM web targets are not a production upload path today. Returning the
* input unchanged keeps the `commonMain` API callable without pulling in
* a canvas-based resizer; add one here if/when web uploads ship.
*/
actual object ImageCompression {
actual suspend fun compress(
input: ByteArray,
maxEdgePx: Int,
quality: Float
): ByteArray = input
}