diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 8cdc660584..238b7edb04 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -89,16 +89,16 @@ class MessageSendJob @AssistedInject constructor( val isSync = destination is Destination.Contact && destination.publicKey == storage.getUserPublicKey() try { - withTimeout(20_000L) { - // Shouldn't send message to group when the group has no keys available - if (destination is Destination.ClosedGroup) { + // Shouldn't send message to group when the group has no keys available + if (destination is Destination.ClosedGroup) { + withTimeout(20_000L) { configFactory .waitForGroupEncryptionKeys(AccountId(destination.publicKey)) } - - MessageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync) } + MessageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync) + this.handleSuccess(dispatcherName) statusCallback?.trySend(Result.success(Unit)) } catch (e: HTTP.HTTPRequestFailedException) { diff --git a/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java b/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java deleted file mode 100644 index 43734605ae..0000000000 --- a/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.session.libsignal.streams; - -import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; -import static org.session.libsignal.utilities.Util.SECURE_RANDOM; - -import java.io.IOException; -import java.io.OutputStream; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; - -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; - -public class ProfileCipherOutputStream extends DigestingOutputStream { - - private final Cipher cipher; - - public ProfileCipherOutputStream(OutputStream out, byte[] key) throws IOException { - super(out); - try { - this.cipher = Cipher.getInstance("AES/GCM/NoPadding"); - - byte[] nonce = generateNonce(); - this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); - - super.write(nonce, 0, nonce.length); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } catch (NoSuchPaddingException e) { - throw new AssertionError(e); - } catch (InvalidAlgorithmParameterException e) { - throw new AssertionError(e); - } catch (InvalidKeyException e) { - throw new IOException(e); - } - } - - @Override - public void write(byte[] buffer) throws IOException { - write(buffer, 0, buffer.length); - } - - @Override - public void write(byte[] buffer, int offset, int length) throws IOException { - byte[] output = cipher.update(buffer, offset, length); - super.write(output); - } - - @Override - public void write(int b) throws IOException { - byte[] input = new byte[1]; - input[0] = (byte)b; - - byte[] output; - synchronized (CIPHER_LOCK) { - output = cipher.update(input); - } - super.write(output); - } - - @Override - public void flush() throws IOException { - try { - byte[] output; - synchronized (CIPHER_LOCK) { - output = cipher.doFinal(); - } - - super.write(output); - super.flush(); - } catch (BadPaddingException | IllegalBlockSizeException e) { - throw new AssertionError(e); - } - } - - private byte[] generateNonce() { - byte[] nonce = new byte[12]; - SECURE_RANDOM.nextBytes(nonce); - return nonce; - } - - public static long getCiphertextLength(long plaintextLength) { - return 12 + 16 + plaintextLength; - } -} diff --git a/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java b/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java deleted file mode 100644 index d29be29602..0000000000 --- a/app/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStreamFactory.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.session.libsignal.streams; - -import java.io.IOException; -import java.io.OutputStream; - -public class ProfileCipherOutputStreamFactory implements OutputStreamFactory { - - private final byte[] key; - - public ProfileCipherOutputStreamFactory(byte[] key) { - this.key = key; - } - - @Override - public DigestingOutputStream createFor(OutputStream wrap) throws IOException { - return new ProfileCipherOutputStream(wrap, key); - } - -} 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..668249f8ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt @@ -8,7 +8,6 @@ import coil3.BitmapImage import coil3.ImageLoader import coil3.decode.DataSource import coil3.decode.ImageSource -import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.SourceFetchResult import coil3.request.CachePolicy @@ -18,12 +17,15 @@ import coil3.request.allowConversionToBitmap import coil3.request.allowHardware import coil3.request.allowRgb565 import coil3.size.Precision +import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.libsession_util.encrypt.Attachments import network.loki.messenger.libsession_util.image.GifUtils import network.loki.messenger.libsession_util.image.WebPUtils import okio.BufferedSource import okio.FileSystem +import okio.buffer +import okio.source import org.session.libsession.utilities.Util import org.session.libsignal.streams.AttachmentCipherInputStream import org.session.libsignal.streams.AttachmentCipherOutputStream @@ -31,11 +33,14 @@ import org.session.libsignal.streams.PaddingInputStream import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.ByteArraySlice.Companion.view import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.util.AnimatedImageUtils import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.ImageUtils +import java.io.ByteArrayInputStream 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 @@ -50,6 +55,7 @@ typealias DigestResult = ByteArray class AttachmentProcessor @Inject constructor( @param:ApplicationContext private val context: Context, private val imageLoader: Provider, + private val storage: Lazy, ) { class ProcessResult( val data: ByteArray, @@ -57,6 +63,116 @@ class AttachmentProcessor @Inject constructor( val imageSize: IntSize ) + suspend fun processAvatar( + data: ByteArray, + ): ProcessResult? { + val buffer = ByteArrayInputStream(data).source().buffer() + return when { + AnimatedImageUtils.isAnimatedWebP(buffer) -> { + val convertResult = runCatching { + buffer.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()!! + } + + if (processResult.data.size > data.size) { + Log.d( + TAG, + "Avatar processing increased size from ${data.size} to ${processResult.data.size}, skipped result" + ) + return null + } else { + processResult + } + } + + AnimatedImageUtils.isAnimatedGif(data) -> { + val origSize = ByteArrayInputStream(data).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 { + "image/webp" to WebPUtils.encodeGifToWebP( + input = data, + timeoutMills = 5_000L, + targetWidth = targetSize.width, targetHeight = targetSize.height + ) + }.recoverCatching { e -> + if (e is TimeoutException) { + // If we timed out, try re-encoding as GIF in 2 seconds + Log.w(TAG, "WebP conversion timed out, trying GIF re-encoding as fallback") + "image/gif" to GifUtils.reencodeGif( + input = data, + timeoutMills = 2_000L, + targetWidth = targetSize.width, + targetHeight = targetSize.height + ) + } else { + throw e + } + } + + val processResult = when { + convertResult.isSuccess -> { + val (mimeType, result) = convertResult.getOrThrow() + ProcessResult( + data = result, + mimeType = mimeType, + imageSize = targetSize + ) + } + + convertResult.exceptionOrNull() is TimeoutException -> { + Log.w(TAG, "All operation times out, skipping avatar processing") + null + } + + else -> { + throw convertResult.exceptionOrNull()!! + } + } + + if (processResult != null && processResult.data.size > data.size) { + Log.d(TAG, "Avatar processing increased size from ${data.size} 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 +208,11 @@ class AttachmentProcessor @Inject constructor( return null } - return processAnimatedWebP(data = data, maxImageResolution) + return processAnimatedWebP( + data = data, + maxImageResolution = maxImageResolution, + timeoutMills = 30_000L + ) } ImageUtils.isWebP(data) -> { @@ -152,8 +272,16 @@ class AttachmentProcessor @Inject constructor( */ fun encryptDeterministically(plaintext: ByteArray, domain: Attachments.Domain): EncryptResult { val cipherOut = ByteArray(Attachments.encryptedSize(plaintext.size.toLong()).toInt()) + val privateKey = requireNotNull(storage.get().getUserED25519KeyPair()?.secretKey) { + "No user identity available" + } + check(privateKey.data.size == 64) { + "Invalid ED25519 private key size: ${privateKey.data.size}" + } + val seed = privateKey.data.sliceArray(0 until 32) + val key = Attachments.encryptBytes( - seed = Util.getSecretBytes(32), + seed = seed, plaintextIn = plaintext, cipherOut = cipherOut, domain = domain, @@ -250,17 +378,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 +402,7 @@ class AttachmentProcessor @Inject constructor( input = data.readByteArray(), targetWidth = targetSize.width, targetHeight = targetSize.height, - timeoutMills = 10_000L, + timeoutMills = timeoutMills, ) Log.d( @@ -345,14 +471,12 @@ class AttachmentProcessor @Inject constructor( ).first } - val reencoded = data.peek().inputStream().use { input -> - GifUtils.reencodeGif( - input = input, - targetWidth = targetSize.width, - targetHeight = targetSize.height, - timeoutMills = 10_000L, - ) - } + val reencoded = GifUtils.reencodeGif( + input = data.readByteArray(), + targetWidth = targetSize.width, + targetHeight = targetSize.height, + timeoutMills = 10_000L, + ) Log.d( TAG, @@ -376,14 +500,12 @@ class AttachmentProcessor @Inject constructor( options: Options, imageLoader: ImageLoader ): Fetcher { - return object : Fetcher { - override suspend fun fetch(): FetchResult? { - return SourceFetchResult( - source = ImageSource(data, FileSystem.SYSTEM), - mimeType = null, - dataSource = DataSource.MEMORY - ) - } + return Fetcher { + SourceFetchResult( + source = ImageSource(data, FileSystem.SYSTEM), + mimeType = null, + dataSource = DataSource.MEMORY + ) } } } 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..ac6369708a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -17,9 +17,7 @@ import dagger.Lazy import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.BuildConfig import okhttp3.HttpUrl.Companion.toHttpUrl @@ -33,7 +31,6 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.CurrentActivityObserver import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant @@ -92,6 +89,8 @@ class AvatarReuploadWorker @AssistedInject constructor( return Result.success() } + val fileExpiry: Instant? + // Check if the file exists and whether we need to do reprocessing, if we do, we reprocess and re-upload localEncryptedFileInputStreamFactory.create(localFile).use { stream -> if (stream.meta.hasPermanentDownloadError) { @@ -99,16 +98,18 @@ class AvatarReuploadWorker @AssistedInject constructor( return Result.success() } + fileExpiry = stream.meta.expiryTime + val source = stream.source().buffer() if ((lastUpdated != null && needsReProcessing(source)) || lastUpdated == null) { logAndToast("About to start reuploading avatar.") - val attachment = attachmentProcessor.process( - data = source, - maxImageResolution = AttachmentProcessor.MAX_AVATAR_SIZE_PX, - compressImage = true, + val attachment = attachmentProcessor.processAvatar( + data = source.use { it.readByteArray() }, ) ?: return Result.failure() + Log.d(TAG, "Reuploading avatar with mimeType=${attachment.mimeType}, size=${attachment.imageSize}") + try { avatarUploadManager.get().uploadAvatar( pictureData = attachment.data, @@ -141,11 +142,14 @@ class AvatarReuploadWorker @AssistedInject constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - logAndToast("FileServer renew failed", e) - - // If the server doesn't allow us to renew, and last updated is 12 days ago, then re-upload our avatar + // When renew fails, we will try to re-upload the avatar if: + // 1. The file is expired (we have the record of this file's expiry time), or + // 2. The last update was more than 12 days ago. if ((e is NonRetryableException || e is OnionRequestAPI.HTTPRequestFailedAtDestinationException)) { - if ((lastUpdated?.isBefore(Instant.now().minus(Duration.ofDays(12)))) == true) { + val now = Instant.now() + if (fileExpiry?.isBefore(now) == true || + (lastUpdated?.isBefore(now.minus(Duration.ofDays(12)))) == true) { + logAndToast("FileServer renew failed, trying to upload", e) val pictureData = localEncryptedFileInputStreamFactory.create(localFile).use { stream -> check(!stream.meta.hasPermanentDownloadError) { @@ -172,9 +176,10 @@ class AvatarReuploadWorker @AssistedInject constructor( } return Result.success() + } else { + logAndToast("Error while renewing avatar. Retrying...", e) + return Result.retry() } - - return Result.failure() } return Result.success() @@ -185,6 +190,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/attachments/AvatarUploadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt index 6de080a809..b4bd8db154 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt @@ -14,21 +14,19 @@ import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.encrypt.Attachments import network.loki.messenger.libsession_util.util.Bytes import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile -import org.session.libsignal.streams.ProfileCipherOutputStream import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.castAwayType -import java.io.ByteArrayOutputStream import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration -import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds @@ -90,15 +88,7 @@ class AvatarUploadManager @Inject constructor( ) } else { val key = Util.getSecretBytes(PROFILE_KEY_LENGTH) - val ciphertext = ByteArrayOutputStream().use { outputStream -> - ProfileCipherOutputStream(outputStream, key).use { - it.write(pictureData) - it.flush() - } - - outputStream.toByteArray() - } - + val ciphertext = AESGCM.encrypt(pictureData, key) AttachmentProcessor.EncryptResult(ciphertext = ciphertext, key = key) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index ea88d50a4e..38c1d6eb34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -439,7 +439,7 @@ private List setMessagesRead(String where, String[] arguments public void updateSentTimestamp(long messageId, long newTimestamp) { SQLiteDatabase db = getWritableDatabase(); - db.rawQuery("UPDATE " + TABLE_NAME + " SET " + DATE_SENT + " = ? " + + db.rawExecSQL("UPDATE " + TABLE_NAME + " SET " + DATE_SENT + " = ? " + "WHERE " + ID + " = ?", newTimestamp, messageId); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 37a3c7a705..0ab54cbdca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -104,7 +104,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV55 = 76; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV54; + private static final int DATABASE_VERSION = lokiV55; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; 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 ec25ec3788..857891a2e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.preferences import android.content.Context import android.net.Uri +import android.provider.OpenableColumns import android.widget.Toast import androidx.core.net.toUri import androidx.lifecycle.ViewModel @@ -28,6 +29,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.libsession_util.util.UserPic +import okio.blackholeSink import okio.buffer import okio.source import org.session.libsession.database.StorageProtocol @@ -51,6 +53,8 @@ import org.thoughtcrime.securesms.attachments.AvatarUploadManager import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.mms.PushMediaConstraints import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData @@ -197,17 +201,33 @@ class SettingsViewModel @Inject constructor( fun onAvatarPicked(uri: Uri) { Log.i(TAG, "Picked a new avatar: $uri") - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { try { - val bytes = context.contentResolver.openInputStream(uri)!!.source().buffer().use { data -> - attachmentProcessor - .process( - data = data, - maxImageResolution = AttachmentProcessor.MAX_AVATAR_SIZE_PX, - compressImage = true, - ) - ?.data - ?: data.readByteArray() + // Query the content resolver for the size of this image + val contentSize = withContext(Dispatchers.IO) { + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.getLong(sizeIndex) + } else { + null + } + } + ?: context.contentResolver.openInputStream(uri)!!.source().buffer().use { it.readAll(blackholeSink()) } + } + + if (contentSize > MediaConstraints.getPushMediaConstraints().getImageMaxSize(context).toLong()) { + Log.e(TAG, "Selected avatar image is too large: $contentSize bytes") + Toast.makeText(context, R.string.profileDisplayPictureSizeError, Toast.LENGTH_LONG).show() + return@launch + } + + + val bytes = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)!!.use { + it.readBytes() + } } _uiState.update { @@ -220,10 +240,10 @@ class SettingsViewModel @Inject constructor( ) } } catch (e: Exception) { + if (e is CancellationException) throw e Log.e(TAG, "Error reading avatar bytes", e) - if (e !is CancellationException) { - Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() - } + Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG) + .show() } } } @@ -313,7 +333,11 @@ class SettingsViewModel @Inject constructor( // update dialog state _uiState.update { it.copy(avatarDialogState = AvatarDialogState.NoAvatar) } } else { - avatarUploadManager.uploadAvatar(profilePicture, isReupload = false) + val processed = withContext(Dispatchers.Default) { + attachmentProcessor.processAvatar(profilePicture) + }?.data ?: profilePicture + + avatarUploadManager.uploadAvatar(processed, isReupload = false) // We'll have to refetch the recipient to get the new avatar val selfRecipient = recipientRepository.getSelf() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt index 4b4e9efa65..61cc78cd5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AnimatedImageUtils.kt @@ -21,8 +21,12 @@ object AnimatedImageUtils { } fun isAnimated(rawImageData: ByteArray): Boolean { + if (isAnimatedGif(rawImageData)) { + return true + } + return ByteArrayInputStream(rawImageData).source().buffer().use { - isAnimatedGif(it) || isAnimatedWebP(it) + isAnimatedWebP(it) } } @@ -37,4 +41,6 @@ object AnimatedImageUtils { fun isAnimatedGif(buffer: BufferedSource): Boolean { return buffer.peek().inputStream().use(GifUtils::isAnimatedGif) } + + fun isAnimatedGif(buffer: ByteArray): Boolean = GifUtils.isAnimatedGif(buffer) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b96962675c..f632e491ef 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.3.0" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.9" +libsessionUtilAndroidVersion = "1.0.9-2-g8c03d1e" media3ExoplayerVersion = "1.8.0" mockitoCoreVersion = "5.20.0" navVersion = "2.9.5"