feat(android): add image message sending
parent
4e2993e772
commit
151a90ea53
@ -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