diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt index 7328274356..3bc6146a25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt @@ -24,6 +24,7 @@ import network.loki.messenger.libsession_util.image.GifUtils import network.loki.messenger.libsession_util.image.WebPUtils import okio.BufferedSource import okio.FileSystem +import okio.blackholeSink import org.session.libsession.utilities.Util import org.session.libsignal.streams.AttachmentCipherInputStream import org.session.libsignal.streams.AttachmentCipherOutputStream @@ -36,6 +37,7 @@ import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.ImageUtils import java.io.ByteArrayOutputStream import java.security.MessageDigest +import java.util.concurrent.TimeoutException import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -57,6 +59,109 @@ class AttachmentProcessor @Inject constructor( val imageSize: IntSize ) + suspend fun processAvatar( + data: BufferedSource, + dataSizeHint: Long?, + ): ProcessResult? { + return when { + AnimatedImageUtils.isAnimatedWebP(data) -> { + val convertResult = runCatching { + data.peek().use { + processAnimatedWebP( + data = it, + maxImageResolution = MAX_AVATAR_SIZE_PX, + timeoutMills = 5_000L, + ) + } + } + + val processResult = when { + convertResult.isSuccess -> convertResult.getOrThrow() ?: return null + convertResult.exceptionOrNull() is TimeoutException -> { + Log.w(TAG, "Animated WebP processing timed out, skipping") + return null + } + + else -> throw convertResult.exceptionOrNull()!! + } + + val dataSize = dataSizeHint ?: data.readAll(blackholeSink()) + if (dataSize < processResult.data.size) { + Log.d( + TAG, + "Avatar processing increased size from $dataSize to ${processResult.data.size}, skipped result" + ) + return null + } else { + processResult + } + } + + AnimatedImageUtils.isAnimatedGif(data) -> { + val origSize = data.peek().inputStream().use(BitmapUtil::getDimensions) + .let { pair -> IntSize(pair.first, pair.second) } + + val targetSize = if (origSize.width <= MAX_AVATAR_SIZE_PX.width && + origSize.height <= MAX_AVATAR_SIZE_PX.height) { + origSize + } else { + scaleToFit(origSize, MAX_AVATAR_SIZE_PX).first + } + + // First try to convert to webp in 5 seconds + val convertResult = runCatching { + data.peek().inputStream().use { input -> + WebPUtils.encodeGifToWebP( + input = input, 5_000L, + targetWidth = targetSize.width, targetHeight = targetSize.height + ) + } + } + + val processResult = when { + convertResult.isSuccess -> ProcessResult( + data = convertResult.getOrThrow(), + mimeType = "image/webp", + imageSize = targetSize + ) + + convertResult.exceptionOrNull() is TimeoutException -> { + Log.w(TAG, "WebP conversion timed out, falling back to GIF re-encoding") + // Fallback to re-encoding as GIF + processGif(data.peek(), MAX_AVATAR_SIZE_PX) + } + + else -> { + throw convertResult.exceptionOrNull()!! + } + } + + if (processResult != null) { + val dataSize = dataSizeHint ?: data.readAll(blackholeSink()) + if (dataSize < processResult.data.size) { + Log.d( + TAG, + "Avatar processing increased size from $dataSize to ${processResult.data.size}, skipped result" + ) + return null + } + } + + processResult + } + + else -> { + // All static images + val (data, size) = processStaticImage(data, MAX_AVATAR_SIZE_PX, Bitmap.CompressFormat.WEBP, 90) + ProcessResult( + data = data, + mimeType = "image/webp", + imageSize = size + ) + } + } + } + /** * Process a file based on its mime type and the given constraints. * @@ -92,7 +197,11 @@ class AttachmentProcessor @Inject constructor( return null } - return processAnimatedWebP(data = data, maxImageResolution) + return processAnimatedWebP( + data = data, + maxImageResolution = maxImageResolution, + timeoutMills = 30_000L + ) } ImageUtils.isWebP(data) -> { @@ -250,17 +359,15 @@ class AttachmentProcessor @Inject constructor( private fun processAnimatedWebP( data: BufferedSource, - maxImageResolution: IntSize?, + maxImageResolution: IntSize, + timeoutMills: Long, ): ProcessResult? { val origSize = data.peek().inputStream().use(BitmapUtil::getDimensions) .let { pair -> IntSize(pair.first, pair.second) } val targetSize: IntSize - if (maxImageResolution == null || ( - origSize.width <= maxImageResolution.width && - origSize.height <= maxImageResolution.height) - ) { + if (origSize.width <= maxImageResolution.width && origSize.height <= maxImageResolution.height) { // No resizing needed hence no processing return null } else { @@ -276,7 +383,7 @@ class AttachmentProcessor @Inject constructor( input = data.readByteArray(), targetWidth = targetSize.width, targetHeight = targetSize.height, - timeoutMills = 10_000L, + timeoutMills = timeoutMills, ) Log.d( diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt index 5b1372f59b..8c22fd3aee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -103,12 +103,13 @@ class AvatarReuploadWorker @AssistedInject constructor( if ((lastUpdated != null && needsReProcessing(source)) || lastUpdated == null) { logAndToast("About to start reuploading avatar.") - val attachment = attachmentProcessor.process( + val attachment = attachmentProcessor.processAvatar( data = source, - maxImageResolution = AttachmentProcessor.MAX_AVATAR_SIZE_PX, - compressImage = true, + dataSizeHint = null, ) ?: return Result.failure() + Log.d(TAG, "Reuploading avatar with mimeType=${attachment.mimeType}, size=${attachment.imageSize}") + try { avatarUploadManager.get().uploadAvatar( pictureData = attachment.data, @@ -185,6 +186,7 @@ class AvatarReuploadWorker @AssistedInject constructor( return true } val bounds = readImageBounds(source) + Log.d(TAG, "Old avatar bounds: $bounds") return bounds.width > AttachmentProcessor.MAX_AVATAR_SIZE_PX.width || bounds.height > AttachmentProcessor.MAX_AVATAR_SIZE_PX.height } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 8af4fc620a..eaa28dd05d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -2,6 +2,7 @@ import android.Manifest import android.content.ActivityNotFoundException +import android.graphics.Bitmap import android.net.Uri import android.widget.Toast import androidx.activity.result.ActivityResultLauncher @@ -10,14 +11,20 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import com.canhub.cropper.CropImageContract import com.canhub.cropper.CropImageContractOptions import com.canhub.cropper.CropImageOptions import com.canhub.cropper.CropImageView import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getColorFromAttr +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.FullComposeScreenLockActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.util.FileProviderUtil @@ -59,7 +66,12 @@ class SettingsActivity : FullComposeScreenLockActivity() { viewModel.hideAvatarPickerOptions() // close the bottom sheet val outputFile = Uri.fromFile(File(cacheDir, "cropped")) - cropImage(viewModel.getTempFile()?.let(Uri::fromFile), outputFile) + val inputFile = viewModel.getTempFile()?.let(Uri::fromFile) + if (inputFile == null) { + Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show() + return@registerForActivityResult + } + cropImage(inputFile, outputFile) } else { Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show() } @@ -118,30 +130,47 @@ class SettingsActivity : FullComposeScreenLockActivity() { .execute() } - private fun cropImage(inputFile: Uri?, outputFile: Uri?){ - onAvatarCropped.launch( - CropImageContractOptions( - uri = inputFile, - cropImageOptions = CropImageOptions( - guidelines = CropImageView.Guidelines.ON, - aspectRatioX = 1, - aspectRatioY = 1, - fixAspectRatio = true, - cropShape = CropImageView.CropShape.OVAL, - customOutputUri = outputFile, - allowRotation = true, - allowFlipping = true, - backgroundColor = imageScrim, - toolbarColor = bgColor, - activityBackgroundColor = bgColor, - toolbarTintColor = txtColor, - toolbarBackButtonColor = txtColor, - toolbarTitleColor = txtColor, - activityMenuIconColor = txtColor, - activityMenuTextColor = txtColor, - activityTitle = activityTitle + private fun cropImage(inputFile: Uri, outputFile: Uri){ + lifecycleScope.launch { + try { + val inputType = withContext(Dispatchers.Default) { + contentResolver.getType(inputFile) + } + + onAvatarCropped.launch( + CropImageContractOptions( + uri = inputFile, + cropImageOptions = CropImageOptions( + guidelines = CropImageView.Guidelines.ON, + aspectRatioX = 1, + aspectRatioY = 1, + fixAspectRatio = true, + cropShape = CropImageView.CropShape.OVAL, + customOutputUri = outputFile, + allowRotation = true, + allowFlipping = true, + backgroundColor = imageScrim, + toolbarColor = bgColor, + activityBackgroundColor = bgColor, + toolbarTintColor = txtColor, + toolbarBackButtonColor = txtColor, + toolbarTitleColor = txtColor, + activityMenuIconColor = txtColor, + activityMenuTextColor = txtColor, + activityTitle = activityTitle, + outputCompressFormat = when { + inputType?.startsWith("image/png") == true -> Bitmap.CompressFormat.PNG + inputType?.startsWith("image/webp") == true -> Bitmap.CompressFormat.WEBP + else -> Bitmap.CompressFormat.JPEG + } + ) + ) ) - ) - ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Error launching cropper", e) + Toast.makeText(this@SettingsActivity, R.string.errorUnknown, Toast.LENGTH_SHORT).show() + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index 554e1a0710..d06505f8ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -203,10 +203,9 @@ class SettingsViewModel @Inject constructor( try { val bytes = context.contentResolver.openInputStream(uri)!!.source().buffer().use { data -> attachmentProcessor - .process( + .processAvatar( data = data, - maxImageResolution = AttachmentProcessor.MAX_AVATAR_SIZE_PX, - compressImage = true, + dataSizeHint = null ) ?.data ?: data.readByteArray() @@ -224,7 +223,10 @@ class SettingsViewModel @Inject constructor( } catch (e: Exception) { Log.e(TAG, "Error reading avatar bytes", e) if (e !is CancellationException) { - Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG) + .show() + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 682c0c4533..80213d0d0b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ kotlinVersion = "2.2.20" kryoVersion = "5.6.2" kspVersion = "2.2.10-2.0.2" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.9" +libsessionUtilAndroidVersion = "1.0.9-3-g53cc5ce" media3ExoplayerVersion = "1.8.0" mockitoCoreVersion = "5.20.0" navVersion = "2.9.4"