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