From 704ddc9132e80d12050b9899f6963b65d862516b Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 15 Apr 2026 22:06:00 +0200 Subject: [PATCH] Update live location shares when reaching timeout (before actual stop event) --- .../impl/show/ShowLocationPresenter.kt | 2 +- .../impl/show/ShowLocationPresenterTest.kt | 76 ++++----- .../components/TimelineItemEventRow.kt | 9 +- .../event/TimelineItemEventContentView.kt | 13 +- .../event/TimelineItemLocationView.kt | 10 +- .../event/TimelineItemContentFactory.kt | 3 +- .../event/TimelineItemEventContentProvider.kt | 4 +- .../event/TimelineItemLocationContent.kt | 47 +++++- .../TimelineItemLocationContentProvider.kt | 4 + .../fixtures/TimelineItemsFactoryFixtures.kt | 3 + .../TimelineItemContentMessageFactoryTest.kt | 3 +- .../api/timeline/item/event/EventContent.kt | 4 +- .../matrix/impl/room/JoinedRustRoom.kt | 3 +- .../location/TimedLiveLocationSharesFlow.kt | 56 +++++++ .../item/event/TimelineEventContentMapper.kt | 2 +- .../TimedLiveLocationSharesFlowTest.kt | 148 ++++++++++++++++++ ...nticsNodeInteractionsProviderExtensions.kt | 8 + 17 files changed, 331 insertions(+), 64 deletions(-) create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index e23baf1a43..10501409fe 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -142,7 +142,7 @@ class ShowLocationPresenter( val member = members.find { it.userId == share.userId } val displayName = member?.getBestName() ?: share.userId.value val avatarUrl = member?.avatarUrl - val relativeTime = dateFormatter.format(timestamp = share.lastLocation?.timestamp, mode = DateFormatterMode.Full, useRelative = true) + val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true) val formattedTimestamp = stringProvider.getString( CommonStrings.screen_static_location_sheet_timestamp_description, relativeTime diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 81ec465686..c5120928dc 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -24,19 +24,21 @@ import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.room.location.LastLocation import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeJoinedRoom -import io.element.android.libraries.matrix.test.room.FakeLiveLocationShareService import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class ShowLocationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -330,7 +332,7 @@ class ShowLocationPresenterTest { @Test fun `live mode emits empty location shares initially`() = runTest { val presenter = createShowLocationPresenter( - mode = ShowLocationMode.Live, + mode = ShowLocationMode.Live(senderId = UserId("@alice:matrix.org")), joinedRoom = FakeJoinedRoom(), ) presenter.test { @@ -345,22 +347,13 @@ class ShowLocationPresenterTest { val userId = UserId("@bob:matrix.org") val liveSharesFlow = MutableStateFlow( listOf( - LiveLocationShare( - userId = userId, - lastGeoUri = "geo:48.8584,2.2945", - lastTimestamp = 1234567890L, - isLive = true, - ) - ) - ) - val fakeRoom = FakeJoinedRoom( - liveLocationShareService = FakeLiveLocationShareService( - liveLocationSharesFlow = liveSharesFlow + aLiveLocationShare(userId = userId) ) ) + val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow) val presenter = createShowLocationPresenter( - mode = ShowLocationMode.Live, + mode = ShowLocationMode.Live(senderId = userId), joinedRoom = fakeRoom, ) presenter.test { @@ -384,28 +377,14 @@ class ShowLocationPresenterTest { val invalidUserId = UserId("@bob:matrix.org") val liveSharesFlow = MutableStateFlow( listOf( - LiveLocationShare( - userId = validUserId, - lastGeoUri = "geo:48.8584,2.2945", - lastTimestamp = 1234567890L, - isLive = true, - ), - LiveLocationShare( - userId = invalidUserId, - lastGeoUri = "invalid-geo-uri", - lastTimestamp = 1234567890L, - isLive = true, - ), - ) - ) - val fakeRoom = FakeJoinedRoom( - liveLocationShareService = FakeLiveLocationShareService( - liveLocationSharesFlow = liveSharesFlow + aLiveLocationShare(userId = validUserId), + aLiveLocationShare(userId = invalidUserId, geoUri = "invalid-geo-uri"), ) ) + val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow) val presenter = createShowLocationPresenter( - mode = ShowLocationMode.Live, + mode = ShowLocationMode.Live(senderId = validUserId), joinedRoom = fakeRoom, ) presenter.test { @@ -423,14 +402,10 @@ class ShowLocationPresenterTest { fun `live mode updates when shares change`() = runTest { val userId = UserId("@bob:matrix.org") val liveSharesFlow = MutableStateFlow(emptyList()) - val fakeRoom = FakeJoinedRoom( - liveLocationShareService = FakeLiveLocationShareService( - liveLocationSharesFlow = liveSharesFlow - ) - ) + val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow) val presenter = createShowLocationPresenter( - mode = ShowLocationMode.Live, + mode = ShowLocationMode.Live(senderId = userId), joinedRoom = fakeRoom, ) presenter.test { @@ -440,12 +415,7 @@ class ShowLocationPresenterTest { // Emit a new live share liveSharesFlow.value = listOf( - LiveLocationShare( - userId = userId, - lastGeoUri = "geo:48.8584,2.2945", - lastTimestamp = 1234567890L, - isLive = true, - ) + aLiveLocationShare(userId = userId) ) val updatedState = awaitItem() @@ -494,3 +464,21 @@ class ShowLocationPresenterTest { } } } + +private fun aLiveLocationShare( + userId: UserId, + geoUri: String = "geo:48.8584,2.2945", + timestamp: Long = 1234567890L, + endTimestamp: Long = Long.MAX_VALUE, + assetType: AssetType = AssetType.SENDER, +): LiveLocationShare { + return LiveLocationShare( + userId = userId, + lastLocation = LastLocation( + geoUri = geoUri, + timestamp = timestamp, + assetType = assetType, + ), + endTimestamp = endTimestamp, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index b0b1e0c755..976fa3c17e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -78,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.mustBeProtected @@ -777,7 +778,13 @@ private fun MessageEventBubbleContent( is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay is TimelineItemStickerContent -> TimestampPosition.Overlay - is TimelineItemLocationContent -> if (content.hideTimestamp) TimestampPosition.Hidden else TimestampPosition.Overlay + is TimelineItemLocationContent -> { + val content = content.ensureActiveLiveLocation() + val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live && + content.mode.isActive && + content.mode.canStop + if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay + } is TimelineItemPollContent -> TimestampPosition.Below else -> TimestampPosition.Default } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 1de73f3658..2044796889 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.wysiwyg.link.Link @@ -71,11 +72,13 @@ fun TimelineItemEventContentView( onContentLayoutChange = onContentLayoutChange, modifier = modifier ) - is TimelineItemLocationContent -> TimelineItemLocationView( - content = content, - onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) }, - modifier = modifier - ) + is TimelineItemLocationContent -> { + TimelineItemLocationView( + content = content.ensureActiveLiveLocation(), + onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) }, + modifier = modifier + ) + } is TimelineItemImageContent -> TimelineItemImageView( content = content, hideMediaContent = hideMediaContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index c9e3152afb..1c35216c38 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -32,12 +33,14 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.location.api.StaticMapView import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider +import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemLocationView( @@ -121,7 +124,12 @@ private fun LiveLocationOverlay( Spacer(Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = if (mode.isActive) "Live location" else "Live location ended", + text = if (mode.isActive) { + stringResource(CommonStrings.common_live_location) + } else { + stringResource(CommonStrings.common_live_location_ended) + }, + style = ElementTheme.typography.fontBodySmMedium, color = ElementTheme.colors.textPrimary, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 8b81ae0906..fcb346ecd7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -111,7 +111,7 @@ class TimelineItemContentFactory( }.lastOrNull() val endsAt = dateFormatter.format( - timestamp = itemContent.endsAt, + timestamp = itemContent.endTimestamp, mode = DateFormatterMode.TimeOnly ) // Always create content, location can be null for "loading/waiting" state @@ -124,6 +124,7 @@ class TimelineItemContentFactory( lastKnownLocation = lastKnownLocation, isActive = itemContent.isLive, endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt), + endTimestamp = itemContent.endTimestamp, ), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 9683a2c149..44dd2df38d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -35,7 +35,9 @@ class TimelineItemEventContentProvider : PreviewParameterProvider mode.lastKnownLocation is Mode.Static -> mode.location @@ -71,7 +71,8 @@ data class TimelineItemLocationContent( val lastKnownLocation: Location?, val isActive: Boolean, val endsAt: String, - val canStop: Boolean = false + val endTimestamp: Long, + val canStop: Boolean = false, ) : Mode { val isLoading = lastKnownLocation == null && isActive } @@ -79,3 +80,41 @@ data class TimelineItemLocationContent( override val type: String = "TimelineItemLocationContent" } + +/** + * Overrides the isActive value if needed, to make sure endTimestamp is used in absence of stop event. + */ +@Composable +internal fun TimelineItemLocationContent.ensureActiveLiveLocation( + currentTimeMillis: () -> Long = System::currentTimeMillis, +): TimelineItemLocationContent { + return when (val mode = mode) { + is TimelineItemLocationContent.Mode.Live -> { + val isActive = rememberIsLiveLocationActive(mode, currentTimeMillis) + copy(mode = mode.copy(isActive = isActive)) + } + is TimelineItemLocationContent.Mode.Static -> this + } +} + +@Composable +private fun rememberIsLiveLocationActive( + mode: TimelineItemLocationContent.Mode.Live, + currentTimeMillis: () -> Long, +): Boolean { + + fun TimelineItemLocationContent.Mode.Live.isActive(): Boolean { + return isActive && endTimestamp > currentTimeMillis() + } + return produceState( + initialValue = mode.isActive(), + key1 = mode.endTimestamp, + key2 = mode.isActive, + ) { + if (mode.isActive) { + val remainingMillis = mode.endTimestamp - currentTimeMillis() + delay(remainingMillis) + } + value = false + }.value +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt index e2309c2210..a9cc9e59d4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -22,6 +22,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider, ) : EventContent { - val endsAt = timestamp + timeout + val endTimestamp = startTimestamp + timeout } data object LegacyCallInviteContent : EventContent diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 0c41824dde..0a754a1f3c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest import io.element.android.libraries.matrix.impl.room.location.liveLocationSharesFlow +import io.element.android.libraries.matrix.impl.room.location.timedByExpiry import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.timeline.RustTimeline @@ -503,7 +504,7 @@ class JoinedRustRoom( } override fun subscribeToLiveLocationShares(): Flow> { - return innerRoom.liveLocationSharesFlow() + return innerRoom.liveLocationSharesFlow().timedByExpiry(systemClock::epochMillis) } override suspend fun startLiveLocationShare(durationMillis: Long): Result = withContext(roomDispatcher) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt new file mode 100644 index 0000000000..5a570d04d5 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt @@ -0,0 +1,56 @@ +/* + * 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.libraries.matrix.impl.room.location + +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch + +/** + * Makes sure to filter and emit live location based on the endTimestamp. + */ +internal fun Flow>.timedByExpiry( + currentTimeMillis: () -> Long = System::currentTimeMillis, +): Flow> = channelFlow { + var timerJob: Job? = null + + fun List.nextExpiryAfter(timestamp: Long): Long? { + return this + .asSequence() + .map { it.endTimestamp } + .filter { it > timestamp } + .minOrNull() + } + + fun List.filterLive(): List { + val currentTimeMillis = currentTimeMillis() + return filter { it.endTimestamp > currentTimeMillis } + } + + fun reschedule(shares: List) { + timerJob?.cancel() + timerJob = launch { + val currentTimeMillis = currentTimeMillis() + val nextExpiry = shares.nextExpiryAfter(currentTimeMillis) ?: return@launch + delay((nextExpiry - currentTimeMillis).coerceAtLeast(0)) + val liveShares = shares.filterLive() + send(liveShares) + reschedule(liveShares) + } + } + + collect { shares -> + val liveShares = shares.filterLive() + send(liveShares) + reschedule(liveShares) + } + +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 0d940a0a11..85b53bc6e3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -114,7 +114,7 @@ class TimelineEventContentMapper( is MsgLikeKind.LiveLocation -> { LiveLocationContent( isLive = kind.content.isLive, - timestamp = kind.content.ts.toLong(), + startTimestamp = kind.content.ts.toLong(), description = kind.content.description, timeout = kind.content.timeoutMs.toLong(), assetType = kind.content.assetType.into(), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt new file mode 100644 index 0000000000..886b927b9a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt @@ -0,0 +1,148 @@ +/* + * 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.libraries.matrix.impl.room.location + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TimedLiveLocationSharesFlowTest { + + @Test + fun `it keeps emitting shares for subsequent expiries without upstream changes`() = runTest { + val shares = listOf( + aLiveLocationShare(userId = "@alice:server", endTimestamp = 1_000), + aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000), + aLiveLocationShare(userId = "@carol:server", endTimestamp = 3_000), + ) + + flowOf(shares) + .timedByExpiry(currentTimeMillis = { testScheduler.currentTime }) + .test { + assertThat(awaitItem()).isEqualTo(shares) + + advanceTimeBy(1_000) + assertThat(awaitItem()).isEqualTo(shares.drop(1)) + + advanceTimeBy(999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEqualTo(shares.drop(2)) + + advanceTimeBy(999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEmpty() + + awaitComplete() + } + } + + @Test + fun `it does not double-emit when a share is already expired on receipt`() = runTest { + val shares = listOf( + aLiveLocationShare(userId = "@alice:server", endTimestamp = 500), + aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000), + ) + + flowOf(shares) + .timedByExpiry(currentTimeMillis = { 1_000 + testScheduler.currentTime }) + .test { + assertThat(awaitItem()).isEqualTo(shares.drop(1)) + expectNoEvents() + + advanceTimeBy(999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEmpty() + + awaitComplete() + } + } + + @Test + fun `it reschedules timed emission when upstream shares change`() = runTest { + val upstream = MutableSharedFlow>(extraBufferCapacity = 1) + val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000)) + val updatedShares = listOf( + aLiveLocationShare(userId = "@alice:server", endTimestamp = 10_000), + aLiveLocationShare(userId = "@bob:server", endTimestamp = 6_000), + ) + + upstream + .timedByExpiry(currentTimeMillis = { testScheduler.currentTime }) + .test { + upstream.emit(initialShares) + assertThat(awaitItem()).isEqualTo(initialShares) + + advanceTimeBy(5_000) + upstream.emit(updatedShares) + assertThat(awaitItem()).isEqualTo(updatedShares) + + advanceTimeBy(999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEqualTo(updatedShares.take(1)) + + advanceTimeBy(3_999) + expectNoEvents() + + advanceTimeBy(1) + assertThat(awaitItem()).isEmpty() + } + } + + @Test + fun `it completes after the last scheduled re-emission when upstream completes`() = runTest { + val shares = listOf(aLiveLocationShare(endTimestamp = 1_000)) + flowOf(shares) + .timedByExpiry(currentTimeMillis = { testScheduler.currentTime }) + .test { + assertThat(awaitItem()).isEqualTo(shares) + + advanceTimeBy(1_000) + assertThat(awaitItem()).isEmpty() + + awaitComplete() + } + } + + @Test + fun `it completes immediately when upstream emits nothing`() = runTest { + emptyFlow>() + .timedByExpiry(currentTimeMillis = { testScheduler.currentTime }) + .test { + awaitComplete() + } + } +} + +private fun aLiveLocationShare( + userId: String = "@user:server", + endTimestamp: Long, +): LiveLocationShare { + return LiveLocationShare( + userId = UserId(userId), + lastLocation = null, + endTimestamp = endTimestamp, + ) +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt index 6502882d7d..5de2cf76da 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt @@ -12,6 +12,7 @@ import androidx.activity.ComponentActivity import androidx.annotation.StringRes import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasContentDescription @@ -60,3 +61,10 @@ fun AndroidComposeTestRule.assertNoNodeWith val text = activity.getString(res) onNodeWithText(text).assertDoesNotExist() } + +fun AndroidComposeTestRule.assertNodeWithTextIsDisplayed(@StringRes res: Int) { + val text = activity.getString(res) + onNodeWithText(text).assertIsDisplayed() +} + +