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 8cefebdc36
commit e162e667be
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 member = members.find { it.userId == share.userId }
val displayName = member?.getBestName() ?: share.userId.value val displayName = member?.getBestName() ?: share.userId.value
val avatarUrl = member?.avatarUrl 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( val formattedTimestamp = stringProvider.getString(
CommonStrings.screen_static_location_sheet_timestamp_description, CommonStrings.screen_static_location_sheet_timestamp_description,
relativeTime 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.core.UserId
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.room.location.AssetType 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.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.core.aBuildMeta 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.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.FakeLiveLocationShareService
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ShowLocationPresenterTest { class ShowLocationPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -330,7 +332,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `live mode emits empty location shares initially`() = runTest { fun `live mode emits empty location shares initially`() = runTest {
val presenter = createShowLocationPresenter( val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live, mode = ShowLocationMode.Live(senderId = UserId("@alice:matrix.org")),
joinedRoom = FakeJoinedRoom(), joinedRoom = FakeJoinedRoom(),
) )
presenter.test { presenter.test {
@ -345,22 +347,13 @@ class ShowLocationPresenterTest {
val userId = UserId("@bob:matrix.org") val userId = UserId("@bob:matrix.org")
val liveSharesFlow = MutableStateFlow( val liveSharesFlow = MutableStateFlow(
listOf( listOf(
LiveLocationShare( aLiveLocationShare(userId = userId)
userId = userId,
lastGeoUri = "geo:48.8584,2.2945",
lastTimestamp = 1234567890L,
isLive = true,
)
)
)
val fakeRoom = FakeJoinedRoom(
liveLocationShareService = FakeLiveLocationShareService(
liveLocationSharesFlow = liveSharesFlow
) )
) )
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
val presenter = createShowLocationPresenter( val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live, mode = ShowLocationMode.Live(senderId = userId),
joinedRoom = fakeRoom, joinedRoom = fakeRoom,
) )
presenter.test { presenter.test {
@ -384,28 +377,14 @@ class ShowLocationPresenterTest {
val invalidUserId = UserId("@bob:matrix.org") val invalidUserId = UserId("@bob:matrix.org")
val liveSharesFlow = MutableStateFlow( val liveSharesFlow = MutableStateFlow(
listOf( listOf(
LiveLocationShare( aLiveLocationShare(userId = validUserId),
userId = validUserId, aLiveLocationShare(userId = invalidUserId, geoUri = "invalid-geo-uri"),
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
) )
) )
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
val presenter = createShowLocationPresenter( val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live, mode = ShowLocationMode.Live(senderId = validUserId),
joinedRoom = fakeRoom, joinedRoom = fakeRoom,
) )
presenter.test { presenter.test {
@ -423,14 +402,10 @@ class ShowLocationPresenterTest {
fun `live mode updates when shares change`() = runTest { fun `live mode updates when shares change`() = runTest {
val userId = UserId("@bob:matrix.org") val userId = UserId("@bob:matrix.org")
val liveSharesFlow = MutableStateFlow(emptyList<LiveLocationShare>()) val liveSharesFlow = MutableStateFlow(emptyList<LiveLocationShare>())
val fakeRoom = FakeJoinedRoom( val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
liveLocationShareService = FakeLiveLocationShareService(
liveLocationSharesFlow = liveSharesFlow
)
)
val presenter = createShowLocationPresenter( val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live, mode = ShowLocationMode.Live(senderId = userId),
joinedRoom = fakeRoom, joinedRoom = fakeRoom,
) )
presenter.test { presenter.test {
@ -440,12 +415,7 @@ class ShowLocationPresenterTest {
// Emit a new live share // Emit a new live share
liveSharesFlow.value = listOf( liveSharesFlow.value = listOf(
LiveLocationShare( aLiveLocationShare(userId = userId)
userId = userId,
lastGeoUri = "geo:48.8584,2.2945",
lastTimestamp = 1234567890L,
isLive = true,
)
) )
val updatedState = awaitItem() 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.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent 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.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.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected 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 TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemStickerContent -> 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 is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default 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.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent 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.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
@ -71,11 +72,13 @@ fun TimelineItemEventContentView(
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
modifier = modifier modifier = modifier
) )
is TimelineItemLocationContent -> TimelineItemLocationView( is TimelineItemLocationContent -> {
content = content, TimelineItemLocationView(
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) }, content = content.ensureActiveLiveLocation(),
modifier = modifier onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
) modifier = modifier
)
}
is TimelineItemImageContent -> TimelineItemImageView( is TimelineItemImageContent -> TimelineItemImageView(
content = content, content = content,
hideMediaContent = hideMediaContent, hideMediaContent = hideMediaContent,

View file

@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.location.api.StaticMapView
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon 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.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemLocationView( fun TimelineItemLocationView(
@ -121,7 +124,12 @@ private fun LiveLocationOverlay(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( 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, style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
) )

View file

@ -111,7 +111,7 @@ class TimelineItemContentFactory(
}.lastOrNull() }.lastOrNull()
val endsAt = dateFormatter.format( val endsAt = dateFormatter.format(
timestamp = itemContent.endsAt, timestamp = itemContent.endTimestamp,
mode = DateFormatterMode.TimeOnly mode = DateFormatterMode.TimeOnly
) )
// Always create content, location can be null for "loading/waiting" state // Always create content, location can be null for "loading/waiting" state
@ -124,6 +124,7 @@ class TimelineItemContentFactory(
lastKnownLocation = lastKnownLocation, lastKnownLocation = lastKnownLocation,
isActive = itemContent.isLive, isActive = itemContent.isLive,
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt), endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
endTimestamp = itemContent.endTimestamp,
), ),
) )
} }

View file

@ -35,7 +35,9 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemUnknownContent(), aTimelineItemUnknownContent(),
aTimelineItemTextContent().copy(isEdited = true), aTimelineItemTextContent().copy(isEdited = true),
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT), 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 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.features.location.api.Location
import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import kotlinx.coroutines.delay
data class TimelineItemLocationContent( data class TimelineItemLocationContent(
val senderId: UserId, val senderId: UserId,
@ -25,9 +28,6 @@ data class TimelineItemLocationContent(
val assetType: AssetType? = null, val assetType: AssetType? = null,
val mode: Mode, val mode: Mode,
) : TimelineItemEventContent { ) : TimelineItemEventContent {
val hideTimestamp = mode is Mode.Live && mode.canStop
val location = when (mode) { val location = when (mode) {
is Mode.Live -> mode.lastKnownLocation is Mode.Live -> mode.lastKnownLocation
is Mode.Static -> mode.location is Mode.Static -> mode.location
@ -71,7 +71,8 @@ data class TimelineItemLocationContent(
val lastKnownLocation: Location?, val lastKnownLocation: Location?,
val isActive: Boolean, val isActive: Boolean,
val endsAt: String, val endsAt: String,
val canStop: Boolean = false val endTimestamp: Long,
val canStop: Boolean = false,
) : Mode { ) : Mode {
val isLoading = lastKnownLocation == null && isActive val isLoading = lastKnownLocation == null && isActive
} }
@ -79,3 +80,41 @@ data class TimelineItemLocationContent(
override val type: String = "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( mode = TimelineItemLocationContent.Mode.Live(
isActive = true, isActive = true,
endsAt = "Ends at 12:34", endsAt = "Ends at 12:34",
endTimestamp = 0L,
canStop = true, canStop = true,
lastKnownLocation = aLocation() lastKnownLocation = aLocation()
), ),
@ -30,6 +31,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
mode = TimelineItemLocationContent.Mode.Live( mode = TimelineItemLocationContent.Mode.Live(
isActive = true, isActive = true,
endsAt = "Ends at 12:34", endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = aLocation() lastKnownLocation = aLocation()
), ),
), ),
@ -37,6 +39,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
mode = TimelineItemLocationContent.Mode.Live( mode = TimelineItemLocationContent.Mode.Live(
isActive = true, isActive = true,
endsAt = "Ends at 12:34", endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = null lastKnownLocation = null
), ),
), ),
@ -44,6 +47,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
mode = TimelineItemLocationContent.Mode.Live( mode = TimelineItemLocationContent.Mode.Live(
isActive = false, isActive = false,
endsAt = "", endsAt = "",
endTimestamp = 0L,
lastKnownLocation = aLocation() 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation 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 io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -79,6 +80,8 @@ internal fun TestScope.aTimelineItemsFactory(
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
sessionId = matrixClient.sessionId, sessionId = matrixClient.sessionId,
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
), ),
matrixClient = matrixClient, matrixClient = matrixClient,
dateFormatter = FakeDateFormatter(), dateFormatter = FakeDateFormatter(),

View file

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

View file

@ -107,12 +107,12 @@ data class FailedToParseStateContent(
data class LiveLocationContent( data class LiveLocationContent(
val isLive: Boolean, val isLive: Boolean,
val description: String?, val description: String?,
val timestamp: Long, val startTimestamp: Long,
val timeout: Long, val timeout: Long,
val assetType: AssetType?, val assetType: AssetType?,
val locations: List<LiveLocationInfo>, val locations: List<LiveLocationInfo>,
) : EventContent { ) : EventContent {
val endsAt = timestamp + timeout val endTimestamp = startTimestamp + timeout
} }
data object LegacyCallInviteContent : EventContent 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.join.map
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest 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.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.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline import io.element.android.libraries.matrix.impl.timeline.RustTimeline
@ -503,7 +504,7 @@ class JoinedRustRoom(
} }
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> { 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) { 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 -> { is MsgLikeKind.LiveLocation -> {
LiveLocationContent( LiveLocationContent(
isLive = kind.content.isLive, isLive = kind.content.isLive,
timestamp = kind.content.ts.toLong(), startTimestamp = kind.content.ts.toLong(),
description = kind.content.description, description = kind.content.description,
timeout = kind.content.timeoutMs.toLong(), timeout = kind.content.timeoutMs.toLong(),
assetType = kind.content.assetType.into(), 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.annotation.StringRes
import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasContentDescription
@ -60,3 +61,10 @@ fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNoNodeWith
val text = activity.getString(res) val text = activity.getString(res)
onNodeWithText(text).assertDoesNotExist() onNodeWithText(text).assertDoesNotExist()
} }
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNodeWithTextIsDisplayed(@StringRes res: Int) {
val text = activity.getString(res)
onNodeWithText(text).assertIsDisplayed()
}