Create the LocalMedia off the main thread when picking or sharing media

LocalMediaFactory.createFromUri queries the ContentResolver and can decode the media, it was called on the main thread from the picker callbacks and when sharing a file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-12 14:33:18 +02:00
parent 865cca6764
commit 6049f25a3e
3 changed files with 53 additions and 38 deletions
@@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -110,6 +111,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@@ -145,6 +147,7 @@ class MessageComposerPresenter(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val contentScannerService: ContentScannerService, private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache, private val contentValidationCache: EventContentValidationCache,
private val dispatchers: CoroutineDispatchers,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@@ -193,22 +196,22 @@ class MessageComposerPresenter(
.collectAsState(initial = false) .collectAsState(initial = false)
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(uri, mimeType) localCoroutineScope.handlePickedMedia(uri, mimeType)
} }
val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris -> val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris ->
handlePickedMediaList(uris) localCoroutineScope.handlePickedMediaList(uris)
} }
val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris ->
handlePickedMediaList(uris, sendAsFile = true) localCoroutineScope.handlePickedMediaList(uris, sendAsFile = true)
} }
val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) localCoroutineScope.handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
} }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
handlePickedMedia(uri, MimeTypes.Jpeg) localCoroutineScope.handlePickedMedia(uri, MimeTypes.Jpeg)
} }
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
handlePickedMedia(uri, MimeTypes.Mp4) localCoroutineScope.handlePickedMedia(uri, MimeTypes.Mp4)
} }
val isFullScreen = rememberSaveable { val isFullScreen = rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
@@ -286,17 +289,16 @@ class MessageComposerPresenter(
} }
is MessageComposerEvent.SendUri -> { is MessageComposerEvent.SendUri -> {
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
sessionCoroutineScope.sendAttachment( sessionCoroutineScope.launch {
attachment = Attachment.Media( val localMedia = createLocalMedia(
localMedia = localMediaFactory.createFromUri( uri = event.uri,
uri = event.uri, mimeType = null,
mimeType = null, )
name = null, sendAttachment(
formattedFileSize = null attachment = Attachment.Media(localMedia),
), inReplyToEventId = inReplyToEventId,
), )
inReplyToEventId = inReplyToEventId, }
)
resetComposerModeAfterAttaching() resetComposerModeAfterAttaching()
} }
@@ -640,17 +642,28 @@ class MessageComposerPresenter(
} }
} }
private fun handlePickedMedia( // Creating the LocalMedia queries the ContentResolver and can decode the media, so do it off the main thread
uri: Uri?, private suspend fun createLocalMedia(
mimeType: String? = null, uri: Uri,
sendAsFile: Boolean = false, mimeType: String?,
) { ) = withContext(dispatchers.io) {
uri ?: return localMediaFactory.createFromUri(
val localMedia = localMediaFactory.createFromUri(
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,
name = null, name = null,
formattedFileSize = null formattedFileSize = null,
)
}
private fun CoroutineScope.handlePickedMedia(
uri: Uri?,
mimeType: String? = null,
sendAsFile: Boolean = false,
) = launch {
uri ?: return@launch
val localMedia = createLocalMedia(
uri = uri,
mimeType = mimeType,
) )
val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile) val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
@@ -659,7 +672,7 @@ class MessageComposerPresenter(
resetComposerModeAfterAttaching() resetComposerModeAfterAttaching()
} }
private fun handlePickedMediaList( private fun CoroutineScope.handlePickedMediaList(
uris: List<Uri>, uris: List<Uri>,
sendAsFile: Boolean = false, sendAsFile: Boolean = false,
) { ) {
@@ -668,19 +681,19 @@ class MessageComposerPresenter(
handlePickedMedia(uris.first(), sendAsFile = sendAsFile) handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
return return
} }
val attachments = uris.map { uri -> launch {
val localMedia = localMediaFactory.createFromUri( val attachments = uris.map { uri ->
uri = uri, val localMedia = createLocalMedia(
mimeType = null, uri = uri,
name = null, mimeType = null,
formattedFileSize = null, )
) Attachment.Media(localMedia, sendAsFile = sendAsFile)
Attachment.Media(localMedia, sendAsFile = sendAsFile) }.toImmutableList()
}.toImmutableList() val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
resetComposerModeAfterAttaching() resetComposerModeAfterAttaching()
}
} }
private fun resetComposerModeAfterAttaching() { private fun resetComposerModeAfterAttaching() {
@@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest {
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
contentScannerService = { _, _ -> }, contentScannerService = { _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(), contentValidationCache = InMemoryEventContentValidationCache(),
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled showTextFormatting = isRichTextEditorEnabled
@@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
contentScannerService = { _, _ -> }, contentScannerService = { _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(), contentValidationCache = InMemoryEventContentValidationCache(),
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled showTextFormatting = isRichTextEditorEnabled