Skip to content

Commit d679519

Browse files
Fixed avatar processing (#1649)
1 parent f4c25b0 commit d679519

File tree

5 files changed

+180
-40
lines changed

5 files changed

+180
-40
lines changed

app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentProcessor.kt

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import network.loki.messenger.libsession_util.image.GifUtils
2525
import network.loki.messenger.libsession_util.image.WebPUtils
2626
import okio.BufferedSource
2727
import okio.FileSystem
28+
import okio.blackholeSink
2829
import org.session.libsession.utilities.Util
2930
import org.session.libsignal.streams.AttachmentCipherInputStream
3031
import org.session.libsignal.streams.AttachmentCipherOutputStream
@@ -40,6 +41,7 @@ import org.thoughtcrime.securesms.util.BitmapUtil
4041
import org.thoughtcrime.securesms.util.ImageUtils
4142
import java.io.ByteArrayOutputStream
4243
import java.security.MessageDigest
44+
import java.util.concurrent.TimeoutException
4345
import javax.inject.Inject
4446
import javax.inject.Provider
4547
import javax.inject.Singleton
@@ -62,6 +64,109 @@ class AttachmentProcessor @Inject constructor(
6264
val imageSize: IntSize
6365
)
6466

67+
suspend fun processAvatar(
68+
data: BufferedSource,
69+
dataSizeHint: Long?,
70+
): ProcessResult? {
71+
return when {
72+
AnimatedImageUtils.isAnimatedWebP(data) -> {
73+
val convertResult = runCatching {
74+
data.peek().use {
75+
processAnimatedWebP(
76+
data = it,
77+
maxImageResolution = MAX_AVATAR_SIZE_PX,
78+
timeoutMills = 5_000L,
79+
)
80+
}
81+
}
82+
83+
val processResult = when {
84+
convertResult.isSuccess -> convertResult.getOrThrow() ?: return null
85+
convertResult.exceptionOrNull() is TimeoutException -> {
86+
Log.w(TAG, "Animated WebP processing timed out, skipping")
87+
return null
88+
}
89+
90+
else -> throw convertResult.exceptionOrNull()!!
91+
}
92+
93+
val dataSize = dataSizeHint ?: data.readAll(blackholeSink())
94+
if (dataSize < processResult.data.size) {
95+
Log.d(
96+
TAG,
97+
"Avatar processing increased size from $dataSize to ${processResult.data.size}, skipped result"
98+
)
99+
return null
100+
} else {
101+
processResult
102+
}
103+
}
104+
105+
AnimatedImageUtils.isAnimatedGif(data) -> {
106+
val origSize = data.peek().inputStream().use(BitmapUtil::getDimensions)
107+
.let { pair -> IntSize(pair.first, pair.second) }
108+
109+
val targetSize = if (origSize.width <= MAX_AVATAR_SIZE_PX.width &&
110+
origSize.height <= MAX_AVATAR_SIZE_PX.height) {
111+
origSize
112+
} else {
113+
scaleToFit(origSize, MAX_AVATAR_SIZE_PX).first
114+
}
115+
116+
// First try to convert to webp in 5 seconds
117+
val convertResult = runCatching {
118+
data.peek().inputStream().use { input ->
119+
WebPUtils.encodeGifToWebP(
120+
input = input, 5_000L,
121+
targetWidth = targetSize.width, targetHeight = targetSize.height
122+
)
123+
}
124+
}
125+
126+
val processResult = when {
127+
convertResult.isSuccess -> ProcessResult(
128+
data = convertResult.getOrThrow(),
129+
mimeType = "image/webp",
130+
imageSize = targetSize
131+
)
132+
133+
convertResult.exceptionOrNull() is TimeoutException -> {
134+
Log.w(TAG, "WebP conversion timed out, falling back to GIF re-encoding")
135+
// Fallback to re-encoding as GIF
136+
processGif(data.peek(), MAX_AVATAR_SIZE_PX)
137+
}
138+
139+
else -> {
140+
throw convertResult.exceptionOrNull()!!
141+
}
142+
}
143+
144+
if (processResult != null) {
145+
val dataSize = dataSizeHint ?: data.readAll(blackholeSink())
146+
if (dataSize < processResult.data.size) {
147+
Log.d(
148+
TAG,
149+
"Avatar processing increased size from $dataSize to ${processResult.data.size}, skipped result"
150+
)
151+
return null
152+
}
153+
}
154+
155+
processResult
156+
}
157+
158+
else -> {
159+
// All static images
160+
val (data, size) = processStaticImage(data, MAX_AVATAR_SIZE_PX, Bitmap.CompressFormat.WEBP, 90)
161+
ProcessResult(
162+
data = data,
163+
mimeType = "image/webp",
164+
imageSize = size
165+
)
166+
}
167+
}
168+
}
169+
65170
/**
66171
* Process a file based on its mime type and the given constraints.
67172
*
@@ -97,7 +202,11 @@ class AttachmentProcessor @Inject constructor(
97202
return null
98203
}
99204

100-
return processAnimatedWebP(data = data, maxImageResolution)
205+
return processAnimatedWebP(
206+
data = data,
207+
maxImageResolution = maxImageResolution,
208+
timeoutMills = 30_000L
209+
)
101210
}
102211

103212
ImageUtils.isWebP(data) -> {
@@ -263,17 +372,15 @@ class AttachmentProcessor @Inject constructor(
263372

264373
private fun processAnimatedWebP(
265374
data: BufferedSource,
266-
maxImageResolution: IntSize?,
375+
maxImageResolution: IntSize,
376+
timeoutMills: Long,
267377
): ProcessResult? {
268378
val origSize = data.peek().inputStream().use(BitmapUtil::getDimensions)
269379
.let { pair -> IntSize(pair.first, pair.second) }
270380

271381
val targetSize: IntSize
272382

273-
if (maxImageResolution == null || (
274-
origSize.width <= maxImageResolution.width &&
275-
origSize.height <= maxImageResolution.height)
276-
) {
383+
if (origSize.width <= maxImageResolution.width && origSize.height <= maxImageResolution.height) {
277384
// No resizing needed hence no processing
278385
return null
279386
} else {
@@ -289,7 +396,7 @@ class AttachmentProcessor @Inject constructor(
289396
input = data.readByteArray(),
290397
targetWidth = targetSize.width,
291398
targetHeight = targetSize.height,
292-
timeoutMills = 10_000L,
399+
timeoutMills = timeoutMills,
293400
)
294401

295402
Log.d(

app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,13 @@ class AvatarReuploadWorker @AssistedInject constructor(
103103

104104
if ((lastUpdated != null && needsReProcessing(source)) || lastUpdated == null) {
105105
logAndToast("About to start reuploading avatar.")
106-
val attachment = attachmentProcessor.process(
106+
val attachment = attachmentProcessor.processAvatar(
107107
data = source,
108-
maxImageResolution = AttachmentProcessor.MAX_AVATAR_SIZE_PX,
109-
compressImage = true,
108+
dataSizeHint = null,
110109
) ?: return Result.failure()
111110

111+
Log.d(TAG, "Reuploading avatar with mimeType=${attachment.mimeType}, size=${attachment.imageSize}")
112+
112113
try {
113114
avatarUploadManager.get().uploadAvatar(
114115
pictureData = attachment.data,
@@ -185,6 +186,7 @@ class AvatarReuploadWorker @AssistedInject constructor(
185186
return true
186187
}
187188
val bounds = readImageBounds(source)
189+
Log.d(TAG, "Old avatar bounds: $bounds")
188190
return bounds.width > AttachmentProcessor.MAX_AVATAR_SIZE_PX.width
189191
|| bounds.height > AttachmentProcessor.MAX_AVATAR_SIZE_PX.height
190192
}

app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import android.Manifest
44
import android.content.ActivityNotFoundException
5+
import android.graphics.Bitmap
56
import android.net.Uri
67
import android.widget.Toast
78
import androidx.activity.result.ActivityResultLauncher
@@ -10,14 +11,20 @@ import androidx.activity.result.contract.ActivityResultContracts
1011
import androidx.activity.viewModels
1112
import androidx.compose.runtime.Composable
1213
import androidx.core.content.ContextCompat
14+
import androidx.lifecycle.lifecycleScope
1315
import com.canhub.cropper.CropImageContract
1416
import com.canhub.cropper.CropImageContractOptions
1517
import com.canhub.cropper.CropImageOptions
1618
import com.canhub.cropper.CropImageView
1719
import dagger.hilt.android.AndroidEntryPoint
20+
import kotlinx.coroutines.CancellationException
21+
import kotlinx.coroutines.Dispatchers
22+
import kotlinx.coroutines.launch
23+
import kotlinx.coroutines.withContext
1824
import network.loki.messenger.R
1925
import org.session.libsession.utilities.TextSecurePreferences
2026
import org.session.libsession.utilities.getColorFromAttr
27+
import org.session.libsignal.utilities.Log
2128
import org.thoughtcrime.securesms.FullComposeScreenLockActivity
2229
import org.thoughtcrime.securesms.permissions.Permissions
2330
import org.thoughtcrime.securesms.util.FileProviderUtil
@@ -59,7 +66,12 @@ class SettingsActivity : FullComposeScreenLockActivity() {
5966
viewModel.hideAvatarPickerOptions() // close the bottom sheet
6067

6168
val outputFile = Uri.fromFile(File(cacheDir, "cropped"))
62-
cropImage(viewModel.getTempFile()?.let(Uri::fromFile), outputFile)
69+
val inputFile = viewModel.getTempFile()?.let(Uri::fromFile)
70+
if (inputFile == null) {
71+
Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show()
72+
return@registerForActivityResult
73+
}
74+
cropImage(inputFile, outputFile)
6375
} else {
6476
Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_SHORT).show()
6577
}
@@ -118,30 +130,47 @@ class SettingsActivity : FullComposeScreenLockActivity() {
118130
.execute()
119131
}
120132

121-
private fun cropImage(inputFile: Uri?, outputFile: Uri?){
122-
onAvatarCropped.launch(
123-
CropImageContractOptions(
124-
uri = inputFile,
125-
cropImageOptions = CropImageOptions(
126-
guidelines = CropImageView.Guidelines.ON,
127-
aspectRatioX = 1,
128-
aspectRatioY = 1,
129-
fixAspectRatio = true,
130-
cropShape = CropImageView.CropShape.OVAL,
131-
customOutputUri = outputFile,
132-
allowRotation = true,
133-
allowFlipping = true,
134-
backgroundColor = imageScrim,
135-
toolbarColor = bgColor,
136-
activityBackgroundColor = bgColor,
137-
toolbarTintColor = txtColor,
138-
toolbarBackButtonColor = txtColor,
139-
toolbarTitleColor = txtColor,
140-
activityMenuIconColor = txtColor,
141-
activityMenuTextColor = txtColor,
142-
activityTitle = activityTitle
133+
private fun cropImage(inputFile: Uri, outputFile: Uri){
134+
lifecycleScope.launch {
135+
try {
136+
val inputType = withContext(Dispatchers.Default) {
137+
contentResolver.getType(inputFile)
138+
}
139+
140+
onAvatarCropped.launch(
141+
CropImageContractOptions(
142+
uri = inputFile,
143+
cropImageOptions = CropImageOptions(
144+
guidelines = CropImageView.Guidelines.ON,
145+
aspectRatioX = 1,
146+
aspectRatioY = 1,
147+
fixAspectRatio = true,
148+
cropShape = CropImageView.CropShape.OVAL,
149+
customOutputUri = outputFile,
150+
allowRotation = true,
151+
allowFlipping = true,
152+
backgroundColor = imageScrim,
153+
toolbarColor = bgColor,
154+
activityBackgroundColor = bgColor,
155+
toolbarTintColor = txtColor,
156+
toolbarBackButtonColor = txtColor,
157+
toolbarTitleColor = txtColor,
158+
activityMenuIconColor = txtColor,
159+
activityMenuTextColor = txtColor,
160+
activityTitle = activityTitle,
161+
outputCompressFormat = when {
162+
inputType?.startsWith("image/png") == true -> Bitmap.CompressFormat.PNG
163+
inputType?.startsWith("image/webp") == true -> Bitmap.CompressFormat.WEBP
164+
else -> Bitmap.CompressFormat.JPEG
165+
}
166+
)
167+
)
143168
)
144-
)
145-
)
169+
} catch (e: Exception) {
170+
if (e is CancellationException) throw e
171+
Log.e(TAG, "Error launching cropper", e)
172+
Toast.makeText(this@SettingsActivity, R.string.errorUnknown, Toast.LENGTH_SHORT).show()
173+
}
174+
}
146175
}
147176
}

app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,9 @@ class SettingsViewModel @Inject constructor(
203203
try {
204204
val bytes = context.contentResolver.openInputStream(uri)!!.source().buffer().use { data ->
205205
attachmentProcessor
206-
.process(
206+
.processAvatar(
207207
data = data,
208-
maxImageResolution = AttachmentProcessor.MAX_AVATAR_SIZE_PX,
209-
compressImage = true,
208+
dataSizeHint = null
210209
)
211210
?.data
212211
?: data.readByteArray()
@@ -224,7 +223,10 @@ class SettingsViewModel @Inject constructor(
224223
} catch (e: Exception) {
225224
Log.e(TAG, "Error reading avatar bytes", e)
226225
if (e !is CancellationException) {
227-
Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
226+
withContext(Dispatchers.Main) {
227+
Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG)
228+
.show()
229+
}
228230
}
229231
}
230232
}

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ kotlinVersion = "2.2.20"
2929
kryoVersion = "5.6.2"
3030
kspVersion = "2.2.10-2.0.2"
3131
legacySupportV13Version = "1.0.0"
32-
libsessionUtilAndroidVersion = "1.0.9"
32+
libsessionUtilAndroidVersion = "1.0.9-3-g53cc5ce"
3333
media3ExoplayerVersion = "1.8.0"
3434
mockitoCoreVersion = "5.20.0"
3535
navVersion = "2.9.4"

0 commit comments

Comments
 (0)