From 10b57aabaa4901dadb5de4784abbf03d9cf181f4 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 13:35:54 -0500 Subject: [PATCH] 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) --- composeApp/build.gradle.kts | 3 + .../honeyDue/util/ImageCompression.android.kt | 107 ++++++++++ .../util/CameraPermissionStateTest.kt | 59 ++++++ .../util/ImageCompressionAndroidTest.kt | 187 ++++++++++++++++++ .../tt/honeyDue/ui/components/CameraPicker.kt | 46 +++++ .../com/tt/honeyDue/util/ImageCompression.kt | 41 ++++ .../tt/honeyDue/util/ImageCompression.ios.kt | 105 ++++++++++ .../tt/honeyDue/util/ImageCompression.js.kt | 17 ++ .../tt/honeyDue/util/ImageCompression.jvm.kt | 18 ++ .../honeyDue/util/ImageCompression.wasmJs.kt | 16 ++ 10 files changed, 599 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/util/ImageCompression.android.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/util/CameraPermissionStateTest.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/util/ImageCompressionAndroidTest.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/CameraPicker.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/util/ImageCompression.kt create mode 100644 composeApp/src/iosMain/kotlin/com/tt/honeyDue/util/ImageCompression.ios.kt create mode 100644 composeApp/src/jsMain/kotlin/com/tt/honeyDue/util/ImageCompression.js.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/tt/honeyDue/util/ImageCompression.jvm.kt create mode 100644 composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/util/ImageCompression.wasmJs.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 18f6f2c..ab3108d 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/util/ImageCompression.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/util/ImageCompression.android.kt new file mode 100644 index 0000000..05d2a49 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/util/ImageCompression.android.kt @@ -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) + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/util/CameraPermissionStateTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/util/CameraPermissionStateTest.kt new file mode 100644 index 0000000..482d39c --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/util/CameraPermissionStateTest.kt @@ -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) + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/util/ImageCompressionAndroidTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/util/ImageCompressionAndroidTest.kt new file mode 100644 index 0000000..04ec865 --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/util/ImageCompressionAndroidTest.kt @@ -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 { + 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) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/CameraPicker.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/CameraPicker.kt new file mode 100644 index 0000000..8aa0bc0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/CameraPicker.kt @@ -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 +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/util/ImageCompression.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/util/ImageCompression.kt new file mode 100644 index 0000000..76d4ced --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/util/ImageCompression.kt @@ -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 +} diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/util/ImageCompression.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/util/ImageCompression.ios.kt new file mode 100644 index 0000000..18a356d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/util/ImageCompression.ios.kt @@ -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) + } + } + } +} diff --git a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/util/ImageCompression.js.kt b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/util/ImageCompression.js.kt new file mode 100644 index 0000000..492d6ae --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/util/ImageCompression.js.kt @@ -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 +} diff --git a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/util/ImageCompression.jvm.kt b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/util/ImageCompression.jvm.kt new file mode 100644 index 0000000..9f1c4b7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/util/ImageCompression.jvm.kt @@ -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 +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/util/ImageCompression.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/util/ImageCompression.wasmJs.kt new file mode 100644 index 0000000..9f12ed0 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/util/ImageCompression.wasmJs.kt @@ -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 +}