Files
honeyDueKMP/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/util/ImageCompressionAndroidTest.kt
Trey T 10b57aabaa 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>
2026-04-18 13:35:54 -05:00

188 lines
6.6 KiB
Kotlin

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