Merge pull request #7901 from element-hq/feature/bma/mainThreadBlocking

Avoid blocking the main thread in several places
This commit is contained in:
Benoit Marty
2026-10-14 16:39:14 +02:00
committed by GitHub
13 changed files with 117 additions and 74 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.libraries.architecture.AsyncAction
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.mimetype.MimeTypes
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.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@@ -145,6 +147,7 @@ class MessageComposerPresenter(
private val featureFlagService: FeatureFlagService,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
private val dispatchers: CoroutineDispatchers,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
@@ -190,22 +193,22 @@ class MessageComposerPresenter(
.collectAsState(initial = false)
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(uri, mimeType)
localCoroutineScope.handlePickedMedia(uri, mimeType)
}
val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris ->
handlePickedMediaList(uris)
localCoroutineScope.handlePickedMediaList(uris)
}
val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris ->
handlePickedMediaList(uris, sendAsFile = true)
localCoroutineScope.handlePickedMediaList(uris, sendAsFile = true)
}
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 ->
handlePickedMedia(uri, MimeTypes.Jpeg)
localCoroutineScope.handlePickedMedia(uri, MimeTypes.Jpeg)
}
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
handlePickedMedia(uri, MimeTypes.Mp4)
localCoroutineScope.handlePickedMedia(uri, MimeTypes.Mp4)
}
val isFullScreen = rememberSaveable {
mutableStateOf(false)
@@ -283,17 +286,16 @@ class MessageComposerPresenter(
}
is MessageComposerEvent.SendUri -> {
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
sessionCoroutineScope.sendAttachment(
attachment = Attachment.Media(
localMedia = localMediaFactory.createFromUri(
uri = event.uri,
mimeType = null,
name = null,
formattedFileSize = null
),
),
inReplyToEventId = inReplyToEventId,
)
sessionCoroutineScope.launch {
val localMedia = createLocalMedia(
uri = event.uri,
mimeType = null,
)
sendAttachment(
attachment = Attachment.Media(localMedia),
inReplyToEventId = inReplyToEventId,
)
}
resetComposerModeAfterAttaching()
}
@@ -637,17 +639,28 @@ class MessageComposerPresenter(
}
}
private fun handlePickedMedia(
uri: Uri?,
mimeType: String? = null,
sendAsFile: Boolean = false,
) {
uri ?: return
val localMedia = localMediaFactory.createFromUri(
// Creating the LocalMedia queries the ContentResolver and can decode the media, so do it off the main thread
private suspend fun createLocalMedia(
uri: Uri,
mimeType: String?,
) = withContext(dispatchers.io) {
localMediaFactory.createFromUri(
uri = uri,
mimeType = mimeType,
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 inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
@@ -656,7 +669,7 @@ class MessageComposerPresenter(
resetComposerModeAfterAttaching()
}
private fun handlePickedMediaList(
private fun CoroutineScope.handlePickedMediaList(
uris: List<Uri>,
sendAsFile: Boolean = false,
) {
@@ -665,19 +678,19 @@ class MessageComposerPresenter(
handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
return
}
val attachments = uris.map { uri ->
val localMedia = localMediaFactory.createFromUri(
uri = uri,
mimeType = null,
name = null,
formattedFileSize = null,
)
Attachment.Media(localMedia, sendAsFile = sendAsFile)
}.toImmutableList()
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
launch {
val attachments = uris.map { uri ->
val localMedia = createLocalMedia(
uri = uri,
mimeType = null,
)
Attachment.Media(localMedia, sendAsFile = sendAsFile)
}.toImmutableList()
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
resetComposerModeAfterAttaching()
resetComposerModeAfterAttaching()
}
}
private fun resetComposerModeAfterAttaching() {
@@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest {
featureFlagService = featureFlagService,
contentScannerService = { _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(),
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
@@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
featureFlagService = featureFlagService,
contentScannerService = { _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(),
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
@@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@@ -76,6 +78,7 @@ class NotificationSettingsPresenter(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
) : Presenter<NotificationSettingsState> {
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
@@ -111,16 +114,19 @@ class NotificationSettingsPresenter(
}
// List of PushProvider -> Distributor
val distributors = remember {
pushService.getAvailablePushProviders()
.flatMap { pushProvider ->
pushProvider.getDistributors().map { distributor ->
pushProvider to distributor
// Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread
val distributors by produceState(emptyList<Pair<PushProvider, Distributor>>()) {
value = withContext(dispatchers.io) {
pushService.getAvailablePushProviders()
.flatMap { pushProvider ->
pushProvider.getDistributors().map { distributor ->
pushProvider to distributor
}
}
}
}
}
// List of Distributors
val availableDistributors = remember {
val availableDistributors = remember(distributors) {
distributors.map { it.second }.toImmutableList()
}
@@ -40,6 +40,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
@@ -1176,6 +1177,7 @@ class NotificationSettingsPresenterTest {
stringProvider = FakeStringProvider(),
sessionCoroutineScope = backgroundScope,
featureFlagService = featureFlagService,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
)
}
}