Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 0f21fc80 authored by Moez Bhatti's avatar Moez Bhatti
Browse files

Improve MMS attachment quality

Fixes #1551
parent 72a61f5b
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
package com.moez.QKSMS.common.util.extensions

fun now(): Long {
    return System.currentTimeMillis()
}
+1 −0
Original line number Diff line number Diff line
@@ -53,6 +53,7 @@ dependencies {
    implementation "androidx.exifinterface:exifinterface:$androidx_exifinterface_version"

    // glide
    implementation "com.github.bumptech.glide:gifencoder-integration:$glide_version"
    implementation "com.github.bumptech.glide:glide:$glide_version"
    kapt "com.github.bumptech.glide:compiler:$glide_version"

+0 −86
Original line number Diff line number Diff line
/*
 * 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)
    }

}
+83 −32
Original line number Diff line number Diff line
@@ -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 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)
            val maxHeight = smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_HEIGHT)
                    .takeIf { prefs.mmsSize.get() == -1 } ?: Int.MAX_VALUE

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

            // 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())
            signedBody.takeIf { it.isNotEmpty() }?.toByteArray()?.let { bytes ->
                remainingBytes -= bytes.size
                parts += MMSPart("text", ContentType.TEXT_PLAIN, bytes)
            }
                    .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)
+1 −0
Original line number Diff line number Diff line
@@ -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))
    }

}
Loading