#1128 - GIF compression support

This commit is contained in:
Moez Bhatti
2018-07-29 23:53:22 -04:00
parent 65f8e306e9
commit ea53962619
6 changed files with 1547 additions and 38 deletions
@@ -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() }
}
}