From 83ba8ee3f3c1dcc6d64fe8111d11936f4de7afbb Mon Sep 17 00:00:00 2001 From: Naveen Date: Thu, 16 Feb 2023 03:46:41 +0530 Subject: [PATCH 1/6] Improve image compression - Approximate quality and compress in one go instead of iterating. - If compressing doesn't help achieve the required file size limit, keep reducing resolution until the file size is smaller than the max limit. - Convert PNGs to JPEG for lossy compression when max MMS limit is less than 1MB. This helps avoid tiny pixelated PNG images. - Removed the abstraction (didn't think it was necessary) --- .../smsmessenger/helpers/ImageCompressor.kt | 139 ++++++++---------- 1 file changed, 62 insertions(+), 77 deletions(-) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/ImageCompressor.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/ImageCompressor.kt index 76560e24..4cfabe9b 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/ImageCompressor.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/helpers/ImageCompressor.kt @@ -15,6 +15,7 @@ import com.simplemobiletools.smsmessenger.extensions.getFileSizeFromUri import com.simplemobiletools.smsmessenger.extensions.isImageMimeType import java.io.File import java.io.FileOutputStream +import kotlin.math.roundToInt /** * Compress image to a given size based on @@ -28,7 +29,11 @@ class ImageCompressor(private val context: Context) { } } - fun compressImage(uri: Uri, compressSize: Long, callback: (compressedFileUri: Uri?) -> Unit) { + private val minQuality = 30 + private val minResolution = 56 + private val scaleStepFactor = 0.6f // increase for more accurate file size at the cost increased computation + + fun compressImage(uri: Uri, compressSize: Long, lossy: Boolean = compressSize < FILE_SIZE_1_MB, callback: (compressedFileUri: Uri?) -> Unit) { ensureBackgroundThread { try { val fileSize = context.getFileSizeFromUri(uri) @@ -36,28 +41,49 @@ class ImageCompressor(private val context: Context) { val mimeType = contentResolver.getType(uri)!! if (mimeType.isImageMimeType()) { val byteArray = contentResolver.openInputStream(uri)?.readBytes()!! - var destinationFile = File(outputDirectory, System.currentTimeMillis().toString().plus(mimeType.getExtensionFromMimeType())) - destinationFile.writeBytes(byteArray) - val sizeConstraint = SizeConstraint(compressSize) - val bitmap = loadBitmap(destinationFile) + var imageFile = File(outputDirectory, System.currentTimeMillis().toString().plus(mimeType.getExtensionFromMimeType())) + imageFile.writeBytes(byteArray) + val bitmap = loadBitmap(imageFile) + val format = if (lossy) { + Bitmap.CompressFormat.JPEG + } else { + imageFile.path.getCompressionFormat() + } - // if image weight > * 2 targeted size: cut down resolution by 2 - if (fileSize > 2 * compressSize) { - val resConstraint = ResolutionConstraint(bitmap.width / 2, bitmap.height / 2) - while (resConstraint.isSatisfied(destinationFile).not()) { - destinationFile = resConstraint.satisfy(destinationFile) + // This quality approximation mostly works for smaller images but will fail with larger images. + val compressionRatio = compressSize / fileSize.toDouble() + val quality = maxOf((compressionRatio * 100).roundToInt(), minQuality) + imageFile = overWrite(imageFile, bitmap, format = format, quality = quality) + + // Even the highest quality images start to look ugly if we use 10 as the minimum quality, + // so we better save some image quality and change resolution instead. This is time consuming + // and mostly needed for very large images. Since there's no reliable way to predict the + // required resolution, we'll just iterate and find the best result. + if (imageFile.length() > compressSize) { + var scaledWidth = bitmap.width + var scaledHeight = bitmap.height + + while (imageFile.length() > compressSize) { + scaledWidth = (scaledWidth * scaleStepFactor).roundToInt() + scaledHeight = (scaledHeight * scaleStepFactor).roundToInt() + if (scaledHeight < minResolution && scaledWidth < minResolution) { + break + } + + imageFile = decodeSampledBitmapFromFile(imageFile, scaledWidth, scaledHeight).run { + determineImageRotation(imageFile, bitmap = this).run { + overWrite(imageFile, bitmap = this, format = format, quality = quality) + } + } } } - // do compression - while (sizeConstraint.isSatisfied(destinationFile).not()) { - destinationFile = sizeConstraint.satisfy(destinationFile) - } - callback.invoke(context.getMyFileUri(destinationFile)) + + callback.invoke(context.getMyFileUri(imageFile)) } else { callback.invoke(null) } } else { - //no need to compress since the file is less than the compress size + // no need to compress since the file is less than the compress size callback.invoke(uri) } } catch (e: Exception) { @@ -107,77 +133,36 @@ class ImageCompressor(private val context: Context) { return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) } - private inner class SizeConstraint( - private val maxFileSize: Long, - private val stepSize: Int = 10, - private val maxIteration: Int = 10, - private val minQuality: Int = 10 - ) { - private var iteration: Int = 0 + private fun decodeSampledBitmapFromFile(imageFile: File, reqWidth: Int, reqHeight: Int): Bitmap { + return BitmapFactory.Options().run { + inJustDecodeBounds = true + BitmapFactory.decodeFile(imageFile.absolutePath, this) - fun isSatisfied(imageFile: File): Boolean { - // If size requirement is not met and maxIteration is reached - if (iteration >= maxIteration && imageFile.length() >= maxFileSize) { - throw Exception("Unable to compress image to targeted size") - } - return imageFile.length() <= maxFileSize - } + inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) - fun satisfy(imageFile: File): File { - iteration++ - val quality = (100 - iteration * stepSize).takeIf { it >= minQuality } ?: minQuality - return overWrite(imageFile, loadBitmap(imageFile), quality = quality) + inJustDecodeBounds = false + BitmapFactory.decodeFile(imageFile.absolutePath, this) } } - private inner class ResolutionConstraint(private val width: Int, private val height: Int) { + private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + // Raw height and width of image + val height = options.outHeight + val width = options.outWidth + var inSampleSize = 1 - private fun decodeSampledBitmapFromFile(imageFile: File, reqWidth: Int, reqHeight: Int): Bitmap { - return BitmapFactory.Options().run { - inJustDecodeBounds = true - BitmapFactory.decodeFile(imageFile.absolutePath, this) + if (height > reqHeight || width > reqWidth) { - inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) + val halfHeight: Int = height / 2 + val halfWidth: Int = width / 2 - inJustDecodeBounds = false - BitmapFactory.decodeFile(imageFile.absolutePath, this) + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 } } - private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { - // Raw height and width of image - val (height: Int, width: Int) = options.run { outHeight to outWidth } - var inSampleSize = 1 - - if (height > reqHeight || width > reqWidth) { - - val halfHeight: Int = height / 2 - val halfWidth: Int = width / 2 - - // Calculate the largest inSampleSize value that is a power of 2 and keeps both - // height and width larger than the requested height and width. - while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { - inSampleSize *= 2 - } - } - - return inSampleSize - } - - fun isSatisfied(imageFile: File): Boolean { - return BitmapFactory.Options().run { - inJustDecodeBounds = true - BitmapFactory.decodeFile(imageFile.absolutePath, this) - calculateInSampleSize(this, width, height) <= 1 - } - } - - fun satisfy(imageFile: File): File { - return decodeSampledBitmapFromFile(imageFile, width, height).run { - determineImageRotation(imageFile, this).run { - overWrite(imageFile, this) - } - } - } + return inSampleSize } } From c931eb01710380cda746d3179ecf319616f15de7 Mon Sep 17 00:00:00 2001 From: Naveen Date: Sun, 19 Feb 2023 03:03:01 +0530 Subject: [PATCH 2/6] Send MMS attachments separately If the message contains text, it is sent with the last attachment. --- .../smsmessenger/activities/ThreadActivity.kt | 5 +++-- .../smsmessenger/messaging/Messaging.kt | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt index c94cfd4e..33433014 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt @@ -1228,8 +1228,9 @@ class ThreadActivity : SimpleActivity() { sendMessageCompat(text, addresses, subscriptionId, attachments) ensureBackgroundThread { val messageIds = messages.map { it.id } - val message = getMessages(threadId, getImageResolutions = true, limit = 1).firstOrNull { it.id !in messageIds } - if (message != null) { + val messages = getMessages(threadId, getImageResolutions = true, limit = maxOf(1, attachments.size)) + .filter { it.id !in messageIds } + for (message in messages) { insertOrUpdateMessage(message) } } diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/messaging/Messaging.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/messaging/Messaging.kt index 5a92c719..3f786503 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/messaging/Messaging.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/messaging/Messaging.kt @@ -40,7 +40,16 @@ fun Context.sendMessageCompat(text: String, addresses: List, subId: Int? val isMms = attachments.isNotEmpty() || isLongMmsMessage(text, settings) || addresses.size > 1 && settings.group if (isMms) { - messagingUtils.sendMmsMessage(text, addresses, attachments, settings) + // we send all MMS attachments separately to reduces the chances of hitting provider MMS limit. + if (attachments.size > 1) { + for (i in 0 until attachments.lastIndex - 1) { + val attachment = attachments[i] + messagingUtils.sendMmsMessage("", addresses, listOf(attachment), settings) + } + } + + val lastAttachment = attachments[attachments.lastIndex] + messagingUtils.sendMmsMessage(text, addresses, listOf(lastAttachment), settings) } else { try { messagingUtils.sendSmsMessage(text, addresses.toSet(), settings.subscriptionId, settings.deliveryReports) From 751fe359e7d1c7fabef214b97a4919d73b63aa5e Mon Sep 17 00:00:00 2001 From: Naveen Date: Sun, 19 Feb 2023 14:37:41 +0530 Subject: [PATCH 3/6] Show error toast for attachments larger than MMS limit --- .../smsmessenger/activities/ThreadActivity.kt | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt index 33433014..41e2db33 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/activities/ThreadActivity.kt @@ -33,6 +33,7 @@ import android.view.inputmethod.EditorInfo import android.widget.LinearLayout import android.widget.LinearLayout.LayoutParams import android.widget.RelativeLayout +import android.widget.Toast import androidx.annotation.StringRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.res.ResourcesCompat @@ -1100,6 +1101,23 @@ class ThreadActivity : SimpleActivity() { return } + val mimeType = contentResolver.getType(uri) + if (mimeType == null) { + toast(R.string.unknown_error_occurred) + return + } + val isImage = mimeType.isImageMimeType() + val isGif = mimeType.isGifMimeType() + if (isGif || !isImage) { + // is it assumed that images will always be compressed below the max MMS size limit + val fileSize = getFileSizeFromUri(uri) + val mmsFileSizeLimit = config.mmsFileSizeLimit + if (mmsFileSizeLimit != FILE_SIZE_NONE && fileSize > mmsFileSizeLimit) { + toast(R.string.attachment_sized_exceeds_max_limit, length = Toast.LENGTH_LONG) + return + } + } + var adapter = getAttachmentsAdapter() if (adapter == null) { adapter = AttachmentsAdapter( @@ -1115,17 +1133,12 @@ class ThreadActivity : SimpleActivity() { } thread_attachments_recyclerview.beVisible() - val mimeType = contentResolver.getType(uri) - if (mimeType == null) { - toast(R.string.unknown_error_occurred) - return - } val attachment = AttachmentSelection( id = id, uri = uri, mimetype = mimeType, filename = getFilenameFromUri(uri), - isPending = mimeType.isImageMimeType() && !mimeType.isGifMimeType() + isPending = isImage && !isGif ) adapter.addAttachment(attachment) checkSendMessageAvailability() From 0b33ec877d2aa43ad1591b99f710ebe27a0b1952 Mon Sep 17 00:00:00 2001 From: Naveen Date: Sun, 19 Feb 2023 14:49:29 +0530 Subject: [PATCH 4/6] Remove index decrement when using `until` --- .../com/simplemobiletools/smsmessenger/messaging/Messaging.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/messaging/Messaging.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/messaging/Messaging.kt index 3f786503..494a9d66 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/messaging/Messaging.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/messaging/Messaging.kt @@ -42,7 +42,7 @@ fun Context.sendMessageCompat(text: String, addresses: List, subId: Int? if (isMms) { // we send all MMS attachments separately to reduces the chances of hitting provider MMS limit. if (attachments.size > 1) { - for (i in 0 until attachments.lastIndex - 1) { + for (i in 0 until attachments.lastIndex) { val attachment = attachments[i] messagingUtils.sendMmsMessage("", addresses, listOf(attachment), settings) } From 49597a8db3f744e3232b04d44ebdf2ab3b2c6999 Mon Sep 17 00:00:00 2001 From: Naveen Date: Sun, 19 Feb 2023 15:17:51 +0530 Subject: [PATCH 5/6] Add some spacing between attachments and text --- .../simplemobiletools/smsmessenger/adapters/ThreadAdapter.kt | 5 ++++- app/src/main/res/layout/item_attachment_image.xml | 1 - app/src/main/res/layout/item_sent_message.xml | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ThreadAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ThreadAdapter.kt index 8e942bfb..95007916 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ThreadAdapter.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/adapters/ThreadAdapter.kt @@ -260,8 +260,9 @@ class ThreadAdapter( holder.viewClicked(message) } - thread_mesage_attachments_holder.removeAllViews() if (message.attachment?.attachments?.isNotEmpty() == true) { + thread_mesage_attachments_holder.beVisible() + thread_mesage_attachments_holder.removeAllViews() for (attachment in message.attachment.attachments) { val mimetype = attachment.mimetype when { @@ -272,6 +273,8 @@ class ThreadAdapter( thread_message_play_outline.beVisibleIf(mimetype.startsWith("video/")) } + } else { + thread_mesage_attachments_holder.beGone() } } } diff --git a/app/src/main/res/layout/item_attachment_image.xml b/app/src/main/res/layout/item_attachment_image.xml index 633f03f9..6c12b04e 100644 --- a/app/src/main/res/layout/item_attachment_image.xml +++ b/app/src/main/res/layout/item_attachment_image.xml @@ -5,5 +5,4 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:adjustViewBounds="true" - android:paddingBottom="@dimen/medium_margin" app:shapeAppearanceOverlay="@style/roundedImageView" /> diff --git a/app/src/main/res/layout/item_sent_message.xml b/app/src/main/res/layout/item_sent_message.xml index 2db0bdc9..2760f71d 100644 --- a/app/src/main/res/layout/item_sent_message.xml +++ b/app/src/main/res/layout/item_sent_message.xml @@ -5,7 +5,7 @@ android:id="@+id/thread_message_holder" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="@dimen/medium_margin" + android:layout_marginTop="@dimen/small_margin" android:foreground="@drawable/selector" android:paddingStart="@dimen/activity_margin" android:paddingEnd="@dimen/activity_margin"> @@ -23,6 +23,7 @@ android:id="@+id/thread_mesage_attachments_holder" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginVertical="@dimen/tiny_margin" android:divider="@drawable/linear_layout_vertical_divider" android:orientation="vertical" android:showDividers="middle" /> @@ -44,6 +45,7 @@ android:layout_height="wrap_content" android:layout_below="@+id/thread_mesage_attachments_holder" android:layout_alignParentEnd="true" + android:layout_marginVertical="@dimen/tiny_margin" android:autoLink="email|web" android:background="@drawable/item_sent_background" android:padding="@dimen/normal_margin" From a27790ee7cad694a20f3194aab11a90a008452f8 Mon Sep 17 00:00:00 2001 From: Naveen Date: Tue, 28 Feb 2023 14:02:53 +0530 Subject: [PATCH 6/6] Always refresh thread to reflect sending failure --- .../smsmessenger/receivers/MmsSentReceiver.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/MmsSentReceiver.kt b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/MmsSentReceiver.kt index d9ea5b18..7dc8e938 100644 --- a/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/MmsSentReceiver.kt +++ b/app/src/main/kotlin/com/simplemobiletools/smsmessenger/receivers/MmsSentReceiver.kt @@ -42,9 +42,7 @@ class MmsSentReceiver : SendStatusReceiver() { } override fun updateAppDatabase(context: Context, intent: Intent, receiverResultCode: Int) { - if (resultCode == Activity.RESULT_OK) { - refreshMessages() - } + refreshMessages() } companion object {