Compare commits
1 Commits
151a90ea53
...
64040d5961
| Author | SHA1 | Date |
|---|---|---|
|
|
64040d5961 | 1 week ago |
@ -0,0 +1,289 @@
|
|||||||
|
package com.onlinemsg.client.util
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
object ImageMessageProcessor {
|
||||||
|
|
||||||
|
data class PreparedImageMessage(
|
||||||
|
val base64: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class PrepareException(message: String) : IllegalArgumentException(message) {
|
||||||
|
object InvalidImage : PrepareException("invalid_image")
|
||||||
|
object DecodeFailed : PrepareException("decode_failed")
|
||||||
|
object TooLarge : PrepareException("too_large")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prepareForMessage(
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
maxLongEdgePx: Int,
|
||||||
|
maxEncodedBytes: Int
|
||||||
|
): PreparedImageMessage {
|
||||||
|
require(maxLongEdgePx > 0) { "maxLongEdgePx must be > 0" }
|
||||||
|
require(maxEncodedBytes > 0) { "maxEncodedBytes must be > 0" }
|
||||||
|
|
||||||
|
val decoded = decodeBitmapCompat(contentResolver, uri, maxLongEdgePx)
|
||||||
|
val oriented = if (decoded.orientationHandled) {
|
||||||
|
decoded.bitmap
|
||||||
|
} else {
|
||||||
|
applyExifOrientation(contentResolver, uri, decoded.bitmap)
|
||||||
|
}
|
||||||
|
val normalized = scaleBitmapToLongEdge(oriented, maxLongEdgePx)
|
||||||
|
val jpegReady = flattenTransparencyForJpeg(normalized)
|
||||||
|
val compressed = compressWithinLimit(jpegReady, maxEncodedBytes)
|
||||||
|
|
||||||
|
return PreparedImageMessage(
|
||||||
|
base64 = Base64.encodeToString(compressed, Base64.NO_WRAP),
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
width = jpegReady.width,
|
||||||
|
height = jpegReady.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBitmapCompat(
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
maxLongEdgePx: Int
|
||||||
|
): DecodedBitmap {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
decodeBitmapWithImageDecoder(contentResolver, uri, maxLongEdgePx)?.let { bitmap ->
|
||||||
|
return DecodedBitmap(bitmap = bitmap, orientationHandled = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeBitmapWithFileDescriptor(contentResolver, uri, maxLongEdgePx)?.let { bitmap ->
|
||||||
|
return DecodedBitmap(bitmap = bitmap, orientationHandled = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeBitmapWithInputStream(contentResolver, uri, maxLongEdgePx)?.let { bitmap ->
|
||||||
|
return DecodedBitmap(bitmap = bitmap, orientationHandled = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw PrepareException.DecodeFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBitmapWithImageDecoder(
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
maxLongEdgePx: Int
|
||||||
|
): Bitmap? {
|
||||||
|
return runCatching {
|
||||||
|
val source = ImageDecoder.createSource(contentResolver, uri)
|
||||||
|
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||||
|
val sourceWidth = info.size.width.coerceAtLeast(1)
|
||||||
|
val sourceHeight = info.size.height.coerceAtLeast(1)
|
||||||
|
val sampleSize = calculateInSampleSize(
|
||||||
|
width = sourceWidth,
|
||||||
|
height = sourceHeight,
|
||||||
|
maxLongEdgePx = maxLongEdgePx,
|
||||||
|
maxPixels = MAX_DECODE_PIXELS
|
||||||
|
)
|
||||||
|
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||||
|
decoder.isMutableRequired = true
|
||||||
|
decoder.setTargetSize(
|
||||||
|
(sourceWidth / sampleSize).coerceAtLeast(1),
|
||||||
|
(sourceHeight / sampleSize).coerceAtLeast(1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBitmapWithFileDescriptor(
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
maxLongEdgePx: Int
|
||||||
|
): Bitmap? {
|
||||||
|
val bounds = contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFileDescriptor(descriptor.fileDescriptor, null, options)
|
||||||
|
options
|
||||||
|
}
|
||||||
|
if (bounds == null || bounds.outWidth <= 0 || bounds.outHeight <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = calculateInSampleSize(
|
||||||
|
width = bounds.outWidth,
|
||||||
|
height = bounds.outHeight,
|
||||||
|
maxLongEdgePx = maxLongEdgePx,
|
||||||
|
maxPixels = MAX_DECODE_PIXELS
|
||||||
|
)
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeFileDescriptor(descriptor.fileDescriptor, null, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBitmapWithInputStream(
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
maxLongEdgePx: Int
|
||||||
|
): Bitmap? {
|
||||||
|
val bounds = contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeStream(stream, null, options)
|
||||||
|
options
|
||||||
|
}
|
||||||
|
if (bounds == null || bounds.outWidth <= 0 || bounds.outHeight <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = calculateInSampleSize(
|
||||||
|
width = bounds.outWidth,
|
||||||
|
height = bounds.outHeight,
|
||||||
|
maxLongEdgePx = maxLongEdgePx,
|
||||||
|
maxPixels = MAX_DECODE_PIXELS
|
||||||
|
)
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeStream(stream, null, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyExifOrientation(
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
bitmap: Bitmap
|
||||||
|
): Bitmap {
|
||||||
|
val orientation = contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
runCatching { ExifInterface(stream).getAttributeInt(
|
||||||
|
ExifInterface.TAG_ORIENTATION,
|
||||||
|
ExifInterface.ORIENTATION_NORMAL
|
||||||
|
) }.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||||
|
} ?: ExifInterface.ORIENTATION_NORMAL
|
||||||
|
|
||||||
|
if (orientation == ExifInterface.ORIENTATION_NORMAL ||
|
||||||
|
orientation == ExifInterface.ORIENTATION_UNDEFINED
|
||||||
|
) {
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
val matrix = Matrix().apply {
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> postScale(-1f, 1f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> postRotate(180f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||||
|
postRotate(180f)
|
||||||
|
postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
|
postRotate(90f)
|
||||||
|
postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> postRotate(90f)
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
|
postRotate(-90f)
|
||||||
|
postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> postRotate(270f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
}.getOrElse { bitmap }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleBitmapToLongEdge(bitmap: Bitmap, maxLongEdgePx: Int): Bitmap {
|
||||||
|
val currentLongEdge = max(bitmap.width, bitmap.height)
|
||||||
|
if (currentLongEdge <= maxLongEdgePx) return bitmap
|
||||||
|
val scale = maxLongEdgePx.toFloat() / currentLongEdge.toFloat()
|
||||||
|
val targetWidth = (bitmap.width * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
val targetHeight = (bitmap.height * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flattenTransparencyForJpeg(bitmap: Bitmap): Bitmap {
|
||||||
|
if (!bitmap.hasAlpha()) return bitmap
|
||||||
|
val flattened = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(flattened)
|
||||||
|
canvas.drawColor(Color.WHITE)
|
||||||
|
canvas.drawBitmap(bitmap, 0f, 0f, null)
|
||||||
|
return flattened
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compressWithinLimit(bitmap: Bitmap, maxEncodedBytes: Int): ByteArray {
|
||||||
|
var workingBitmap = bitmap
|
||||||
|
var quality = INITIAL_JPEG_QUALITY
|
||||||
|
var compressed = compressBitmap(workingBitmap, quality)
|
||||||
|
while (compressed.size > maxEncodedBytes && quality > MIN_JPEG_QUALITY) {
|
||||||
|
quality -= JPEG_QUALITY_STEP
|
||||||
|
compressed = compressBitmap(workingBitmap, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (compressed.size > maxEncodedBytes && max(workingBitmap.width, workingBitmap.height) > MIN_LONG_EDGE_PX) {
|
||||||
|
val nextLongEdge = (max(workingBitmap.width, workingBitmap.height) * SCALE_DOWN_RATIO)
|
||||||
|
.roundToInt()
|
||||||
|
.coerceAtLeast(MIN_LONG_EDGE_PX)
|
||||||
|
workingBitmap = scaleBitmapToLongEdge(workingBitmap, nextLongEdge)
|
||||||
|
quality = maxOf(MIN_JPEG_QUALITY, minOf(quality, INITIAL_JPEG_QUALITY - JPEG_QUALITY_STEP))
|
||||||
|
compressed = compressBitmap(workingBitmap, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compressed.size > maxEncodedBytes) {
|
||||||
|
throw PrepareException.TooLarge
|
||||||
|
}
|
||||||
|
return compressed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compressBitmap(bitmap: Bitmap, quality: Int): ByteArray {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val success = bitmap.compress(Bitmap.CompressFormat.JPEG, quality, output)
|
||||||
|
if (!success) {
|
||||||
|
throw PrepareException.DecodeFailed
|
||||||
|
}
|
||||||
|
return output.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateInSampleSize(
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
maxLongEdgePx: Int,
|
||||||
|
maxPixels: Int
|
||||||
|
): Int {
|
||||||
|
var sample = 1
|
||||||
|
var sampledWidth = width
|
||||||
|
var sampledHeight = height
|
||||||
|
while (
|
||||||
|
max(sampledWidth, sampledHeight) > maxLongEdgePx * 2 ||
|
||||||
|
sampledWidth.toLong() * sampledHeight.toLong() > maxPixels
|
||||||
|
) {
|
||||||
|
sample *= 2
|
||||||
|
sampledWidth = width / sample
|
||||||
|
sampledHeight = height / sample
|
||||||
|
}
|
||||||
|
return sample.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_DECODE_PIXELS = 4_200_000
|
||||||
|
private const val MIN_LONG_EDGE_PX = 480
|
||||||
|
private const val INITIAL_JPEG_QUALITY = 86
|
||||||
|
private const val MIN_JPEG_QUALITY = 58
|
||||||
|
private const val JPEG_QUALITY_STEP = 8
|
||||||
|
private const val SCALE_DOWN_RATIO = 0.85f
|
||||||
|
|
||||||
|
private data class DecodedBitmap(
|
||||||
|
val bitmap: Bitmap,
|
||||||
|
val orientationHandled: Boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue