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:
Trey T
2026-04-18 13:35:54 -05:00
parent 3069ec41de
commit 10b57aabaa
10 changed files with 599 additions and 0 deletions

View File

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

View File

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