package com.tt.honeyDue.media import android.graphics.Bitmap import android.graphics.BitmapFactory import java.io.ByteArrayOutputStream import java.io.InputStream /** * Memory-efficient image resizer for upload preprocessing on Android. * * Why not just decode + Bitmap.createScaledBitmap? createScaledBitmap * decodes the full source bitmap first — a 12 MP photo materializes ~50 MB * in RAM regardless of how big the JPEG is. That OOMs older devices. * * BitmapFactory.Options.inSampleSize, paired with inJustDecodeBounds=true * for a metadata-only first pass, lets us decode at a power-of-two * subsample. Combined with a final scaled-down draw, peak memory is * roughly proportional to the *output* bitmap's pixel count — not the * source's. * * Quality tuning matches WhatsApp-class apps: 2048 px max edge, JPEG 85. */ object ImageDownsampler { data class Profile( val maxPixelEdge: Int, /** JPEG quality 0-100. */ val jpegQuality: Int, ) { companion object { val Completion = Profile(maxPixelEdge = 2048, jpegQuality = 85) val DocumentImage = Profile(maxPixelEdge = 2560, jpegQuality = 90) } } /** Downsample raw image bytes into JPEG bytes ready for upload. */ fun downsample(bytes: ByteArray, profile: Profile): ByteArray? { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null val sampleSize = computeSampleSize(bounds.outWidth, bounds.outHeight, profile.maxPixelEdge) val decodeOpts = BitmapFactory.Options().apply { inSampleSize = sampleSize // ARGB_8888 keeps quality; on memory-constrained devices we // could drop to RGB_565 here, but for upload prep the extra // ~2x peak memory isn't worth the visible quality loss. inPreferredConfig = Bitmap.Config.ARGB_8888 } val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts) ?: return null // Subsample is power-of-two only; the result may still be larger // than maxPixelEdge by up to 2x. One more proportional scale gets // us to the exact target. val scaled = scaleProportional(decoded, profile.maxPixelEdge) val out = ByteArrayOutputStream(64 * 1024) val ok = scaled.compress(Bitmap.CompressFormat.JPEG, profile.jpegQuality, out) // Only recycle if scaled is a different bitmap; createScaledBitmap // sometimes returns the input unchanged, and recycling that would // double-recycle below. if (scaled !== decoded) decoded.recycle() scaled.recycle() return if (ok) out.toByteArray() else null } /** Same, from a stream (for content:// URIs etc.). */ fun downsample(input: InputStream, profile: Profile): ByteArray? { val bytes = input.use { it.readBytes() } return downsample(bytes, profile) } /** * Pick the largest power-of-two sub-sample factor that still yields * an image at least as large as maxPixelEdge on both axes. Mirrors * the canonical Android docs example. */ private fun computeSampleSize(srcW: Int, srcH: Int, maxEdge: Int): Int { var sample = 1 var halfW = srcW / 2 var halfH = srcH / 2 while (halfW >= maxEdge && halfH >= maxEdge) { sample *= 2 halfW /= 2 halfH /= 2 } return sample } private fun scaleProportional(src: Bitmap, maxEdge: Int): Bitmap { val w = src.width val h = src.height val longest = maxOf(w, h) if (longest <= maxEdge) return src val ratio = maxEdge.toFloat() / longest.toFloat() val newW = (w * ratio).toInt().coerceAtLeast(1) val newH = (h * ratio).toInt().coerceAtLeast(1) return Bitmap.createScaledBitmap(src, newW, newH, true) } }