Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 144 additions & 68 deletions data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import dev.octoshrimpy.quik.receiver.MessageDeliveredReceiver
import dev.octoshrimpy.quik.receiver.MessageSentReceiver
import dev.octoshrimpy.quik.receiver.SendDelayedMessageReceiver
import dev.octoshrimpy.quik.receiver.SendDelayedMessageReceiver.Companion.MESSAGE_ID_EXTRA
import dev.octoshrimpy.quik.util.GifCompressor
import dev.octoshrimpy.quik.util.ImageUtils
import dev.octoshrimpy.quik.util.PhoneNumberUtils
import dev.octoshrimpy.quik.util.Preferences
Expand All @@ -76,14 +77,14 @@ import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.sqrt

@Singleton
open class MessageRepositoryImpl @Inject constructor(
private val activeConversationManager: ActiveConversationManager,
private val context: Context,
private val messageIds: KeyManager,
private val phoneNumberUtils: PhoneNumberUtils,
private val gifCompressor: GifCompressor,
private val prefs: Preferences,
private val syncRepository: SyncRepository,
private val reactions: EmojiReactionRepository,
Expand Down Expand Up @@ -458,6 +459,9 @@ open class MessageRepositoryImpl @Inject constructor(
?.let(SmsManagerFactory::createSmsManager)
?: SmsManager.getDefault()

val allowAttachAudio = smsManager.carrierConfigValues
.getBoolean(SmsManager.MMS_CONFIG_ALLOW_ATTACH_AUDIO, true)

val maxWidth = smsManager.carrierConfigValues
.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_WIDTH)
.takeIf { prefs.mmsSize.get() == -1 }
Expand All @@ -483,11 +487,12 @@ open class MessageRepositoryImpl @Inject constructor(
// filter in only items that exist (user may have deleted the file)
.filter { it.uri.resourceExists(context) }
.map {
remainingBytes -= it.getResourceBytes(context).size
val bytes = compressedAttachmentBytes(it, Int.MAX_VALUE, maxWidth, maxHeight, allowAttachAudio)
remainingBytes -= bytes.size
val part = com.google.android.mms.MMSPart().apply {
MimeType = it.getType(context)
Name = it.getName(context)
Data = it.getResourceBytes(context)
Data = bytes
}

// release the attachment hold on the image bytes so the GC can reclaim
Expand All @@ -512,71 +517,12 @@ open class MessageRepositoryImpl @Inject constructor(
val imageByteCount = imageBytesByAttachment.values.sumOf { it.size }
if (imageByteCount > remainingBytes) {
imageBytesByAttachment.forEach { (attachment, originalBytes) ->
val uri = attachment.uri
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.getType(context) == "image/gif") {
true -> ImageUtils.getScaledGif(
context, attachment.uri, newWidth, newHeight
)

false -> ImageUtils.getScaledImage(
context, attachment.uri, newWidth, newHeight
)
}

Timber.d(
"Compression attempt $attempts: ${
scaledBytes.size / 1024
}/${maxBytes.toInt() / 1024}Kb ($width*$height -> $newWidth*${
newHeight
})"
)

// release the attachment hold on the image bytes so the GC can reclaim
attachment.releaseResourceBytes()
}

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
val perMaxBytes = (originalBytes.size / imageByteCount.toFloat() * remainingBytes).toInt()
// skip images already within their proportional share - recompressing them
// wastes a decode pass and needlessly degrades quality
if (originalBytes.size > perMaxBytes)
imageBytesByAttachment[attachment] =
compressedAttachmentBytes(attachment, perMaxBytes, maxWidth, maxHeight, allowAttachAudio)
}
}

Expand Down Expand Up @@ -639,6 +585,136 @@ open class MessageRepositoryImpl @Inject constructor(
return sendMessage(message)
}

// Compress images via binary search on dimensions then quality reduction, capping at maxBytes.
// Audio attachments are logged and passed through raw. All other types are passed through raw.
private fun compressedAttachmentBytes(
attachment: Attachment,
maxBytes: Int,
maxWidth: Int,
maxHeight: Int,
allowAttachAudio: Boolean
): ByteArray {
val mimeType = attachment.getType(context)
return when {
mimeType.startsWith("image/") -> {
val isGif = mimeType == "image/gif"
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(attachment.uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, opts)
}
val origWidth = opts.outWidth
val origHeight = opts.outHeight

// Bounds decoding fails on formats BitmapFactory can't read. Without real
// dimensions the aspect ratio is 0/0, so hand off to Glide rather than divide by it.
if (origWidth <= 0 || origHeight <= 0) {
Timber.w("Could not read image bounds, falling back to Glide scaling")
return ImageUtils.getScaledImage(context, attachment.uri, maxWidth, maxHeight)
}

val aspectRatio = origWidth.toFloat() / origHeight.toFloat()

val maxWidthByHeight = if (maxHeight != Int.MAX_VALUE) {
minOf((maxHeight * aspectRatio.toDouble()).toInt(), origWidth)
} else origWidth
// Never above the original or the carrier's ceiling: raising this to a floor of
// 100 would upscale images smaller than that and inflate the payload.
val searchHi = minOf(origWidth, maxWidth, maxWidthByHeight).coerceAtLeast(1)

var lo = 1
var hi = searchHi
var bestBytes: ByteArray? = null
var attempt = 0

// Last-resort size when nothing in the search fit. Bounded by searchHi so an
// image already smaller than this is never upscaled back up.
val fallbackWidth = minOf(100, searchHi).coerceAtLeast(1)
val fallbackHeight = (fallbackWidth / aspectRatio).toInt().coerceAtLeast(1)

val result = if (isGif) {
bestBytes = gifCompressor.compressGif(
context,
attachment,
maxBytes,
origWidth,
aspectRatio
)

bestBytes ?: ImageUtils
.getScaledGif(context, attachment.uri, fallbackWidth, fallbackHeight)
.also {
Timber.w(
"GIF too large after compression: ${
it.size / 1024
}Kb target ${maxBytes / 1024}Kb, sending anyway"
)
}
} else {
val searchHiHeight = (searchHi / aspectRatio).toInt().coerceAtLeast(1)
// Decode once for the whole search. Every attempt below re-encodes that
// bitmap from memory instead of re-reading and re-decoding the source file.
val encoder = ImageUtils.ScaledImageEncoder
.open(context, attachment.uri, searchHi, searchHiHeight)

if (encoder == null) {
Timber.w("Could not decode image, falling back to Glide scaling")
ImageUtils.getScaledImage(context, attachment.uri, maxWidth, maxHeight)
} else encoder.use {
while (lo <= hi) {
val midWidth = (lo + hi) / 2
val midHeight = (midWidth / aspectRatio).toInt().coerceAtLeast(1)
attempt++
val candidate = encoder.encode(midWidth, midHeight)
Timber.d(
"Compression attempt $attempt: ${
candidate.size / 1024
}/${maxBytes / 1024}Kb ($origWidth*$origHeight -> $midWidth*$midHeight)"
)
if (candidate.size <= maxBytes) {
bestBytes = candidate
lo = midWidth + 1
} else {
hi = midWidth - 1
}
}

bestBytes ?: encoder
.encodeWithinBytes(fallbackWidth, fallbackHeight, maxBytes)
.also {
if (it.size > maxBytes) {
Timber.w(
"Failed to compress image: ${
it.size / 1024
}Kb target ${maxBytes / 1024}Kb, sending anyway"
)
}
}
}
}

