Close the threaded timeline created by PollRepository

PollRepository created a dedicated timeline for each operation performed in a
thread, and never closed it, leaking SDK resources. Replace getTimelineProvider()
with a scoped withTimeline() helper, so that the created timeline is closed once
the operation completes, whether it succeeds, fails or gets cancelled.

The live timeline is left untouched, since it is owned by its room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-09-14 15:17:24 +02:00
parent 8cd3750cde
commit e2fb2962f6
2 changed files with 211 additions and 49 deletions
@@ -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.item.event.PollContent
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
@AssistedInject
@@ -40,17 +38,16 @@ class PollRepository(
): PollRepository
}
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatchingExceptions {
getTimelineProvider()
.getOrThrow()
.getActiveTimeline()
.timelineItems
.first()
.asSequence()
.filterIsInstance<MatrixTimelineItem.Event>()
.first { it.eventId == eventId }
.event
.content as PollContent
suspend fun getPoll(eventId: EventId): Result<PollContent> = withTimeline { timeline ->
runCatchingExceptions {
timeline.timelineItems
.first()
.asSequence()
.filterIsInstance<MatrixTimelineItem.Event>()
.first { it.eventId == eventId }
.event
.content as PollContent
}
}
suspend fun savePoll(
@@ -59,52 +56,49 @@ class PollRepository(
answers: List<String>,
pollKind: PollKind,
maxSelections: Int,
): Result<Unit> = when (existingPollId) {
null -> getTimelineProvider().flatMap { timelineProvider ->
timelineProvider
.getActiveTimeline()
.createPoll(
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
}
else -> getTimelineProvider().flatMap { timelineProvider ->
timelineProvider.getActiveTimeline()
.editPoll(
pollStartId = existingPollId,
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
): Result<Unit> = withTimeline { timeline ->
when (existingPollId) {
null -> timeline.createPoll(
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
else -> timeline.editPoll(
pollStartId = existingPollId,
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
}
}
suspend fun deletePoll(
pollStartId: EventId,
): Result<Unit> =
getTimelineProvider().flatMap { timelineProvider ->
timelineProvider.getActiveTimeline()
.redactEvent(
eventOrTransactionId = pollStartId.toEventOrTransactionId(),
reason = null,
)
}
): Result<Unit> = withTimeline { timeline ->
timeline.redactEvent(
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) {
is Timeline.Mode.Thread -> {
val threadedTimelineResult = room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
threadedTimelineResult.map { threadedTimeline ->
object : TimelineProvider {
private val flow = MutableStateFlow<Timeline?>(threadedTimeline)
override fun activeTimelineFlow(): StateFlow<Timeline?> = flow
room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
.flatMap { threadedTimeline ->
threadedTimeline.use { block(it) }
}
}
}
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,
)
}