Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
*
Expand Down Expand Up @@ -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) -> {
Expand Down Expand Up @@ -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 {
Expand All @@ -276,7 +383,7 @@ class AttachmentProcessor @Inject constructor(
input = data.readByteArray(),
targetWidth = targetSize.width,
targetHeight = targetSize.height,
timeoutMills = 10_000L,
timeoutMills = timeoutMills,
)

Log.d(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down