Improve MMS attachment quality

Fixes #1551
This commit is contained in:
Moez Bhatti
2020-01-08 18:59:46 -05:00
parent 72a61f5b44
commit 0f21fc80c2
10 changed files with 114 additions and 215 deletions
@@ -1,86 +0,0 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* This file is part of QKSMS.
*
* QKSMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QKSMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QKSMS. If not, see <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.repository
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import javax.inject.Inject
class ImageRepositoryImpl @Inject constructor(private val context: Context) : ImageRepository {
override fun loadImage(uri: Uri, width: Int, height: Int): Bitmap? {
val orientation = context.contentResolver.openInputStream(uri)?.use(::ExifInterface)
?.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
val rotated = orientation == ExifInterface.ORIENTATION_ROTATE_90
|| orientation == ExifInterface.ORIENTATION_ROTATE_270
// Determine the dimensions
val dimensionsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(context.contentResolver.openInputStream(uri), null, dimensionsOptions)
val srcWidth = if (rotated) dimensionsOptions.outHeight else dimensionsOptions.outWidth
val srcHeight = if (rotated) dimensionsOptions.outWidth else dimensionsOptions.outHeight
// If we get the dimensions and they don't exceed the max size, we don't need to scale
val inputStream = context.contentResolver.openInputStream(uri)
val bitmap = if ((width == 0 || srcWidth < width) && (height == 0 || srcHeight < height)) {
BitmapFactory.decodeStream(inputStream)
} else {
val widthScaleFactor = width.toDouble() / srcWidth
val heightScaleFactor = height.toDouble() / srcHeight
val options = when {
widthScaleFactor > heightScaleFactor -> BitmapFactory.Options().apply {
inScaled = true
inSampleSize = 4
inDensity = srcHeight
inTargetDensity = height * inSampleSize
}
else -> BitmapFactory.Options().apply {
inScaled = true
inSampleSize = 4
inDensity = srcWidth
inTargetDensity = width * inSampleSize
}
}
BitmapFactory.decodeStream(inputStream, null, options) ?: return null
}
return when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f)
ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f)
ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f)
else -> bitmap
}
}
private fun rotateBitmap(bitmap: Bitmap, degree: Float): Bitmap {
val w = bitmap.width
val h = bitmap.height
val mtx = Matrix()
mtx.postRotate(degree)
return Bitmap.createBitmap(bitmap, 0, 0, w, h, mtx, true)
}
}
@@ -24,6 +24,7 @@ import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.media.MediaScannerConnection
import android.os.Build
import android.os.Environment
@@ -40,6 +41,7 @@ import com.google.android.mms.pdu_alt.PduPersister
import com.klinker.android.send_message.SmsManagerFactory
import com.klinker.android.send_message.StripAccents
import com.klinker.android.send_message.Transaction
import com.moez.QKSMS.common.util.extensions.now
import com.moez.QKSMS.compat.TelephonyCompat
import com.moez.QKSMS.extensions.anyOf
import com.moez.QKSMS.manager.ActiveConversationManager
@@ -66,12 +68,12 @@ import java.io.FileOutputStream
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.sqrt
@Singleton
class MessageRepositoryImpl @Inject constructor(
private val activeConversationManager: ActiveConversationManager,
private val context: Context,
private val imageRepository: ImageRepository,
private val messageIds: KeyManager,
private val phoneNumberUtils: PhoneNumberUtils,
private val prefs: Preferences,
@@ -329,49 +331,98 @@ class MessageRepositoryImpl @Inject constructor(
alarmManager.setExact(AlarmManager.RTC_WAKEUP, sendTime, intent)
}
} else { // No delay
val message = insertSentSms(subId, threadId, addresses.first(), strippedBody, System.currentTimeMillis())
val message = insertSentSms(subId, threadId, addresses.first(), strippedBody, now())
sendSms(message)
}
} else { // MMS
val parts = arrayListOf<MMSPart>()
if (signedBody.isNotBlank()) {
parts += MMSPart("text", ContentType.TEXT_PLAIN, signedBody.toByteArray())
val maxWidth = smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_WIDTH)
.takeIf { prefs.mmsSize.get() == -1 } ?: Int.MAX_VALUE
val maxHeight = smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_HEIGHT)
.takeIf { prefs.mmsSize.get() == -1 } ?: Int.MAX_VALUE
var remainingBytes = when (prefs.mmsSize.get()) {
-1 -> smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_MESSAGE_SIZE)
0 -> Int.MAX_VALUE
else -> prefs.mmsSize.get() * 1024
} * 0.9 // Ugly, but buys us a bit of wiggle room
signedBody.takeIf { it.isNotEmpty() }?.toByteArray()?.let { bytes ->
remainingBytes -= bytes.size
parts += MMSPart("text", ContentType.TEXT_PLAIN, bytes)
}
val smsManager = subId.takeIf { it != -1 }
?.let(SmsManagerFactory::createSmsManager)
?: SmsManager.getDefault()
val width = smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_WIDTH)
val height = smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_HEIGHT)
// Add the GIFs as attachments
parts += attachments
.mapNotNull { attachment -> attachment as? Attachment.Image }
.filter { attachment -> attachment.isGif(context) }
.mapNotNull { attachment -> attachment.getUri() }
.map { uri -> ImageUtils.compressGif(context, uri, prefs.mmsSize.get() * 1024) }
.map { bitmap -> MMSPart("image", ContentType.IMAGE_GIF, bitmap) }
// Compress the images and add them as attachments
var totalImageBytes = 0
parts += attachments
.mapNotNull { attachment -> attachment as? Attachment.Image }
.filter { attachment -> !attachment.isGif(context) }
.mapNotNull { attachment -> attachment.getUri() }
.mapNotNull { uri -> tryOrNull { imageRepository.loadImage(uri, width, height) } }
.also { totalImageBytes = it.sumBy { it.allocationByteCount } }
.map { bitmap ->
val byteRatio = bitmap.allocationByteCount / totalImageBytes.toFloat()
ImageUtils.compressBitmap(bitmap, (prefs.mmsSize.get() * 1024 * byteRatio).toInt())
}
.map { bitmap -> MMSPart("image", ContentType.IMAGE_JPEG, bitmap) }
// Send contacts
// Attach contacts
parts += attachments
.mapNotNull { attachment -> attachment as? Attachment.Contact }
.map { attachment -> attachment.vCard.toByteArray() }
.map { vCard -> MMSPart("contact", ContentType.TEXT_VCARD, vCard) }
.map { vCard ->
remainingBytes -= vCard.size
MMSPart("contact", ContentType.TEXT_VCARD, vCard)
}
val imageBytesByAttachment = attachments
.mapNotNull { attachment -> attachment as? Attachment.Image }
.associateWith { attachment ->
val uri = attachment.getUri() ?: return@associateWith byteArrayOf()
when (attachment.isGif(context)) {
true -> ImageUtils.getScaledGif(context, uri, maxWidth, maxHeight)
false -> ImageUtils.getScaledImage(context, uri, maxWidth, maxHeight)
}
}
.toMutableMap()
val imageByteCount = imageBytesByAttachment.values.sumBy { byteArray -> byteArray.size }
if (imageByteCount > remainingBytes) {
imageBytesByAttachment.forEach { (attachment, originalBytes) ->
val uri = attachment.getUri() ?: return@forEach
val maxBytes = originalBytes.size / imageByteCount.toFloat() * remainingBytes
// Get the image dimensions
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(context.contentResolver.openInputStream(uri), null, options)
val width = options.outWidth
val height = options.outHeight
val aspectRatio = width.toFloat() / height.toFloat()
var attempts = 0
var scaledBytes = originalBytes
while (scaledBytes.size > maxBytes) {
// Estimate how much we need to scale the image down by. If it's still too big, we'll need to
// try smaller and smaller values
val scale = maxBytes / originalBytes.size * (0.9 - attempts * 0.2)
if (scale <= 0) {
Timber.w("Failed to compress ${originalBytes.size / 1024}Kb to ${maxBytes.toInt() / 1024}Kb")
return@forEach
}
val newArea = scale * width * height
val newWidth = sqrt(newArea * aspectRatio).toInt()
val newHeight = (newWidth / aspectRatio).toInt()
attempts++
scaledBytes = when (attachment.isGif(context)) {
true -> ImageUtils.getScaledGif(context, uri, newWidth, newHeight, 80)
false -> ImageUtils.getScaledImage(context, uri, newWidth, newHeight, 80)
}
Timber.d("Compression attempt $attempts: ${scaledBytes.size / 1024}/${maxBytes.toInt() / 1024}Kb ($width*$height -> $newWidth*$newHeight)")
}
Timber.v("Compressed ${originalBytes.size / 1024}Kb to ${scaledBytes.size / 1024}Kb with a target size of ${maxBytes.toInt() / 1024}Kb in $attempts attempts")
imageBytesByAttachment[attachment] = scaledBytes
}
}
imageBytesByAttachment.forEach { (attachment, bytes) ->
parts += when (attachment.isGif(context)) {
true -> MMSPart("image", ContentType.IMAGE_GIF, bytes)
false -> MMSPart("image", ContentType.IMAGE_JPEG, bytes)
}
}
// We need to strip the separators from outgoing MMS, or else they'll appear to have sent and not go through
val transaction = Transaction(context)
@@ -34,6 +34,7 @@ class GlideAppModule : AppGlideModule() {
}
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
// registry.prepend(GifDrawable::class.java, ReEncodingGifResourceEncoder(context, glide.bitmapPool))
}
}
@@ -19,77 +19,35 @@
package com.moez.QKSMS.util
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import java.io.ByteArrayOutputStream
object ImageUtils {
fun compressGif(context: Context, uri: Uri, maxBytes: Int): ByteArray {
val request = GlideApp
fun getScaledGif(context: Context, uri: Uri, maxWidth: Int, maxHeight: Int, quality: Int = 90): ByteArray {
val gif = GlideApp
.with(context)
.asGif()
.load(uri)
.transform(CenterCrop())
.centerInside()
.encodeQuality(quality)
.submit(maxWidth, maxHeight)
.get()
val gif = request.submit().get()
val width = gif.firstFrame.width
val height = gif.firstFrame.height
val stream = ByteArrayOutputStream()
GifEncoder(context, GlideApp.get(context).bitmapPool)
.encodeTransformedToStream(gif, stream)
val unscaledBytes = stream.size().toDouble()
var attempts = 0
var bytes = unscaledBytes
while (maxBytes > 0 && bytes > maxBytes) {
val scale = Math.sqrt(maxBytes / unscaledBytes) * (1 - attempts * 0.1)
val scaledGif = request.submit((width * scale).toInt(), (height * scale).toInt()).get()
stream.reset()
GifEncoder(context, GlideApp.get(context).bitmapPool)
.encodeTransformedToStream(scaledGif, stream)
attempts++
bytes = stream.size().toDouble()
}
return stream.toByteArray()
val outputStream = ByteArrayOutputStream()
GifEncoder(context, GlideApp.get(context).bitmapPool).encodeTransformedToStream(gif, outputStream)
return outputStream.toByteArray()
}
fun compressBitmap(src: Bitmap, maxBytes: Int): ByteArray {
val quality = 90
val height = src.height
val width = src.width
val stream = ByteArrayOutputStream()
src.compress(Bitmap.CompressFormat.JPEG, quality, stream)
val unscaledBytes = stream.size().toDouble()
// Based on the byte size of the bitmap, we'll try to reduce the image's dimensions such
// that it will fit within the max byte size set. If we don't get it right the first time,
// use a slightly heavier compression until we fit within the max size
var attempts = 0
var bytes = unscaledBytes
while (maxBytes > 0 && bytes > maxBytes) {
val scale = Math.sqrt(maxBytes / unscaledBytes) * (1 - attempts * 0.1)
stream.reset()
Bitmap.createScaledBitmap(src, (width * scale).toInt(), (height * scale).toInt(), true)
.compress(Bitmap.CompressFormat.JPEG, quality, stream)
attempts++
bytes = stream.size().toDouble()
}
return stream.toByteArray().also { src.recycle() }
fun getScaledImage(context: Context, uri: Uri, maxWidth: Int, maxHeight: Int, quality: Int = 90): ByteArray {
return GlideApp
.with(context)
.`as`(ByteArray::class.java)
.load(uri)
.centerInside()
.encodeQuality(quality)
.submit(maxWidth, maxHeight)
.get()
}
}