Merge pull request #6611 from element-hq/feature/fga/live_location_rendering

WIP : live location rendering
This commit is contained in:
ganfra 2026-04-20 19:46:13 +02:00 committed by GitHub
commit e5f85592d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 1256 additions and 273 deletions

View file

@ -24,5 +24,7 @@ sealed interface ShowLocationMode : Parcelable {
) : ShowLocationMode
@Parcelize
data object Live : ShowLocationMode
data class Live(
val senderId: UserId
) : ShowLocationMode
}

View file

@ -9,7 +9,9 @@
package io.element.android.features.location.api
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.Extras
import coil3.compose.AsyncImagePainter
@ -38,11 +42,16 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Shows a static map image downloaded via a third party service's static maps API.
*
* Handles 4 distinct cases:
* 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching
* 2. Null location - shows blurred placeholder, no pin, no loading
* 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
* 4. Success (location != null, loaded) - shows actual map with pin
*/
@Composable
fun StaticMapView(
lat: Double,
lon: Double,
location: Location?,
zoom: Double,
pinVariant: PinVariant,
contentDescription: String?,
@ -56,50 +65,111 @@ fun StaticMapView(
modifier = modifier,
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder() }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
null
} else {
ImageRequest.Builder(context)
.data(
builder.build(
lat = lat,
lon = lon,
zoom = zoom,
darkMode = darkMode,
width = constraints.maxWidth,
height = constraints.maxHeight,
density = LocalDensity.current.density,
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)
.apply {
extras.set(Extras.Key("retry_hash"), retryHash).build()
}
.build()
// Case 1: Stale location - show stale map with stale pin, no fetching
when {
pinVariant is PinVariant.StaleLocation -> {
StaleMapContent(
pinVariant = pinVariant,
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
)
}
)
// Case 2: Null location - show blurred placeholder, no pin, no loading
location == null -> {
StaticMapPlaceholder(
painter = painterResource(R.drawable.blurred_map),
canReload = false,
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
onLoadMapClick = {}
)
}
// Cases 3 & 4: Non-null location - fetch map
else -> LoadableMapContent(
location = location,
zoom = zoom,
pinVariant = pinVariant,
contentDescription = contentDescription,
darkMode = darkMode,
)
}
}
}
val collectedState = painter.state.collectAsState()
if (collectedState.value is AsyncImagePainter.State.Success) {
@Composable
private fun BoxWithConstraintsScope.StaleMapContent(
pinVariant: PinVariant,
contentDescription: String?,
width: Dp,
height: Dp,
) {
Box(contentAlignment = Alignment.Center) {
Image(
painter = painterResource(R.drawable.stale_map),
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(width = width, height = height)
)
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this@StaleMapContent))
}
}
@Composable
private fun BoxWithConstraintsScope.LoadableMapContent(
location: Location,
zoom: Double,
pinVariant: PinVariant,
contentDescription: String?,
darkMode: Boolean,
) {
val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder() }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero
null
} else {
ImageRequest.Builder(context)
.data(
builder.build(
lat = location.lat,
lon = location.lon,
zoom = zoom,
darkMode = darkMode,
width = constraints.maxWidth,
height = constraints.maxHeight,
density = LocalDensity.current.density,
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)
.apply {
extras.set(Extras.Key("retry_hash"), retryHash).build()
}
.build()
}
)
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Success -> {
Image(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight),
// The returned image can be smaller than the requested size due to the static maps API having
// a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
// a max width and height of 2048 px. We apply ContentScale.Fit to handle this.
contentScale = ContentScale.Fit,
)
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
} else {
}
else -> {
StaticMapPlaceholder(
showProgress = collectedState.value.isLoading(),
canReload = builder.isServiceAvailable(),
painter = painterResource(R.drawable.blurred_map),
canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
@ -109,17 +179,11 @@ fun StaticMapView(
}
}
private fun AsyncImagePainter.State.isLoading(): Boolean {
return this is AsyncImagePainter.State.Empty ||
this is AsyncImagePainter.State.Loading
}
@PreviewsDayNight
@Composable
internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView(
lat = 0.0,
lon = 0.0,
location = Location(0.0, 0.0),
zoom = 0.0,
contentDescription = null,
pinVariant = PinVariant.PinnedLocation,

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -27,14 +28,13 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.R
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.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun StaticMapPlaceholder(
showProgress: Boolean,
painter: Painter,
canReload: Boolean,
contentDescription: String?,
width: Dp,
@ -46,17 +46,15 @@ internal fun StaticMapPlaceholder(
contentAlignment = Alignment.Center,
modifier = modifier
.size(width = width, height = height)
.then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick))
.clickable(enabled = canReload, onClick = onLoadMapClick)
) {
Image(
painter = painterResource(id = R.drawable.blurred_map),
painter = painter,
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(width = width, height = height)
)
if (showProgress) {
CircularProgressIndicator()
} else if (canReload) {
if (canReload) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -77,13 +75,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview {
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(
true to false,
false to true,
false to false,
).forEach { (showProgress, canReload) ->
listOf(false, true)
.forEach { canReload ->
StaticMapPlaceholder(
showProgress = showProgress,
painter = painterResource(R.drawable.blurred_map),
canReload = canReload,
contentDescription = null,
width = 400.dp,

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -91,9 +91,9 @@ fun LocationShareRow(
)
}
Text(
text = item.formattedTimestamp,
text = if (item.isLive) stringResource(CommonStrings.screen_room_live_location_banner) else item.formattedTimestamp,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
color = if (item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)

View file

@ -10,12 +10,14 @@ package io.element.android.features.location.impl.common.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
@ -43,6 +45,7 @@ import androidx.compose.ui.unit.max
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
@ -112,8 +115,11 @@ fun MapBottomSheetScaffold(
modifier = Modifier,
sheetPeekHeight = sheetPeekHeight,
sheetContent = {
sheetContent(sheetPadding)
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
sheetContent(sheetPadding)
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
},
scaffoldState = scaffoldState,
sheetDragHandle = sheetDragHandle,

View file

@ -0,0 +1,20 @@
/*
* 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.location.impl.show
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
class LiveLocationShareComparator(private val currentUser: UserId) : Comparator<LiveLocationShare> {
override fun compare(p0: LiveLocationShare, p1: LiveLocationShare): Int {
val p0IsCurrentUser = p0.userId == currentUser
val p1IsCurrentUser = p1.userId == currentUser
if (p0IsCurrentUser != p1IsCurrentUser) return if (p0IsCurrentUser) -1 else 1
return p1.startTimestamp.compareTo(p0.startTimestamp)
}
}

View file

@ -13,11 +13,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
@ -29,14 +31,20 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
@AssistedInject
class ShowLocationPresenter(
@ -46,6 +54,7 @@ class ShowLocationPresenter(
private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
private val joinedRoom: JoinedRoom,
) : Presenter<ShowLocationState> {
@AssistedFactory
fun interface Factory {
@ -96,9 +105,9 @@ class ShowLocationPresenter(
}
}
val locationShares = remember {
when (mode) {
is ShowLocationMode.Static -> {
val locationShares = when (mode) {
is ShowLocationMode.Static -> {
remember {
val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true)
val formattedTimestamp = stringProvider.getString(
CommonStrings.screen_static_location_sheet_timestamp_description,
@ -121,15 +130,59 @@ class ShowLocationPresenter(
)
)
}
ShowLocationMode.Live -> persistentListOf()
}
is ShowLocationMode.Live -> {
produceState(persistentListOf()) {
val comparator = LiveLocationShareComparator(currentUser = joinedRoom.sessionId)
val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares()
val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() }
combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members ->
liveShares
.sortedWith(comparator)
.mapNotNull { share ->
val lastLocation = share.lastLocation ?: return@mapNotNull null
val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null
val member = members.find { it.userId == share.userId }
val displayName = member?.getBestName() ?: share.userId.value
val avatarUrl = member?.avatarUrl
val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true)
val formattedTimestamp = stringProvider.getString(
CommonStrings.screen_static_location_sheet_timestamp_description,
relativeTime
)
LocationShareItem(
userId = share.userId,
displayName = displayName,
avatarData = AvatarData(
id = share.userId.value,
name = displayName,
url = avatarUrl,
size = AvatarSize.UserListItem,
),
formattedTimestamp = formattedTimestamp,
location = location,
isLive = true,
assetType = lastLocation.assetType,
)
}
.toImmutableList()
}.collect { value = it }
}.value
}
}
val focusedLocation = when (mode) {
is ShowLocationMode.Static -> locationShares.firstOrNull()
is ShowLocationMode.Live -> locationShares.firstOrNull { it.userId == mode.senderId }
}
return ShowLocationState(
dialogState = dialogState,
locationShares = locationShares,
focusedLocation = focusedLocation,
hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation,
isLive = mode is ShowLocationMode.Live,
appName = appName,
eventSink = ::handleEvent,
)

View file

@ -18,14 +18,16 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState(
val isLive: Boolean,
val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList<LocationShareItem>,
val focusedLocation: LocationShareItem?,
val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean,
val appName: String,
val eventSink: (ShowLocationEvent) -> Unit,
) {
val isSheetDraggable = locationShares.any { item -> item.isLive }
val isSheetDraggable = isLive && locationShares.isNotEmpty()
}
data class LocationShareItem(

View file

@ -21,6 +21,8 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
override val values: Sequence<ShowLocationState>
get() = sequenceOf(
aShowLocationState(),
aShowLocationState(isLive = true),
aShowLocationState(isLive = true, locationShares = emptyList()),
aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
),
@ -44,8 +46,10 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
private const val APP_NAME = "ApplicationName"
fun aShowLocationState(
isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List<LocationShareItem> = listOf(aLocationShareItem()),
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
focusedLocation: LocationShareItem? = locationShares.firstOrNull(),
hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false,
appName: String = APP_NAME,
@ -54,9 +58,11 @@ fun aShowLocationState(
return ShowLocationState(
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation,
hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation,
appName = appName,
isLive = isLive,
eventSink = eventSink,
)
}
@ -70,10 +76,10 @@ fun aLocationShareItem(
url = null,
size = AvatarSize.UserListItem,
),
formattedTimestamp: String = "Shared 1 min ago",
location: Location = Location(1.23, 2.34, 4f),
isLive: Boolean = false,
assetType: AssetType? = null,
formattedTimestamp: String = "Shared 1 min ago",
location: Location = Location(1.23, 2.34, 4f),
) = LocationShareItem(
userId = userId,
displayName = displayName,

View file

@ -12,8 +12,11 @@ package io.element.android.features.location.impl.show
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
@ -21,11 +24,15 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@ -65,35 +72,33 @@ fun ShowLocationView(
onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) },
)
val initialPosition = remember {
if (state.locationShares.isEmpty()) {
MapDefaults.defaultCameraPosition
} else {
val firstLocation = state.locationShares.first().location
CameraPosition(
target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon),
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) }
LaunchedEffect(state.focusedLocation) {
if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) {
hasAnimatedToFocusedLocation = true
val position = CameraPosition(
target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon),
zoom = MapDefaults.DEFAULT_ZOOM
)
cameraState.position = position
}
}
val cameraState = rememberCameraState(firstPosition = initialPosition)
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
}
}
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(
initialValue =
if (state.isSheetDraggable) {
SheetValue.PartiallyExpanded
} else {
SheetValue.Expanded
}
)
bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
)
LaunchedEffect(state.isSheetDraggable) {
if (!state.isSheetDraggable) {
scaffoldState.bottomSheetState.expand()
}
}
MapBottomSheetScaffold(
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
@ -116,29 +121,46 @@ fun ShowLocationView(
},
sheetContent = { sheetPaddings ->
val coroutineScope = rememberCoroutineScope()
Spacer(Modifier.height(20.dp))
Text(
text = stringResource(CommonStrings.screen_static_location_sheet_title),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
state.locationShares.forEach { locationShare ->
LocationShareRow(
item = locationShare,
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
modifier = Modifier.clickable {
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
val position = CameraPosition(
padding = sheetPaddings,
target = Position(locationShare.location.lon, locationShare.location.lat),
zoom = MapDefaults.DEFAULT_ZOOM
)
coroutineScope.launch {
cameraState.animateTo(finalPosition = position)
}
}
if (!state.isSheetDraggable) {
// If sheet is draggable the DragHandle has already some padding
Spacer(Modifier.height(20.dp))
}
if (state.locationShares.isEmpty()) {
Text(
text = stringResource(CommonStrings.screen_live_location_sheet_nobody_sharing),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp),
textAlign = TextAlign.Center,
)
} else {
Text(
text = stringResource(CommonStrings.screen_static_location_sheet_title),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
LazyColumn {
items(state.locationShares) { locationShare ->
LocationShareRow(
item = locationShare,
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
modifier = Modifier.clickable {
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
val position = CameraPosition(
padding = sheetPaddings,
target = Position(locationShare.location.lon, locationShare.location.lat),
zoom = MapDefaults.DEFAULT_ZOOM
)
coroutineScope.launch {
cameraState.animateTo(finalPosition = position)
}
}
)
}
}
}
},
mapContent = {

View file

@ -19,6 +19,7 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.node.TestParentNode
@ -43,7 +44,8 @@ class DefaultShowLocationEntryPointTest {
locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(),
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider()
stringProvider = FakeStringProvider(),
joinedRoom = FakeJoinedRoom(),
)
},
analyticsService = FakeAnalyticsService(),

View file

@ -0,0 +1,69 @@
/*
* 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.location.impl.show
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 org.junit.Test
class LiveLocationShareComparatorTest {
private val currentUser = UserId("@me:matrix.org")
private val comparator = LiveLocationShareComparator(currentUser)
@Test
fun `compare returns zero when comparing the same current user share`() {
val share = aLiveLocationShare(userId = currentUser, startTimestamp = 123L)
val result = comparator.compare(share, share)
assertThat(result).isEqualTo(0)
}
@Test
fun `compare orders current user share before another user share`() {
val otherShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L)
val currentUserShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L)
val sortedShares = listOf(otherShare, currentUserShare).sortedWith(comparator)
assertThat(sortedShares).containsExactly(currentUserShare, otherShare).inOrder()
}
@Test
fun `compare orders current user shares by newest start timestamp first`() {
val newerShare = aLiveLocationShare(userId = currentUser, startTimestamp = 200L)
val olderShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L)
val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator)
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
}
@Test
fun `compare orders non current user shares by newest start timestamp first`() {
val newerShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L)
val olderShare = aLiveLocationShare(userId = UserId("@bob:matrix.org"), startTimestamp = 100L)
val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator)
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
}
}
private fun aLiveLocationShare(
userId: UserId,
startTimestamp: Long,
): LiveLocationShare {
return LiveLocationShare(
userId = userId,
lastLocation = null,
startTimestamp = startTimestamp,
endTimestamp = startTimestamp + 1_000L,
)
}

View file

@ -22,15 +22,23 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
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.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()
@ -51,13 +59,15 @@ class ShowLocationPresenterTest {
assetType = null,
),
locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
locationActions = locationActions,
buildMeta = fakeBuildMeta,
dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider()
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
)
@Test
@ -318,4 +328,159 @@ class ShowLocationPresenterTest {
assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
}
}
@Test
fun `live mode emits empty location shares initially`() = runTest {
val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live(senderId = UserId("@alice:matrix.org")),
joinedRoom = FakeJoinedRoom(),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.locationShares).isEmpty()
assertThat(initialState.isSheetDraggable).isFalse()
}
}
@Test
fun `live mode collects live shares from room`() = runTest {
val userId = UserId("@bob:matrix.org")
val liveSharesFlow = MutableStateFlow(
listOf(
aLiveLocationShare(userId = userId)
)
)
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live(senderId = userId),
joinedRoom = fakeRoom,
)
presenter.test {
// Skip initial empty state from collectAsState(initial = emptyList())
skipItems(1)
val state = awaitItem()
assertThat(state.locationShares).hasSize(1)
val item = state.locationShares.first()
assertThat(item.userId).isEqualTo(userId)
assertThat(item.location.lat).isEqualTo(48.8584)
assertThat(item.location.lon).isEqualTo(2.2945)
assertThat(item.isLive).isTrue()
assertThat(state.isSheetDraggable).isTrue()
}
}
@Test
fun `live mode handles invalid geo uri gracefully`() = runTest {
val validUserId = UserId("@alice:matrix.org")
val invalidUserId = UserId("@bob:matrix.org")
val liveSharesFlow = MutableStateFlow(
listOf(
aLiveLocationShare(userId = validUserId),
aLiveLocationShare(userId = invalidUserId, geoUri = "invalid-geo-uri"),
)
)
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live(senderId = validUserId),
joinedRoom = fakeRoom,
)
presenter.test {
// Skip initial empty state from collectAsState(initial = emptyList())
skipItems(1)
val state = awaitItem()
// Only the valid location share should be present
assertThat(state.locationShares).hasSize(1)
assertThat(state.locationShares.first().userId).isEqualTo(validUserId)
}
}
@Test
fun `live mode updates when shares change`() = runTest {
val userId = UserId("@bob:matrix.org")
val liveSharesFlow = MutableStateFlow(emptyList<LiveLocationShare>())
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live(senderId = userId),
joinedRoom = fakeRoom,
)
presenter.test {
// Initial state is empty
val initialState = awaitItem()
assertThat(initialState.locationShares).isEmpty()
// Emit a new live share
liveSharesFlow.value = listOf(
aLiveLocationShare(userId = userId)
)
val updatedState = awaitItem()
assertThat(updatedState.locationShares).hasSize(1)
assertThat(updatedState.locationShares.first().userId).isEqualTo(userId)
}
}
@Test
fun `static mode emits location share with correct data`() = runTest {
val senderId = UserId("@alice:matrix.org")
val senderName = "Alice"
val avatarUrl = "https://example.com/avatar.png"
val mode = ShowLocationMode.Static(
location = location,
senderName = senderName,
senderId = senderId,
senderAvatarUrl = avatarUrl,
timestamp = 0L,
assetType = AssetType.SENDER,
)
val presenter = createShowLocationPresenter(mode = mode)
presenter.test {
val state = awaitItem()
assertThat(state.locationShares).hasSize(1)
val item = state.locationShares.first()
assertThat(item.userId).isEqualTo(senderId)
assertThat(item.displayName).isEqualTo(senderName)
assertThat(item.location).isEqualTo(location)
assertThat(item.isLive).isFalse()
assertThat(item.assetType).isEqualTo(AssetType.SENDER)
assertThat(item.avatarData.id).isEqualTo(senderId.value)
assertThat(item.avatarData.name).isEqualTo(senderName)
assertThat(item.avatarData.url).isEqualTo(avatarUrl)
}
}
@Test
fun `static mode has non-draggable sheet`() = runTest {
val presenter = createShowLocationPresenter()
presenter.test {
val state = awaitItem()
assertThat(state.isSheetDraggable).isFalse()
}
}
}
private fun aLiveLocationShare(
userId: UserId,
geoUri: String = "geo:48.8584,2.2945",
timestamp: Long = 0L,
startTimestamp: Long = 0L,
endTimestamp: Long = Long.MAX_VALUE,
assetType: AssetType = AssetType.SENDER,
): LiveLocationShare {
return LiveLocationShare(
userId = userId,
lastLocation = LastLocation(
geoUri = geoUri,
timestamp = timestamp,
assetType = assetType,
),
startTimestamp = startTimestamp,
endTimestamp = endTimestamp,
)
}

View file

@ -586,17 +586,18 @@ class MessagesFlowNode(
)
}
is TimelineItemLocationContent -> {
val mode = ShowLocationMode.Static(
location = event.content.location,
senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
NavTarget.LocationViewer(
mode = mode
).takeIf { locationService.isServiceAvailable() }
val mode = when (event.content.mode) {
is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId)
is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static(
location = event.content.mode.location,
senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
}
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
}
else -> null
}

View file

@ -289,7 +289,11 @@ private fun MessageSummary(
is TimelineItemRedactedContent,
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
is TimelineItemLocationContent -> {
content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) }
val body = when (event.content.mode) {
is TimelineItemLocationContent.Mode.Live -> stringResource(CommonStrings.common_shared_live_location)
is TimelineItemLocationContent.Mode.Static -> stringResource(CommonStrings.common_shared_location)
}
content = { ContentForBody(body) }
}
is TimelineItemImageContent -> {
content = { ContentForBody(event.content.bestDescription) }

View file

@ -57,4 +57,6 @@ sealed interface TimelineEvent {
data class EditPoll(
val pollStartId: EventId,
) : TimelineItemPollEvent
data object StopLiveLocationShare : TimelineItemEvent
}

View file

@ -200,6 +200,7 @@ class TimelinePresenter(
is TimelineEvent.EditPoll -> {
navigator.navigateToEditPoll(event.pollStartId)
}
is TimelineEvent.StopLiveLocationShare -> Unit
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
delay(event.debounce)

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
@ -677,6 +678,7 @@ private fun MessageEventBubbleContent(
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
TimestampPosition.Hidden -> Box(modifier) { content {} }
}
}
@ -772,11 +774,17 @@ private fun MessageEventBubbleContent(
}
}
val timestampPosition = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemStickerContent,
is TimelineItemLocationContent -> TimestampPosition.Overlay
val timestampPosition = when (val content = event.content) {
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 -> {
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

@ -22,7 +22,12 @@ enum class TimestampPosition {
/**
* Timestamp should always be rendered below the timeline event content (eg. poll).
*/
Below;
Below,
/**
* Timestamp should be hidden.
*/
Hidden;
companion object {
/**

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,10 +72,13 @@ fun TimelineItemEventContentView(
onContentLayoutChange = onContentLayoutChange,
modifier = modifier
)
is TimelineItemLocationContent -> TimelineItemLocationView(
content = content,
modifier = modifier
)
is TimelineItemLocationContent -> {
TimelineItemLocationView(
content = content.ensureActiveLiveLocation(),
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
modifier = modifier
)
}
is TimelineItemImageContent -> TimelineItemImageView(
content = content,
hideMediaContent = hideMediaContent,

View file

@ -8,33 +8,153 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButtonDefaults
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
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.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(
content: TimelineItemLocationContent,
onStopLiveLocationClick: () -> Unit,
modifier: Modifier = Modifier,
) {
StaticMapView(
Box(modifier = modifier.fillMaxWidth()) {
StaticMapView(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 188.dp),
pinVariant = content.pinVariant,
location = content.location,
zoom = 15.0,
contentDescription = content.description
)
if (content.mode is TimelineItemLocationContent.Mode.Live) {
LiveLocationOverlay(
mode = content.mode,
onStopClick = onStopLiveLocationClick,
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
}
@Composable
private fun LiveLocationOverlay(
mode: TimelineItemLocationContent.Mode.Live,
onStopClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(max = 188.dp),
pinVariant = content.pinVariant,
lat = content.location.lat,
lon = content.location.lon,
zoom = 15.0,
contentDescription = content.body
)
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val iconShape = RoundedCornerShape(8.dp)
Box(
modifier = Modifier
.size(32.dp)
.border(
width = 1.dp,
color = if (mode.isActive) ElementTheme.colors.iconQuaternaryAlpha else Color.Transparent,
shape = iconShape,
)
.background(
color = if (mode.isActive) {
ElementTheme.colors.bgCanvasDefault
} else {
ElementTheme.colors.bgSubtleSecondary
},
shape = iconShape
)
) {
if (mode.isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = ElementTheme.colors.iconSecondary,
modifier = Modifier
.align(Alignment.Center)
.size(20.dp)
)
} else {
Icon(
imageVector = CompoundIcons.LocationPinSolid(),
contentDescription = null,
tint = if (mode.isActive) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.iconDisabled
},
modifier = Modifier.align(Alignment.Center)
)
}
}
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (mode.isActive) {
stringResource(CommonStrings.common_live_location)
} else {
stringResource(CommonStrings.common_live_location_ended)
},
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary,
)
if (mode.isActive) {
Text(
text = mode.endsAt,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
if (mode.isActive && mode.canStop) {
IconButton(
onClick = onStopClick,
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgCriticalPrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
) {
Icon(
imageVector = CompoundIcons.Stop(),
contentDescription = null,
)
}
}
}
}
@PreviewsDayNight
@ -43,5 +163,6 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat
ElementPreview {
TimelineItemLocationView(
content = content,
onStopLiveLocationClick = {},
)
}

View file

@ -15,6 +15,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
@ -36,6 +38,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
@Inject
class TimelineItemContentFactory(
@ -50,6 +54,8 @@ class TimelineItemContentFactory(
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
private val sessionId: SessionId,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
) {
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return create(
@ -103,19 +109,24 @@ class TimelineItemContentFactory(
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
Location.fromGeoUri(beacon.geoUri)
}.lastOrNull()
if (lastKnownLocation != null) {
TimelineItemLocationContent(
body = itemContent.body.trimEnd(),
description = itemContent.description?.trimEnd(),
assetType = itemContent.assetType,
senderId = sender,
senderProfile = senderProfile,
location = lastKnownLocation,
mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive)
)
} else {
TimelineItemUnknownContent
}
val endsAt = dateFormatter.format(
timestamp = itemContent.endTimestamp,
mode = DateFormatterMode.TimeOnly
)
// Always create content, location can be null for "loading/waiting" state
TimelineItemLocationContent(
description = itemContent.description?.trimEnd(),
assetType = itemContent.assetType,
senderId = sender,
senderProfile = senderProfile,
mode = TimelineItemLocationContent.Mode.Live(
lastKnownLocation = lastKnownLocation,
isActive = itemContent.isLive,
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
endTimestamp = itemContent.endTimestamp,
),
)
}
}
}

View file

@ -150,13 +150,11 @@ class TimelineItemContentMessageFactory(
)
} else {
TimelineItemLocationContent(
body = body,
location = location,
description = messageType.description,
senderId = senderId,
senderProfile = senderProfile,
assetType = messageType.assetType,
mode = TimelineItemLocationContent.Mode.Static
mode = TimelineItemLocationContent.Mode.Static(location = location)
)
}
}

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)),
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,17 +19,25 @@ 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 body: String,
val senderId: UserId,
val senderProfile: ProfileDetails,
val location: Location,
val description: String? = null,
val assetType: AssetType? = null,
val mode: Mode,
) : TimelineItemEventContent {
val pinVariant = when (mode) {
val location = when (mode) {
is Mode.Live -> mode.lastKnownLocation
is Mode.Static -> mode.location
}
/**
* The pin variant to display on the map.
* Returns a default variant when location is null (map will show loading placeholder anyway).
*/
val pinVariant: PinVariant = when (mode) {
is Mode.Live -> {
if (mode.isActive) {
PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true)
@ -35,7 +45,7 @@ data class TimelineItemLocationContent(
PinVariant.StaleLocation
}
}
Mode.Static -> {
is Mode.Static -> {
when (assetType) {
AssetType.PIN -> PinVariant.PinnedLocation
AssetType.SENDER,
@ -53,9 +63,57 @@ data class TimelineItemLocationContent(
)
sealed interface Mode {
data object Static : Mode
data class Live(val isActive: Boolean) : Mode
data class Static(
val location: Location,
) : Mode
data class Live(
val lastKnownLocation: Location?,
val isActive: Boolean,
val endsAt: String,
val endTimestamp: Long,
val canStop: Boolean = false,
) : Mode {
val isLoading = lastKnownLocation == null && isActive
}
}
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 (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

@ -18,24 +18,56 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
override val values: Sequence<TimelineItemLocationContent>
get() = sequenceOf(
aTimelineItemLocationContent(),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
canStop = true,
lastKnownLocation = aLocation()
),
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = null
),
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = false,
endsAt = "",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
),
)
}
fun aTimelineItemLocationContent(
body: String = "",
senderId: UserId = UserId("@sender:matrix.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(),
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static,
description: String? = null,
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
) = TimelineItemLocationContent(
body = body,
location = Location(
lat = 52.2445,
lon = 0.7186,
accuracy = 5000f,
),
senderId = senderId,
senderProfile = senderProfile,
mode = mode
description = description,
mode = mode,
)
fun aLocation() = Location(
lat = 52.2445,
lon = 0.7186,
accuracy = 5000f,
)

View file

@ -41,7 +41,10 @@ class DefaultMessageSummaryFormatter(
is TimelineItemTextBasedContent -> content.plainText
is TimelineItemProfileChangeContent -> content.body
is TimelineItemStateContent -> content.body
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
is TimelineItemLocationContent -> when (content.mode) {
is TimelineItemLocationContent.Mode.Live -> context.getString(CommonStrings.common_shared_live_location)
is TimelineItemLocationContent.Mode.Static -> context.getString(CommonStrings.common_shared_location)
}
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent -> content.question

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
@ -72,6 +73,8 @@ internal fun aTimelineItemContentFactory(
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
sessionId = matrixClient.sessionId,
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
)
internal fun TestScope.aTimelineItemsFactory(

View file

@ -78,9 +78,7 @@ import org.robolectric.RobolectricTestRunner
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@Suppress("LargeClass")
@RunWith(RobolectricTestRunner::class)
class TimelineItemContentMessageFactoryTest {
@Suppress("LargeClass") @RunWith(RobolectricTestRunner::class) class TimelineItemContentMessageFactoryTest {
@Test
fun `test create OtherMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory()
@ -110,11 +108,9 @@ class TimelineItemContentMessageFactoryTest {
eventId = AN_EVENT_ID,
)
val expected = TimelineItemLocationContent(
body = "body",
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(),
)
@ -166,16 +162,11 @@ class TimelineItemContentMessageFactoryTest {
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
) as TimelineItemTextContent
val expected = TimelineItemTextContent(
body = "https://www.example.org",
htmlDocument = null,
isEdited = false,
formattedBody = buildSpannedString {
inSpans(URLSpan("https://www.example.org")) {
append("https://www.example.org")
}
val expected = TimelineItemTextContent(body = "https://www.example.org", htmlDocument = null, isEdited = false, formattedBody = buildSpannedString {
inSpans(URLSpan("https://www.example.org")) {
append("https://www.example.org")
}
)
})
assertThat(result.body).isEqualTo(expected.body)
assertThat(result.htmlDocument).isEqualTo(expected.htmlDocument)
assertThat(result.plainText).isEqualTo(expected.plainText)
@ -200,9 +191,7 @@ class TimelineItemContentMessageFactoryTest {
append("and manually added link")
}
}.toSpannable()
val sut = createTimelineItemContentMessageFactory(
domConverterTransform = { expected }
)
val sut = createTimelineItemContentMessageFactory(domConverterTransform = { expected })
val result = sut.create(
content = createMessageContent(
type = TextMessageType(
@ -219,9 +208,7 @@ class TimelineItemContentMessageFactoryTest {
@Test
fun `test create TextMessageType with unknown formatted body does nothing`() = runTest {
val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { it }
)
val sut = createTimelineItemContentMessageFactory(htmlConverterTransform = { it })
val result = sut.create(
content = createMessageContent(
type = TextMessageType(
@ -356,10 +343,10 @@ class TimelineItemContentMessageFactoryTest {
formattedCaption = null,
source = MediaSource("url"),
info = AudioInfo(
duration = 1.minutes,
size = 123L,
mimetype = MimeTypes.Mp3,
)
duration = 1.minutes,
size = 123L,
mimetype = MimeTypes.Mp3,
)
),
isEdited = true,
),
@ -597,16 +584,16 @@ class TimelineItemContentMessageFactoryTest {
formattedCaption = null,
source = MediaSource("url"),
info = FileInfo(
mimetype = MimeTypes.Pdf,
size = 123L,
thumbnailInfo = ThumbnailInfo(
height = 10L,
width = 5L,
mimetype = MimeTypes.Jpeg,
size = 111L,
),
thumbnailSource = MediaSource("url_thumbnail"),
)
mimetype = MimeTypes.Pdf,
size = 123L,
thumbnailInfo = ThumbnailInfo(
height = 10L,
width = 5L,
mimetype = MimeTypes.Jpeg,
size = 111L,
),
thumbnailSource = MediaSource("url_thumbnail"),
)
),
isEdited = true,
),

View file

@ -117,7 +117,7 @@ class DefaultRoomLatestEventFormatter(
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is LiveLocationContent -> {
val message = sp.getString(CommonStrings.common_shared_location)
val message = sp.getString(CommonStrings.common_shared_live_location)
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)

View file

@ -15,10 +15,19 @@ import io.element.android.libraries.matrix.api.core.UserId
data class LiveLocationShare(
/** The user who is sharing their location. */
val userId: UserId,
/** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */
val lastGeoUri: String,
/** The timestamp of the last location update. */
val lastTimestamp: Long,
/** Whether the live location share is still active. */
val isLive: Boolean,
/** The last known location if any. */
val lastLocation: LastLocation?,
/** The timestamp when location sharing started, in milliseconds.*/
val startTimestamp: Long,
/** The timestamp when location sharing ends, in milliseconds. */
val endTimestamp: Long,
)
data class LastLocation(
/** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */
val geoUri: String,
/** The timestamp of the last location update. */
val timestamp: Long,
/** The asset of the last location update. */
val assetType: AssetType,
)

View file

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

View file

@ -44,6 +44,8 @@ import io.element.android.libraries.matrix.impl.mapper.map
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.room.threads.RustThreadsListService
import io.element.android.libraries.matrix.impl.roomdirectory.map
@ -511,7 +513,7 @@ class JoinedRustRoom(
}
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
TODO("Not implemented yet")
return innerRoom.liveLocationSharesFlow().timedByExpiry(systemClock::epochMillis)
}
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {

View file

@ -0,0 +1,74 @@
/*
* 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.core.UserId
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.impl.util.cancelAndDestroy
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import org.matrix.rustcomponents.sdk.LiveLocationShareListener
import org.matrix.rustcomponents.sdk.LiveLocationShareUpdate
import org.matrix.rustcomponents.sdk.RoomInterface
import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare
fun RoomInterface.liveLocationSharesFlow(): Flow<List<LiveLocationShare>> {
fun MutableList<LiveLocationShare>.applyUpdate(update: LiveLocationShareUpdate) {
when (update) {
is LiveLocationShareUpdate.Append -> addAll(update.values.map { it.into() })
is LiveLocationShareUpdate.Clear -> clear()
is LiveLocationShareUpdate.Insert -> add(update.index.toInt(), update.value.into())
is LiveLocationShareUpdate.PopBack -> if (isNotEmpty()) removeAt(lastIndex)
is LiveLocationShareUpdate.PopFront -> if (isNotEmpty()) removeAt(0)
is LiveLocationShareUpdate.PushBack -> add(update.value.into())
is LiveLocationShareUpdate.PushFront -> add(0, update.value.into())
is LiveLocationShareUpdate.Remove -> removeAt(update.index.toInt())
is LiveLocationShareUpdate.Reset -> {
clear()
addAll(update.values.map { it.into() })
}
is LiveLocationShareUpdate.Set -> set(update.index.toInt(), update.value.into())
is LiveLocationShareUpdate.Truncate -> subList(update.length.toInt(), size).clear()
}
}
return callbackFlow {
val liveLocationShares = liveLocationShares()
val shares: MutableList<LiveLocationShare> = ArrayList()
val taskHandle = liveLocationShares.subscribe(object : LiveLocationShareListener {
override fun onUpdate(updates: List<LiveLocationShareUpdate>) {
for (update in updates) {
shares.applyUpdate(update)
}
trySend(shares)
}
})
awaitClose {
taskHandle.cancelAndDestroy()
liveLocationShares.destroy()
}
}.buffer(Channel.UNLIMITED)
}
private fun RustLiveLocationShare.into(): LiveLocationShare {
return LiveLocationShare(
userId = UserId(userId),
lastLocation = lastLocation?.let {
LastLocation(
geoUri = it.location.geoUri,
timestamp = it.ts.toLong(),
assetType = it.location.asset.into(),
)
},
startTimestamp = startTs.toLong(),
endTimestamp = (startTs + timeout).toLong()
)
}

View file

@ -0,0 +1,55 @@
/*
* 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

@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.timeline.item.event
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.LiveLocationInfo
import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
@ -19,6 +20,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
@ -33,8 +35,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.location.into
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.BeaconInfo
import org.matrix.rustcomponents.sdk.EmbeddedEventDetails
import org.matrix.rustcomponents.sdk.MsgLikeContent
import org.matrix.rustcomponents.sdk.MsgLikeKind
@ -108,8 +112,14 @@ class TimelineEventContentMapper(
)
}
is MsgLikeKind.LiveLocation -> {
// Live location messages are a special kind of message that we want to treat as unknown content for now
UnknownContent
LiveLocationContent(
isLive = kind.content.isLive,
startTimestamp = kind.content.ts.toLong(),
description = kind.content.description,
timeout = kind.content.timeoutMs.toLong(),
assetType = kind.content.assetType.into(),
locations = kind.content.locations.map { location -> location.map() }
)
}
is MsgLikeKind.Other -> UnknownContent
}
@ -259,3 +269,11 @@ private fun RustEncryptedMessage.map(): UnableToDecryptContent.Data {
RustEncryptedMessage.Unknown -> UnableToDecryptContent.Data.Unknown
}
}
private fun BeaconInfo.map(): LiveLocationInfo {
return LiveLocationInfo(
description = description,
geoUri = geoUri,
timestamp = ts.toLong(),
)
}

View file

@ -0,0 +1,147 @@
/*
* 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.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
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,
startTimestamp = 0L,
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,8 @@ 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()
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0c2adbbce73e57c601821ea9b511913b3df0efbdd3d42643fabe0d39655e04e6
size 438908
oid sha256:855f37c9ca2dc6ddc9a699e42a1a3c784c26911552174735f46f31ad2588e977
size 295172

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3baf32fc12535ffe246264dfe1f20db1fddddb970f86953e9877251dc3f74d3d
size 173356
oid sha256:e99b3d1c62e4907f55beefa2414ff6324c62133ab723c13b68c906331e8ee072
size 118086

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:359960ea4b7be5ff9d766565007291f8585223052483736e17d4532c5f8af0c6
size 252728
oid sha256:56a86695d2c25c94a8e79c27c8f525229cc348201073566909f83a681b37ac30
size 251329

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b775500b1fdc6294d94dcb1f07f74c515c3eae31bc55d0faacfd86ff7bd1da3
size 105526
oid sha256:0c2b2a7071bd1c21ff706525e2416169507fef364485c651c429baefa15d4c9f
size 104240

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2550638ee12b4181cea31caff0b5838a9cdb3a180c01d1188bc7c2726051b863
size 16578
oid sha256:aaafea9efc1000495ee469797239b82193844caa3d6f98c0c3a4344a536a1798
size 17155

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c880e4d01495868b3f0689d20d3cbf2050d6261be936421343bc1ac210aabeec
size 15959
oid sha256:1f113f8979679c0673e4cc1f691140bc570b6826bea23eecc403f2fbfd3f6d09
size 16460

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb
size 37572
oid sha256:ba0285628cb8f18c5d666e6727b213d3c7674d1782a7364c7b72ff906ad57eff
size 19684

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08
size 35976
oid sha256:ed64e57bea072d5bdf232133c365db043f117e35ef180aa12c9b05bff40a1a92
size 16437

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7
size 31530
oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb
size 37572

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73
size 19104
oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08
size 35976

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22
size 19228
oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7
size 31530

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73
size 19104

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22
size 19228

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474
size 36084
oid sha256:4356f7de986f803b890d3bc95afdefd01e9eb42d775836c647a6d9fafe3bcc4a
size 19189

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed
size 34500
oid sha256:0ce9e4f5911a6dfd35655f362d122f95690a651d7c05e5ef6c4ea0621be2e628
size 15783

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a
size 30345
oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474
size 36084

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7
size 18715
oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed
size 34500

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b
size 18842
oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a
size 30345

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7
size 18715

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b
size 18842

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553
size 144841
oid sha256:3daba890afd2533e1746f1ffd0c535674a4a238e4591da2a6bcbe31716d26858
size 143676

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553
size 144841
oid sha256:82ed52f907048490ffb63ea9b33274703c1d92d4131cca0320816cc6949bea5e
size 113727

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553
size 144841
oid sha256:ae3a754c163f83e69ab062c8e638a6e620948081d70105ddb00c32dda7c2ba74
size 119927

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:757d555f948a637952aed4b0ad2745212f6a9ab279d172b0a1649fdf547c5a0c
size 120027

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2f0cac0a30818ae7a2e1439d403654052bad931c8ba136bdaf37f16272a0b858
size 20160

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573
size 58482
oid sha256:463a2e544b2806f4082c813ec3ba8c6ce0ad0ebcd8ee32666806757a90c248f5
size 57131

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573
size 58482
oid sha256:fb0d86877e5d049d618836846cd8ea97b40d1d66e7bd8d50639280fb3e829372
size 38499

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573
size 58482
oid sha256:b41879f5159a126b0641c66bed13470756a914cc038dd239d40d359661784b71
size 40696

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:38fa52e6e755d86aa79a029427f04146a3939ac2038cc15717f5aec84ce3be20
size 40870

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:09893d3f2bfa2c1f5b99368c21d070ea1aa460d576722410fc2ca85c4eab238c
size 15361

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341
size 374820
oid sha256:4a0827eb23b48bd03fde5078598cb7827459a213ff0a693890581628330f6939
size 66845

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341
size 374820
oid sha256:a8fa43d296a984d242ed56f7879de4c38f3da94147c365c424248eba2c4ad61d
size 371263

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c
size 153022
oid sha256:bea070091132156a809da1c185e2f94333e62f8ea9ae7d6d1b789adffb869522
size 55538

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c
size 153022
oid sha256:bcd9e0934b5f6808d58a3d3506641f173c4b223d78c605de09832d3741e8834f
size 149300