forked from upstream-mirrors/quik
#1128 - GIF compression support
This commit is contained in:
@@ -24,7 +24,6 @@ import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.provider.Telephony
|
||||
import android.telephony.PhoneNumberUtils
|
||||
@@ -42,6 +41,7 @@ import com.moez.QKSMS.model.Attachment
|
||||
import com.moez.QKSMS.model.Conversation
|
||||
import com.moez.QKSMS.model.Message
|
||||
import com.moez.QKSMS.model.MmsPart
|
||||
import com.moez.QKSMS.util.ImageUtils
|
||||
import com.moez.QKSMS.util.Preferences
|
||||
import com.moez.QKSMS.util.tryOrNull
|
||||
import io.realm.Case
|
||||
@@ -49,7 +49,6 @@ import io.realm.Realm
|
||||
import io.realm.RealmResults
|
||||
import io.realm.Sort
|
||||
import timber.log.Timber
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -234,9 +233,8 @@ class MessageRepositoryImpl @Inject constructor(
|
||||
// TODO Add support for GIF compression
|
||||
parts += attachments
|
||||
.filter { attachment -> attachment.isGif(context) }
|
||||
.map { attachment -> attachment.getUri() }
|
||||
.map { uri -> context.contentResolver.openInputStream(uri) }
|
||||
.map { inputStream -> inputStream.readBytes() }
|
||||
.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
|
||||
@@ -248,7 +246,7 @@ class MessageRepositoryImpl @Inject constructor(
|
||||
.also { totalImageBytes = it.sumBy { it.allocationByteCount } }
|
||||
.map { bitmap ->
|
||||
val byteRatio = bitmap.allocationByteCount / totalImageBytes.toFloat()
|
||||
compress(bitmap, (prefs.mmsSize.get() * 1024 * byteRatio).toInt())
|
||||
ImageUtils.compressBitmap(bitmap, (prefs.mmsSize.get() * 1024 * byteRatio).toInt())
|
||||
}
|
||||
.map { bitmap -> MMSPart("image", ContentType.IMAGE_JPEG, bitmap) }
|
||||
|
||||
@@ -284,38 +282,6 @@ class MessageRepositoryImpl @Inject constructor(
|
||||
smsManager.sendMultipartTextMessage(message.address, null, parts, ArrayList(sentIntents), ArrayList(deliveredIntents))
|
||||
}
|
||||
|
||||
private fun compress(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)
|
||||
|
||||
// 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 = stream.size().toDouble()
|
||||
while (maxBytes > 0 && bytes > maxBytes) {
|
||||
val scale = Math.sqrt(maxBytes / bytes) * (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()
|
||||
}
|
||||
|
||||
if (maxBytes > 0 && stream.size() > maxBytes) {
|
||||
Timber.w(Exception("Compressed image size exceeds max byte size (${stream.size()}/$maxBytes) after $attempts attempts"))
|
||||
}
|
||||
|
||||
return stream.toByteArray().also { src.recycle() }
|
||||
}
|
||||
|
||||
override fun cancelDelayedSms(id: Long) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
alarmManager.cancel(getIntentForDelayedSms(id))
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.bumptech.glide.gifdecoder.GifDecoder
|
||||
import com.bumptech.glide.gifdecoder.GifHeaderParser
|
||||
import com.bumptech.glide.gifdecoder.StandardGifDecoder
|
||||
import com.bumptech.glide.gifencoder.AnimatedGifEncoder
|
||||
import com.bumptech.glide.load.Transformation
|
||||
import com.bumptech.glide.load.engine.Resource
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||
import com.bumptech.glide.load.resource.bitmap.BitmapResource
|
||||
import com.bumptech.glide.load.resource.gif.GifBitmapProvider
|
||||
import com.bumptech.glide.load.resource.gif.GifDrawable
|
||||
import java.io.OutputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Allows converting an animated [GifDrawable] to an [OutputStream]
|
||||
* Based on Glide's ReEncodingGifResourceEncoder
|
||||
*/
|
||||
class GifEncoder internal constructor(
|
||||
private val context: Context,
|
||||
private val bitmapPool: BitmapPool,
|
||||
private val factory: Factory = Factory()
|
||||
) {
|
||||
|
||||
private val provider = GifBitmapProvider(bitmapPool)
|
||||
|
||||
fun encodeTransformedToStream(drawable: GifDrawable, os: OutputStream): Boolean {
|
||||
val transformation = drawable.frameTransformation
|
||||
val decoder = decodeHeaders(drawable.buffer)
|
||||
val encoder = factory.buildEncoder()
|
||||
if (!encoder.start(os)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (i in 0 until decoder.frameCount) {
|
||||
val currentFrame = decoder.nextFrame
|
||||
val transformedResource = getTransformedFrame(currentFrame, transformation, drawable)
|
||||
try {
|
||||
if (!encoder.addFrame(transformedResource.get())) {
|
||||
return false
|
||||
}
|
||||
val currentFrameIndex = decoder.currentFrameIndex
|
||||
val delay = decoder.getDelay(currentFrameIndex)
|
||||
encoder.setDelay(delay)
|
||||
|
||||
decoder.advance()
|
||||
} finally {
|
||||
transformedResource.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
return encoder.finish()
|
||||
}
|
||||
|
||||
private fun decodeHeaders(data: ByteBuffer): GifDecoder {
|
||||
val parser = factory.buildParser()
|
||||
parser.setData(data)
|
||||
val header = parser.parseHeader()
|
||||
|
||||
val decoder = factory.buildDecoder(provider)
|
||||
decoder.setData(header, data)
|
||||
decoder.advance()
|
||||
|
||||
return decoder
|
||||
}
|
||||
|
||||
private fun getTransformedFrame(currentFrame: Bitmap?, transformation: Transformation<Bitmap>, drawable: GifDrawable): Resource<Bitmap> {
|
||||
// TODO: what if current frame is null?
|
||||
val bitmapResource = factory.buildFrameResource(currentFrame!!, bitmapPool)
|
||||
val transformedResource = transformation.transform(context, bitmapResource, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
if (bitmapResource != transformedResource) {
|
||||
bitmapResource.recycle()
|
||||
}
|
||||
return transformedResource
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal class Factory {
|
||||
fun buildDecoder(bitmapProvider: GifDecoder.BitmapProvider) = StandardGifDecoder(bitmapProvider)
|
||||
|
||||
fun buildParser() = GifHeaderParser()
|
||||
|
||||
fun buildEncoder() = AnimatedGifEncoder()
|
||||
|
||||
fun buildFrameResource(bitmap: Bitmap, bitmapPool: BitmapPool) = BitmapResource(bitmap, bitmapPool)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.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
|
||||
.with(context)
|
||||
.asGif()
|
||||
.load(uri)
|
||||
.transform(CenterCrop())
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user