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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user