forked from upstream-mirrors/quik
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user