// release the attachment hold on the image bytes so the GC can reclaim
attachment.releaseResourceBytes()

Timber.v(
"Compressed to ${result.size / 1024}Kb with target ${
maxBytes / 1024
}Kb in $attempt attempts"
)
result
}
mimeType.startsWith("audio/") -> {
attachment.getResourceBytes(context).also { bytes ->
Timber.i(
"Audio attachment: ${
bytes.size / 1024
}Kb, carrier allowAttachAudio=$allowAttachAudio"
)
}
}
else -> attachment.getResourceBytes(context)
}
}

override fun sendMessage(message: Message): Collection<Message> {
val retVal = mutableListOf<Message>()

Expand Down
105 changes: 105 additions & 0 deletions data/src/main/java/com/moez/QKSMS/util/GifCompressor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2025 QUIK SMS
*
* This file is part of QUIK SMS.
*
* QUIK SMS 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.
*
* QUIK SMS 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 QUIK SMS. If not, see <http://www.gnu.org/licenses/>.
*/
package dev.octoshrimpy.quik.util

import android.content.Context
import android.content.res.AssetFileDescriptor
import android.provider.OpenableColumns
import dev.octoshrimpy.quik.model.Attachment
import dev.octoshrimpy.quik.util.ImageUtils.getScaledGif
import timber.log.Timber
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sqrt

class GifCompressor(
/**
* Dampens the amount the target compression amount will influence scale ratio.
* In order to reduce compression, lower this number, and to increase it, raise this number
*
* @property compressionParameter
*/
val compressionParameter: Double = 0.35,

/**
* Determines minimum file dimensions,
* adjust to avoid large GIFS compressed to unreadable amounts
*/

val minFileDimensions: Int = 20
) {
/**
* GIFs are very expensive to compress, so does a quick compression
* by estimating the scale down ratio using byte density, and overall size
* then compressing that way.
*
* Can be fine-tuned using [compressionParameter], and currently leans toward overcompression
*/
fun compressGif(
context: Context,
attachment: Attachment,
maxBytes: Int,
origWidth: Int,
aspectRatio: Float
): ByteArray {
// Determine file properties
val rawFileSize = ImageUtils.fetchFileSize(context, attachment).coerceAtLeast(1)
val origHeight = (origWidth / aspectRatio).toInt()
val totalPixelsPerFrame = origWidth * origHeight
val byteDensity = max(1.0, rawFileSize.toDouble() / totalPixelsPerFrame)

// Figure out generally how much we will have to compress,
// then dampen it and create a base scale
val targetCompressionRatio = rawFileSize.toDouble() / maxBytes.toDouble()
val targetCompressionDampened =
compressionParameter * (compressionParameter * 10) / targetCompressionRatio
val baseScale = 1.0 / (sqrt(targetCompressionRatio) + targetCompressionDampened)

// Calculate how much byte density deviates from expected values
val byteDensityScore = byteDensityScore(byteDensity, rawFileSize.toDouble())

// Determine how aggressive the compression will be, based mainly on byteDensityScore
val compressionScale = (targetCompressionRatio / (compressionParameter * 2))
val compressionAggressiveness =
targetCompressionRatio.pow(byteDensityScore) / (compressionScale)

// How much we will have to scale down the GIF, can't be over 1
val scaleRatio = baseScale.pow(compressionAggressiveness).coerceAtMost(1.0)

// Determine compressed dimensions from scale ratio
val midWidthGif = (origWidth * scaleRatio).toInt().coerceAtLeast(minFileDimensions)
val midHeightGif = (midWidthGif / aspectRatio).toInt().coerceAtLeast(minFileDimensions)

return getScaledGif(context, attachment.uri, midWidthGif, midHeightGif)
}

/**
* Determines how much byte density deviates from expected.
* If there is a lot of deviation, we can assume that the GIF is going to require more compression
*/
private fun byteDensityScore(byteDensity: Double, rawFileSize: Double): Double {
val d = ln(1.0 + byteDensity)
val r = ln(1.0 + rawFileSize)

val t = d / (d + r)
return 2.0 - 8.0 * t * (1.0 - t)
}

}
Loading
Loading