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:
@@ -78,6 +78,9 @@ kotlin {
|
||||
// Biometric authentication (requires FragmentActivity)
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.5")
|
||||
|
||||
// EXIF orientation reader for ImageCompression (P6 Stream V)
|
||||
implementation("androidx.exifinterface:exifinterface:1.3.7")
|
||||
}
|
||||
iosMain.dependencies {
|
||||
implementation(libs.ktor.client.darwin)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user