Update live location shares when reaching timeout (before actual stop event)

This commit is contained in:
ganfra 2026-04-15 22:06:00 +02:00
parent 11866afb03
commit 704ddc9132
17 changed files with 331 additions and 64 deletions

View file

@ -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

View file

@ -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<LiveLocationShare>())
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,
)
}

View file

@ -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
}

View file

@ -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,

View file

@ -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,
)

View file

@ -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,
),
)
}

View file

@ -35,7 +35,9 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemUnknownContent(),
aTimelineItemTextContent().copy(isEdited = true),
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", lastKnownLocation = null)),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
),
)
}

View file

@ -8,6 +8,8 @@
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import io.element.android.features.location.api.Location
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -17,6 +19,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import kotlinx.coroutines.delay
data class TimelineItemLocationContent(
val senderId: UserId,
@ -25,9 +28,6 @@ data class TimelineItemLocationContent(
val assetType: AssetType? = null,
val mode: Mode,
) : TimelineItemEventContent {
val hideTimestamp = mode is Mode.Live && mode.canStop
val location = when (mode) {
is Mode.Live -> 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
}

View file

@ -22,6 +22,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
canStop = true,
lastKnownLocation = aLocation()
),
@ -30,6 +31,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
),
@ -37,6 +39,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = null
),
),
@ -44,6 +47,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
mode = TimelineItemLocationContent.Mode.Live(
isActive = false,
endsAt = "",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
),

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
@ -79,6 +80,8 @@ internal fun TestScope.aTimelineItemsFactory(
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
sessionId = matrixClient.sessionId,
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
),
matrixClient = matrixClient,
dateFormatter = FakeDateFormatter(),

View file

@ -110,10 +110,9 @@ class TimelineItemContentMessageFactoryTest {
eventId = AN_EVENT_ID,
)
val expected = TimelineItemLocationContent(
location = Location(lat = 1.0, lon = 2.0, accuracy = null),
description = "description",
assetType = assetType,
mode = TimelineItemLocationContent.Mode.Static,
mode = TimelineItemLocationContent.Mode.Static(location = Location(lat = 1.0, lon = 2.0, accuracy = null)),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
)

View file

@ -107,12 +107,12 @@ data class FailedToParseStateContent(
data class LiveLocationContent(
val isLive: Boolean,
val description: String?,
val timestamp: Long,
val startTimestamp: Long,
val timeout: Long,
val assetType: AssetType?,
val locations: List<LiveLocationInfo>,
) : EventContent {
val endsAt = timestamp + timeout
val endTimestamp = startTimestamp + timeout
}
data object LegacyCallInviteContent : EventContent

View file

@ -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<List<LiveLocationShare>> {
return innerRoom.liveLocationSharesFlow()
return innerRoom.liveLocationSharesFlow().timedByExpiry(systemClock::epochMillis)
}
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {

View file

@ -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<List<LiveLocationShare>>.timedByExpiry(
currentTimeMillis: () -> Long = System::currentTimeMillis,
): Flow<List<LiveLocationShare>> = channelFlow {
var timerJob: Job? = null
fun List<LiveLocationShare>.nextExpiryAfter(timestamp: Long): Long? {
return this
.asSequence()
.map { it.endTimestamp }
.filter { it > timestamp }
.minOrNull()
}
fun List<LiveLocationShare>.filterLive(): List<LiveLocationShare> {
val currentTimeMillis = currentTimeMillis()
return filter { it.endTimestamp > currentTimeMillis }
}
fun reschedule(shares: List<LiveLocationShare>) {
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)
}
}

View file

@ -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(),

View file

@ -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<List<LiveLocationShare>>(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<List<LiveLocationShare>>()
.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,
)
}

View file

@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNoNodeWith
val text = activity.getString(res)
onNodeWithText(text).assertDoesNotExist()
}
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNodeWithTextIsDisplayed(@StringRes res: Int) {
val text = activity.getString(res)
onNodeWithText(text).assertIsDisplayed()
}