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
@@ -217,8 +217,8 @@ class ShareLocationPresenter(
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
val inReplyToEventId = replyMode?.eventId val inReplyToEventId = replyMode?.eventId
val geoUri = event.location.toGeoUri() val geoUri = event.location.toGeoUri()
getTimeline().flatMap { withTimeline { timeline ->
it.sendLocation( timeline.sendLocation(
body = generateBody(geoUri), body = generateBody(geoUri),
geoUri = geoUri, geoUri = geoUri,
description = null, description = null,
@@ -237,10 +237,22 @@ class ShareLocationPresenter(
) )
} }
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. 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) { return when (timelineMode) {
is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) is Timeline.Mode.Thread -> {
else -> Result.success(room.liveTimeline) room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
.flatMap { threadedTimeline ->
threadedTimeline.use { block(it) }
}
}
else -> block(room.liveTimeline)
} }
} }
} }
@@ -708,6 +708,45 @@ class ShareLocationPresenterTest {
} }
} }
@Test
fun `ShareStaticLocation in a thread closes the created timeline`() = runTest {
val threadedTimeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply {
sendLocationLambda = { _, _, _, _, _, _ -> Result.success(Unit) }
}
val liveTimeline = FakeTimeline()
val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(threadedTimeline) },
)
val shareLocationPresenter = createShareLocationPresenter(
joinedRoom = joinedRoom,
timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f),
isPinned = false,
)
)
advanceUntilIdle()
assertThat(threadedTimeline.closeCounter).isEqualTo(1)
assertThat(liveTimeline.closeCounter).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1) skipItems(1)
return awaitItem() return awaitItem()
@@ -23,8 +23,6 @@ import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@AssistedInject @AssistedInject
@@ -40,17 +38,16 @@ class PollRepository(
): PollRepository ): PollRepository
} }
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatchingExceptions { suspend fun getPoll(eventId: EventId): Result<PollContent> = withTimeline { timeline ->
getTimelineProvider() runCatchingExceptions {
.getOrThrow() timeline.timelineItems
.getActiveTimeline() .first()
.timelineItems .asSequence()
.first() .filterIsInstance<MatrixTimelineItem.Event>()
.asSequence() .first { it.eventId == eventId }
.filterIsInstance<MatrixTimelineItem.Event>() .event
.first { it.eventId == eventId } .content as PollContent
.event }
.content as PollContent
} }
suspend fun savePoll( suspend fun savePoll(
@@ -59,52 +56,49 @@ class PollRepository(
answers: List<String>, answers: List<String>,
pollKind: PollKind, pollKind: PollKind,
maxSelections: Int, maxSelections: Int,
): Result<Unit> = when (existingPollId) { ): Result<Unit> = withTimeline { timeline ->
null -> getTimelineProvider().flatMap { timelineProvider -> when (existingPollId) {
timelineProvider null -> timeline.createPoll(
.getActiveTimeline() question = question,
.createPoll( answers = answers,
question = question, maxSelections = maxSelections,
answers = answers, pollKind = pollKind,
maxSelections = maxSelections, )
pollKind = pollKind, else -> timeline.editPoll(
) pollStartId = existingPollId,
} question = question,
else -> getTimelineProvider().flatMap { timelineProvider -> answers = answers,
timelineProvider.getActiveTimeline() maxSelections = maxSelections,
.editPoll( pollKind = pollKind,
pollStartId = existingPollId, )
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
} }
} }
suspend fun deletePoll( suspend fun deletePoll(
pollStartId: EventId, pollStartId: EventId,
): Result<Unit> = ): Result<Unit> = withTimeline { timeline ->
getTimelineProvider().flatMap { timelineProvider -> timeline.redactEvent(
timelineProvider.getActiveTimeline() eventOrTransactionId = pollStartId.toEventOrTransactionId(),
.redactEvent( reason = null,
eventOrTransactionId = pollStartId.toEventOrTransactionId(), )
reason = null, }
)
}
private suspend fun getTimelineProvider(): Result<TimelineProvider> { /**
* 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. For the other modes, the active timeline of
* [defaultTimelineProvider] is used, and is not closed here since it is owned elsewhere.
*/
private suspend fun <T> withTimeline(block: suspend (Timeline) -> Result<T>): Result<T> {
return when (timelineMode) { return when (timelineMode) {
is Timeline.Mode.Thread -> { is Timeline.Mode.Thread -> {
val threadedTimelineResult = room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
threadedTimelineResult.map { threadedTimeline -> .flatMap { threadedTimeline ->
object : TimelineProvider { threadedTimeline.use { block(it) }
private val flow = MutableStateFlow<Timeline?>(threadedTimeline)
override fun activeTimelineFlow(): StateFlow<Timeline?> = flow
} }
}
} }
else -> Result.success(defaultTimelineProvider) else -> block(defaultTimelineProvider.getActiveTimeline())
} }
} }
} }
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.data
import com.google.common.truth.Truth.assertThat
import io.element.android.features.poll.impl.aPollTimelineItems
import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PollRepositoryTest {
private val pollContent = anOngoingPollContent()
@Test
fun `given a thread, getPoll closes the created timeline`() = runTest {
val threadedTimeline = aThreadedTimeline()
val room = aRoomCreating(threadedTimeline)
val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID))
val result = sut.getPoll(AN_EVENT_ID)
assertThat(result.getOrNull()).isEqualTo(pollContent)
assertThat(threadedTimeline.closeCounter).isEqualTo(1)
assertThat((room.liveTimeline as FakeTimeline).closeCounter).isEqualTo(0)
}
@Test
fun `given a thread, savePoll closes the created timeline`() = runTest {
val threadedTimeline = aThreadedTimeline().apply {
createPollLambda = { _, _, _, _ -> Result.success(Unit) }
}
val room = aRoomCreating(threadedTimeline)
val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID))
val result = sut.savePoll(
existingPollId = null,
question = "A question",
answers = listOf("Yes", "No"),
pollKind = PollKind.Disclosed,
maxSelections = 1,
)
assertThat(result.isSuccess).isTrue()
assertThat(threadedTimeline.closeCounter).isEqualTo(1)
}
@Test
fun `given a thread, savePoll of an existing poll closes the created timeline`() = runTest {
val threadedTimeline = aThreadedTimeline().apply {
editPollLambda = { _, _, _, _, _ -> Result.success(Unit) }
}
val room = aRoomCreating(threadedTimeline)
val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID))
val result = sut.savePoll(
existingPollId = AN_EVENT_ID,
question = "A question",
answers = listOf("Yes", "No"),
pollKind = PollKind.Disclosed,
maxSelections = 1,
)
assertThat(result.isSuccess).isTrue()
assertThat(threadedTimeline.closeCounter).isEqualTo(1)
}
@Test
fun `given a thread, deletePoll closes the created timeline`() = runTest {
val threadedTimeline = aThreadedTimeline().apply {
redactEventLambda = { _, _ -> Result.success(Unit) }
}
val room = aRoomCreating(threadedTimeline)
val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID))
val result = sut.deletePoll(AN_EVENT_ID)
assertThat(result.isSuccess).isTrue()
assertThat(threadedTimeline.closeCounter).isEqualTo(1)
}
@Test
fun `given a thread, the created timeline is closed even if the operation fails`() = runTest {
val error = RuntimeException("Failed to send the poll")
val threadedTimeline = aThreadedTimeline().apply {
createPollLambda = { _, _, _, _ -> Result.failure(error) }
}
val room = aRoomCreating(threadedTimeline)
val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID))
val result = sut.savePoll(
existingPollId = null,
question = "A question",
answers = listOf("Yes", "No"),
pollKind = PollKind.Disclosed,
maxSelections = 1,
)
assertThat(result.exceptionOrNull()).isEqualTo(error)
assertThat(threadedTimeline.closeCounter).isEqualTo(1)
}
@Test
fun `given a thread, if the timeline cannot be created the error is returned`() = runTest {
val error = RuntimeException("Failed to create the timeline")
val room = FakeJoinedRoom(
liveTimeline = aLiveTimeline(),
createTimelineResult = { Result.failure(error) },
)
val sut = createPollRepository(room, Timeline.Mode.Thread(A_THREAD_ID))
val result = sut.deletePoll(AN_EVENT_ID)
assertThat(result.exceptionOrNull()).isEqualTo(error)
}
@Test
fun `given a live timeline, the timeline is not closed`() = runTest {
val liveTimeline = aLiveTimeline().apply {
redactEventLambda = { _, _ -> Result.success(Unit) }
}
val room = FakeJoinedRoom(liveTimeline = liveTimeline)
val sut = createPollRepository(room, Timeline.Mode.Live)
val result = sut.deletePoll(AN_EVENT_ID)
assertThat(result.isSuccess).isTrue()
assertThat(liveTimeline.closeCounter).isEqualTo(0)
}
private fun aLiveTimeline() = FakeTimeline(
timelineItems = aPollTimelineItems(mapOf(AN_EVENT_ID to pollContent)),
)
private fun aThreadedTimeline() = FakeTimeline(
timelineItems = aPollTimelineItems(mapOf(AN_EVENT_ID to pollContent)),
mode = Timeline.Mode.Thread(A_THREAD_ID),
)
private fun aRoomCreating(threadedTimeline: FakeTimeline) = FakeJoinedRoom(
liveTimeline = aLiveTimeline(),
createTimelineResult = { params ->
assertThat(params).isEqualTo(CreateTimelineParams.Threaded(A_THREAD_ID))
Result.success(threadedTimeline)
},
)
private fun createPollRepository(
room: FakeJoinedRoom,
timelineMode: Timeline.Mode,
) = PollRepository(
room = room,
defaultTimelineProvider = LiveTimelineProvider(room),
timelineMode = timelineMode,
)
}
@@ -100,9 +100,9 @@ class DefaultMediaSender(
inReplyToEventId: EventId?, inReplyToEventId: EventId?,
): Result<Unit> { ): Result<Unit> {
val mediaLogId = mediaId(mediaUploadInfo.file) val mediaLogId = mediaId(mediaUploadInfo.file)
return getTimeline().flatMap { return withTimeline { timeline ->
Timber.d("Started sending media $mediaLogId using timeline: ${it.mode}") Timber.d("Started sending media $mediaLogId using timeline: ${timeline.mode}")
it.sendMedia( timeline.sendMedia(
uploadInfo = mediaUploadInfo, uploadInfo = mediaUploadInfo,
caption = caption, caption = caption,
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
@@ -128,12 +128,14 @@ class DefaultMediaSender(
mediaOptimizationConfig = mediaOptimizationConfig, mediaOptimizationConfig = mediaOptimizationConfig,
) )
.flatMapCatching { info -> .flatMapCatching { info ->
getTimeline().getOrThrow().sendMedia( withTimeline { timeline ->
uploadInfo = info, timeline.sendMedia(
caption = caption, uploadInfo = info,
formattedCaption = formattedCaption, caption = caption,
inReplyToEventId = inReplyToEventId, formattedCaption = formattedCaption,
) inReplyToEventId = inReplyToEventId,
)
}
} }
.handleSendResult(mediaId(uri)) .handleSendResult(mediaId(uri))
} }
@@ -158,12 +160,14 @@ class DefaultMediaSender(
audioInfo = audioInfo, audioInfo = audioInfo,
waveform = waveForm, waveform = waveForm,
) )
getTimeline().getOrThrow().sendMedia( withTimeline { timeline ->
uploadInfo = newInfo, timeline.sendMedia(
caption = null, uploadInfo = newInfo,
formattedCaption = null, caption = null,
inReplyToEventId = inReplyToEventId, formattedCaption = null,
) inReplyToEventId = inReplyToEventId,
)
}
} }
.handleSendResult(mediaId(uri)) .handleSendResult(mediaId(uri))
} }
@@ -176,7 +180,7 @@ class DefaultMediaSender(
): Result<Unit> { ): Result<Unit> {
val galleryLogId = "gallery[${mediaUploadInfos.size} items]" val galleryLogId = "gallery[${mediaUploadInfos.size} items]"
Timber.d("Sending $galleryLogId") Timber.d("Sending $galleryLogId")
return getTimeline().flatMap { timeline -> return withTimeline { timeline ->
val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo() } val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo() }
timeline.sendGallery( timeline.sendGallery(
items = galleryItems, items = galleryItems,
@@ -184,10 +188,10 @@ class DefaultMediaSender(
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId, inReplyToEventId = inReplyToEventId,
) )
.flatMapCatching { uploadHandler ->
uploadHandler.await()
}
} }
.flatMapCatching { uploadHandler ->
uploadHandler.await()
}
.handleSendResult(galleryLogId) .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) { return when (timelineMode) {
is Timeline.Mode.Thread -> { is Timeline.Mode.Thread -> {
room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId)) 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.media.ImageInfo
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline 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.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
@@ -156,14 +157,52 @@ class DefaultMediaSenderTest : RobolectricTest() {
sendFileResult.assertions().isCalledOnce() 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( private fun createDefaultMediaSender(
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(), preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
room: JoinedRoom = FakeJoinedRoom(), room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig }, mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig },
) = DefaultMediaSender( ) = DefaultMediaSender(
preProcessor = preProcessor, preProcessor = preProcessor,
room = room, room = room,
timelineMode = Timeline.Mode.Live, timelineMode = timelineMode,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
) )
} }