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>
188 lines
6.6 KiB
Kotlin
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)
|
|
}
|
|
}
|