Merge pull request #7705 from element-hq/bma/fixLeak

Close the threaded timelines created to create a poll, share a location or send media
This commit is contained in:
Benoit Marty
2026-09-21 10:18:37 +02:00
committed by GitHub
6 changed files with 343 additions and 76 deletions
@@ -100,9 +100,9 @@ class DefaultMediaSender(
inReplyToEventId: EventId?,
): Result<Unit> {
val mediaLogId = mediaId(mediaUploadInfo.file)
return getTimeline().flatMap {
Timber.d("Started sending media $mediaLogId using timeline: ${it.mode}")
it.sendMedia(
return withTimeline { timeline ->
Timber.d("Started sending media $mediaLogId using timeline: ${timeline.mode}")
timeline.sendMedia(
uploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = formattedCaption,
@@ -128,12 +128,14 @@ class DefaultMediaSender(
mediaOptimizationConfig = mediaOptimizationConfig,
)
.flatMapCatching { info ->
getTimeline().getOrThrow().sendMedia(
uploadInfo = info,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
withTimeline { timeline ->
timeline.sendMedia(
uploadInfo = info,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
}
.handleSendResult(mediaId(uri))
}
@@ -158,12 +160,14 @@ class DefaultMediaSender(
audioInfo = audioInfo,
waveform = waveForm,
)
getTimeline().getOrThrow().sendMedia(
uploadInfo = newInfo,
caption = null,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
)
withTimeline { timeline ->
timeline.sendMedia(
uploadInfo = newInfo,
caption = null,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
)
}
}
.handleSendResult(mediaId(uri))
}
@@ -176,7 +180,7 @@ class DefaultMediaSender(
): Result<Unit> {
val galleryLogId = "gallery[${mediaUploadInfos.size} items]"
Timber.d("Sending $galleryLogId")
return getTimeline().flatMap { timeline ->
return withTimeline { timeline ->
val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo() }
timeline.sendGallery(
items = galleryItems,
@@ -184,10 +188,10 @@ class DefaultMediaSender(
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
.flatMapCatching { uploadHandler ->
uploadHandler.await()
}
}
.flatMapCatching { uploadHandler ->
uploadHandler.await()
}
.handleSendResult(galleryLogId)
}
@@ -269,12 +273,23 @@ class DefaultMediaSender(
}
}
private suspend fun getTimeline(): Result<Timeline> {
/**
* Invokes [block] with the [Timeline] matching [timelineMode].
*
* For a thread, a dedicated timeline is created and closed once [block] returns, since such a
* timeline owns SDK resources. [block] must then perform the whole sending, including waiting
* for the media upload to complete. For the other modes, the live timeline is used, and is not
* closed here since it is owned by its room.
*/
private suspend fun <T> withTimeline(block: suspend (Timeline) -> Result<T>): Result<T> {
return when (timelineMode) {
is Timeline.Mode.Thread -> {
room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId))
.flatMap { threadedTimeline ->
threadedTimeline.use { block(it) }
}
}
else -> Result.success(room.liveTimeline)
else -> block(room.liveTimeline)
}
}
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
@@ -156,14 +157,52 @@ class DefaultMediaSenderTest : RobolectricTest() {
sendFileResult.assertions().isCalledOnce()
}
@Test
fun `given a thread, the created timeline is closed once the media is sent`() = runTest {
val threadedTimeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply {
sendFileLambda = { _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) }
}
val liveTimeline = FakeTimeline()
val room = FakeJoinedRoom(
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(threadedTimeline) },
)
val sender = createDefaultMediaSender(
room = room,
timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
)
val uri = Uri.parse("content://image.jpg")
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
assertThat(result.isSuccess).isTrue()
assertThat(threadedTimeline.closeCounter).isEqualTo(1)
assertThat(liveTimeline.closeCounter).isEqualTo(0)
}
@Test
fun `given a live timeline, the timeline is not closed once the media is sent`() = runTest {
val liveTimeline = FakeTimeline().apply {
sendFileLambda = { _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) }
}
val sender = createDefaultMediaSender(room = FakeJoinedRoom(liveTimeline = liveTimeline))
val uri = Uri.parse("content://image.jpg")
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
assertThat(result.isSuccess).isTrue()
assertThat(liveTimeline.closeCounter).isEqualTo(0)
}
private fun createDefaultMediaSender(
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig },
) = DefaultMediaSender(
preProcessor = preProcessor,
room = room,
timelineMode = Timeline.Mode.Live,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}