Skip to content

Commit 96fcb88

Browse files
committed
refactor(mms): Add an algorithm for compressing GIFs in one pass
1 parent b0f5107 commit 96fcb88

4 files changed

Lines changed: 193 additions & 21 deletions

File tree

data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import dev.octoshrimpy.quik.receiver.MessageDeliveredReceiver
5959
import dev.octoshrimpy.quik.receiver.MessageSentReceiver
6060
import dev.octoshrimpy.quik.receiver.SendDelayedMessageReceiver
6161
import dev.octoshrimpy.quik.receiver.SendDelayedMessageReceiver.Companion.MESSAGE_ID_EXTRA
62+
import dev.octoshrimpy.quik.util.GifCompressor
6263
import dev.octoshrimpy.quik.util.ImageUtils
6364
import dev.octoshrimpy.quik.util.PhoneNumberUtils
6465
import dev.octoshrimpy.quik.util.Preferences
@@ -83,6 +84,7 @@ open class MessageRepositoryImpl @Inject constructor(
8384
private val context: Context,
8485
private val messageIds: KeyManager,
8586
private val phoneNumberUtils: PhoneNumberUtils,
87+
private val gifCompressor: GifCompressor,
8688
private val prefs: Preferences,
8789
private val syncRepository: SyncRepository,
8890
private val reactions: EmojiReactionRepository,
@@ -614,29 +616,36 @@ open class MessageRepositoryImpl @Inject constructor(
614616
var bestBytes: ByteArray? = null
615617
var attempt = 0
616618

617-
while (lo <= hi) {
618-
val midWidth = (lo + hi) / 2
619-
val midHeight = (midWidth / aspectRatio).toInt().coerceAtLeast(1)
620-
attempt++
621-
val candidate = if (isGif) {
622-
ImageUtils.getScaledGif(context, attachment.uri, midWidth, midHeight)
623-
} else {
624-
ImageUtils.getScaledImage(context, attachment.uri, midWidth, midHeight)
625-
}
626-
Timber.d(
627-
"Compression attempt $attempt: ${
628-
candidate.size / 1024
629-
}/${maxBytes / 1024}Kb ($origWidth*$origHeight -> $midWidth*$midHeight)"
619+
if (isGif) {
620+
bestBytes = gifCompressor.compressGif(
621+
context,
622+
attachment,
623+
maxBytes,
624+
origWidth,
625+
aspectRatio
630626
)
631-
if (candidate.size <= maxBytes) {
632-
bestBytes = candidate
633-
lo = midWidth + 1
634-
} else {
635-
hi = midWidth - 1
636-
}
637-
638-
// release the attachment hold on the image bytes so the GC can reclaim
639627
attachment.releaseResourceBytes()
628+
} else {
629+
while (lo <= hi) {
630+
val midWidth = (lo + hi) / 2
631+
val midHeight = (midWidth / aspectRatio).toInt().coerceAtLeast(1)
632+
attempt++
633+
val candidate = ImageUtils.getScaledImage(context, attachment.uri, midWidth, midHeight)
634+
Timber.d(
635+
"Compression attempt $attempt: ${
636+
candidate.size / 1024
637+
}/${maxBytes / 1024}Kb ($origWidth*$origHeight -> $midWidth*$midHeight)"
638+
)
639+
if (candidate.size <= maxBytes) {
640+
bestBytes = candidate
641+
lo = midWidth + 1
642+
} else {
643+
hi = midWidth - 1
644+
}
645+
646+
// release the attachment hold on the image bytes so the GC can reclaim
647+
attachment.releaseResourceBytes()
648+
}
640649
}
641650

642651
val result = bestBytes ?: run {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright (C) 2025 QUIK SMS
3+
*
4+
* This file is part of QUIK SMS.
5+
*
6+
* QUIK SMS is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* QUIK SMS is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU General Public License
17+
* along with QUIK SMS. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
package dev.octoshrimpy.quik.util
20+
21+
import android.content.Context
22+
import android.content.res.AssetFileDescriptor
23+
import android.provider.OpenableColumns
24+
import dev.octoshrimpy.quik.model.Attachment
25+
import dev.octoshrimpy.quik.util.ImageUtils.getScaledGif
26+
import timber.log.Timber
27+
import kotlin.math.ln
28+
import kotlin.math.max
29+
import kotlin.math.pow
30+
import kotlin.math.sqrt
31+
32+
class GifCompressor(
33+
/**
34+
* Dampens the amount the target compression amount will influence scale ratio.
35+
* In order to reduce compression, lower this number, and to increase it, raise this number
36+
*
37+
* @property compressionParameter
38+
*/
39+
val compressionParameter: Double = 0.35,
40+
41+
/**
42+
* Determines minimum file dimensions,
43+
* adjust to avoid large GIFS compressed to unreadable amounts
44+
*/
45+
46+
val minFileDimensions: Int = 20
47+
) {
48+
/**
49+
* GIFs are very expensive to compress, so does a quick compression
50+
* by estimating the scale down ratio using byte density, and overall size
51+
* then compressing that way.
52+
*
53+
* Can be fine-tuned using [compressionParameter], and currently leans toward overcompression
54+
*/
55+
fun compressGif(
56+
context: Context,
57+
attachment: Attachment,
58+
maxBytes: Int,
59+
origWidth: Int,
60+
aspectRatio: Float
61+
): ByteArray {
62+
// Determine file properties
63+
val rawFileSize = ImageUtils.fetchFileSize(context, attachment).coerceAtLeast(1)
64+
val origHeight = (origWidth / aspectRatio).toInt()
65+
val totalPixelsPerFrame = origWidth * origHeight
66+
val byteDensity = max(1.0, rawFileSize.toDouble() / totalPixelsPerFrame)
67+
68+
// Figure out generally how much we will have to compress,
69+
// then dampen it and create a base scale
70+
val targetCompressionRatio = rawFileSize.toDouble() / maxBytes.toDouble()
71+
val targetCompressionDampened =
72+
compressionParameter * (compressionParameter * 10) / targetCompressionRatio
73+
val baseScale = 1.0 / (sqrt(targetCompressionRatio) + targetCompressionDampened)
74+
75+
// Calculate how much byte density deviates from expected values
76+
val byteDensityScore = byteDensityScore(byteDensity, rawFileSize.toDouble())
77+
78+
// Determine how aggressive the compression will be, based mainly on byteDensityScore
79+
val compressionScale = (targetCompressionRatio / (compressionParameter * 2))
80+
val compressionAggressiveness =
81+
targetCompressionRatio.pow(byteDensityScore) / (compressionScale)
82+
83+
// How much we will have to scale down the GIF, can't be over 1
84+
val scaleRatio = baseScale.pow(compressionAggressiveness).coerceAtMost(1.0)
85+
86+
// Determine compressed dimensions from scale ratio
87+
val midWidthGif = (origWidth * scaleRatio).toInt().coerceAtLeast(minFileDimensions)
88+
val midHeightGif = (midWidthGif / aspectRatio).toInt().coerceAtLeast(minFileDimensions)
89+
90+
return getScaledGif(context, attachment.uri, midWidthGif, midHeightGif)
91+
}
92+
93+
/**
94+
* Determines how much byte density deviates from expected.
95+
* If there is a lot of deviation, we can assume that the GIF is going to require more compression
96+
*/
97+
private fun byteDensityScore(byteDensity: Double, rawFileSize: Double): Double {
98+
val d = ln(1.0 + byteDensity)
99+
val r = ln(1.0 + rawFileSize)
100+
101+
val t = d / (d + r)
102+
return 2.0 - 8.0 * t * (1.0 - t)
103+
}
104+
105+
}

data/src/main/java/com/moez/QKSMS/util/ImageUtils.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919
package dev.octoshrimpy.quik.util
2020

2121
import android.content.Context
22+
import android.content.res.AssetFileDescriptor
2223
import android.net.Uri
24+
import android.provider.OpenableColumns
2325
import com.bumptech.glide.load.engine.DiskCacheStrategy
2426
import com.bumptech.glide.request.RequestOptions
27+
import dev.octoshrimpy.quik.model.Attachment
2528
import java.io.ByteArrayOutputStream
2629

2730
object ImageUtils {
@@ -75,4 +78,52 @@ object ImageUtils {
7578
return result
7679
}
7780

81+
/**
82+
* Get file size, by first using contentResolver.openAssetFileDescriptor
83+
* then if it fails, by reading the file size directly from the size column in contentResolver,
84+
* then if both of those fail, by defaulting to reading the input stream
85+
*/
86+
fun fetchFileSize(context: Context, attachment: Attachment): Long {
87+
val resolver = context.contentResolver
88+
89+
val afdSize = resolver
90+
.openAssetFileDescriptor(attachment.uri, "r")
91+
?.use { it.length }
92+
93+
if (afdSize != null && afdSize != AssetFileDescriptor.UNKNOWN_LENGTH) {
94+
return afdSize
95+
}
96+
97+
val cursorSize = resolver.query(
98+
attachment.uri,
99+
arrayOf(OpenableColumns.SIZE),
100+
null,
101+
null,
102+
null
103+
)?.use { cursor ->
104+
val index = cursor.getColumnIndex(OpenableColumns.SIZE)
105+
if (index != -1 && cursor.moveToFirst() && !cursor.isNull(index)) {
106+
cursor.getLong(index)
107+
} else {
108+
null
109+
}
110+
}
111+
112+
if (cursorSize != null && cursorSize > 0) {
113+
return cursorSize
114+
}
115+
116+
val inputStreamBytes = resolver.openInputStream(attachment.uri)?.use { inputStream ->
117+
val buffer = ByteArray(8192)
118+
var totalBytes = 0L
119+
var bytesRead: Int
120+
121+
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
122+
totalBytes += bytesRead
123+
}
124+
totalBytes
125+
}
126+
return requireNotNull(inputStreamBytes)
127+
}
128+
78129
}

presentation/src/main/java/com/moez/QKSMS/injection/AppModule.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import dev.octoshrimpy.quik.repository.ScheduledMessageRepository
9191
import dev.octoshrimpy.quik.repository.ScheduledMessageRepositoryImpl
9292
import dev.octoshrimpy.quik.repository.SyncRepository
9393
import dev.octoshrimpy.quik.repository.SyncRepositoryImpl
94+
import dev.octoshrimpy.quik.util.GifCompressor
9495
import dev.octoshrimpy.quik.worker.InjectionWorkerFactory
9596
import javax.inject.Singleton
9697

@@ -126,6 +127,12 @@ class AppModule(private var application: Application) {
126127
.build()
127128
}
128129

130+
@Provides
131+
@Singleton
132+
fun provideGifCompressor(): GifCompressor {
133+
return GifCompressor()
134+
}
135+
129136
@Provides
130137
fun provideViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory = factory
131138

0 commit comments

Comments
 (0)