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 1195499eb7
74 changed files with 1256 additions and 273 deletions

View file

@ -24,5 +24,7 @@ sealed interface ShowLocationMode : Parcelable {
) : ShowLocationMode ) : ShowLocationMode
@Parcelize @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 package io.element.android.features.location.api
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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 androidx.compose.ui.unit.dp
import coil3.Extras import coil3.Extras
import coil3.compose.AsyncImagePainter 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. * 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 @Composable
fun StaticMapView( fun StaticMapView(
lat: Double, location: Location?,
lon: Double,
zoom: Double, zoom: Double,
pinVariant: PinVariant, pinVariant: PinVariant,
contentDescription: String?, contentDescription: String?,
@ -56,50 +65,111 @@ fun StaticMapView(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val context = LocalContext.current // Case 1: Stale location - show stale map with stale pin, no fetching
var retryHash by remember { mutableIntStateOf(0) } when {
val builder = remember { StaticMapUrlBuilder() } pinVariant is PinVariant.StaleLocation -> {
val painter = rememberAsyncImagePainter( StaleMapContent(
model = if (constraints.isZero) { pinVariant = pinVariant,
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). contentDescription = contentDescription,
null width = maxWidth,
} else { height = maxHeight,
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 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() @Composable
if (collectedState.value is AsyncImagePainter.State.Success) { 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( Image(
painter = painter, painter = painter,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight), modifier = Modifier.size(width = maxWidth, height = maxHeight),
// The returned image can be smaller than the requested size due to the static maps API having // 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. // a max width and height of 2048 px. We apply ContentScale.Fit to handle this.
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
} else { }
else -> {
StaticMapPlaceholder( StaticMapPlaceholder(
showProgress = collectedState.value.isLoading(), painter = painterResource(R.drawable.blurred_map),
canReload = builder.isServiceAvailable(), canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
contentDescription = contentDescription, contentDescription = contentDescription,
width = maxWidth, width = maxWidth,
height = maxHeight, 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 @PreviewsDayNight
@Composable @Composable
internal fun StaticMapViewPreview() = ElementPreview { internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView( StaticMapView(
lat = 0.0, location = Location(0.0, 0.0),
lon = 0.0,
zoom = 0.0, zoom = 0.0,
contentDescription = null, contentDescription = null,
pinVariant = PinVariant.PinnedLocation, pinVariant = PinVariant.PinnedLocation,

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable 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.painter.Painter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.features.location.api.R
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.Icon import io.element.android.libraries.designsystem.theme.components.Icon
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 import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun StaticMapPlaceholder( internal fun StaticMapPlaceholder(
showProgress: Boolean, painter: Painter,
canReload: Boolean, canReload: Boolean,
contentDescription: String?, contentDescription: String?,
width: Dp, width: Dp,
@ -46,17 +46,15 @@ internal fun StaticMapPlaceholder(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = modifier modifier = modifier
.size(width = width, height = height) .size(width = width, height = height)
.then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick)) .clickable(enabled = canReload, onClick = onLoadMapClick)
) { ) {
Image( Image(
painter = painterResource(id = R.drawable.blurred_map), painter = painter,
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = ContentScale.FillBounds, contentScale = ContentScale.FillBounds,
modifier = Modifier.size(width = width, height = height) modifier = Modifier.size(width = width, height = height)
) )
if (showProgress) { if (canReload) {
CircularProgressIndicator()
} else if (canReload) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -77,13 +75,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview {
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
listOf( listOf(false, true)
true to false, .forEach { canReload ->
false to true,
false to false,
).forEach { (showProgress, canReload) ->
StaticMapPlaceholder( StaticMapPlaceholder(
showProgress = showProgress, painter = painterResource(R.drawable.blurred_map),
canReload = canReload, canReload = canReload,
contentDescription = null, contentDescription = null,
width = 400.dp, 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(
text = item.formattedTimestamp, text = if (item.isLive) stringResource(CommonStrings.screen_room_live_location_banner) else item.formattedTimestamp,
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary, color = if (item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing 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.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.libraries.core.data.tryOrNull 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 io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.camera.rememberCameraState
@ -112,8 +115,11 @@ fun MapBottomSheetScaffold(
modifier = Modifier, modifier = Modifier,
sheetPeekHeight = sheetPeekHeight, sheetPeekHeight = sheetPeekHeight,
sheetContent = { sheetContent = {
sheetContent(sheetPadding) val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
sheetContent(sheetPadding)
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}, },
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
sheetDragHandle = sheetDragHandle, 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.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject 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.api.ShowLocationMode
import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults 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.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.Presenter 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.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
@AssistedInject @AssistedInject
class ShowLocationPresenter( class ShowLocationPresenter(
@ -46,6 +54,7 @@ class ShowLocationPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val joinedRoom: JoinedRoom,
) : Presenter<ShowLocationState> { ) : Presenter<ShowLocationState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@ -96,9 +105,9 @@ class ShowLocationPresenter(
} }
} }
val locationShares = remember { val locationShares = when (mode) {
when (mode) { is ShowLocationMode.Static -> {
is ShowLocationMode.Static -> { remember {
val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true) val relativeTime = dateFormatter.format(timestamp = mode.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,
@ -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( return ShowLocationState(
dialogState = dialogState, dialogState = dialogState,
locationShares = locationShares, locationShares = locationShares,
focusedLocation = focusedLocation,
hasLocationPermission = permissionsState.isAnyGranted, hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation, isTrackMyLocation = isTrackMyLocation,
isLive = mode is ShowLocationMode.Live,
appName = appName, appName = appName,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )

View file

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

View file

@ -21,6 +21,8 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
override val values: Sequence<ShowLocationState> override val values: Sequence<ShowLocationState>
get() = sequenceOf( get() = sequenceOf(
aShowLocationState(), aShowLocationState(),
aShowLocationState(isLive = true),
aShowLocationState(isLive = true, locationShares = emptyList()),
aShowLocationState( aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
), ),
@ -44,8 +46,10 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
private const val APP_NAME = "ApplicationName" private const val APP_NAME = "ApplicationName"
fun aShowLocationState( fun aShowLocationState(
isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List<LocationShareItem> = listOf(aLocationShareItem()), locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
focusedLocation: LocationShareItem? = locationShares.firstOrNull(),
hasLocationPermission: Boolean = false, hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false, isTrackMyLocation: Boolean = false,
appName: String = APP_NAME, appName: String = APP_NAME,
@ -54,9 +58,11 @@ fun aShowLocationState(
return ShowLocationState( return ShowLocationState(
dialogState = constraintsDialogState, dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(), locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation,
hasLocationPermission = hasLocationPermission, hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation, isTrackMyLocation = isTrackMyLocation,
appName = appName, appName = appName,
isLive = isLive,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -70,10 +76,10 @@ fun aLocationShareItem(
url = null, url = null,
size = AvatarSize.UserListItem, size = AvatarSize.UserListItem,
), ),
formattedTimestamp: String = "Shared 1 min ago",
location: Location = Location(1.23, 2.34, 4f),
isLive: Boolean = false, isLive: Boolean = false,
assetType: AssetType? = null, assetType: AssetType? = null,
formattedTimestamp: String = "Shared 1 min ago",
location: Location = Location(1.23, 2.34, 4f),
) = LocationShareItem( ) = LocationShareItem(
userId = userId, userId = userId,
displayName = displayName, 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.clickable
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
@ -21,11 +24,15 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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
@ -65,35 +72,33 @@ fun ShowLocationView(
onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) }, onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) },
) )
val initialPosition = remember { val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
if (state.locationShares.isEmpty()) { var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) }
MapDefaults.defaultCameraPosition LaunchedEffect(state.focusedLocation) {
} else { if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) {
val firstLocation = state.locationShares.first().location hasAnimatedToFocusedLocation = true
CameraPosition( val position = CameraPosition(
target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon), target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon),
zoom = MapDefaults.DEFAULT_ZOOM zoom = MapDefaults.DEFAULT_ZOOM
) )
cameraState.position = position
} }
} }
val cameraState = rememberCameraState(firstPosition = initialPosition)
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
LaunchedEffect(cameraState.isCameraMoving) { LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) { if (cameraState.moveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShowLocationEvent.TrackMyLocation(false)) state.eventSink(ShowLocationEvent.TrackMyLocation(false))
} }
} }
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
val scaffoldState = rememberBottomSheetScaffoldState( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState( bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
initialValue =
if (state.isSheetDraggable) {
SheetValue.PartiallyExpanded
} else {
SheetValue.Expanded
}
)
) )
LaunchedEffect(state.isSheetDraggable) {
if (!state.isSheetDraggable) {
scaffoldState.bottomSheetState.expand()
}
}
MapBottomSheetScaffold( MapBottomSheetScaffold(
sheetDragHandle = if (state.isSheetDraggable) { sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() } { BottomSheetDefaults.DragHandle() }
@ -116,29 +121,46 @@ fun ShowLocationView(
}, },
sheetContent = { sheetPaddings -> sheetContent = { sheetPaddings ->
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
Spacer(Modifier.height(20.dp)) if (!state.isSheetDraggable) {
Text( // If sheet is draggable the DragHandle has already some padding
text = stringResource(CommonStrings.screen_static_location_sheet_title), Spacer(Modifier.height(20.dp))
style = ElementTheme.typography.fontBodyLgMedium, }
color = ElementTheme.colors.textPrimary, if (state.locationShares.isEmpty()) {
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), Text(
) text = stringResource(CommonStrings.screen_live_location_sheet_nobody_sharing),
state.locationShares.forEach { locationShare -> style = ElementTheme.typography.fontBodyLgMedium,
LocationShareRow( color = ElementTheme.colors.textPrimary,
item = locationShare, modifier = Modifier
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, .fillMaxWidth()
modifier = Modifier.clickable { .padding(all = 16.dp),
state.eventSink(ShowLocationEvent.TrackMyLocation(false)) textAlign = TextAlign.Center,
val position = CameraPosition(
padding = sheetPaddings,
target = Position(locationShare.location.lon, locationShare.location.lat),
zoom = MapDefaults.DEFAULT_ZOOM
)
coroutineScope.launch {
cameraState.animateTo(finalPosition = position)
}
}
) )
} 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 = { 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.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.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.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@ -43,7 +44,8 @@ class DefaultShowLocationEntryPointTest {
locationActions = FakeLocationActions(), locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
dateFormatter = FakeDateFormatter(), dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider() stringProvider = FakeStringProvider(),
joinedRoom = FakeJoinedRoom(),
) )
}, },
analyticsService = FakeAnalyticsService(), 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.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.dateformatter.test.FakeDateFormatter 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.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.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
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.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()
@ -51,13 +59,15 @@ class ShowLocationPresenterTest {
assetType = null, assetType = null,
), ),
locationActions: FakeLocationActions = fakeLocationActions, locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
) = ShowLocationPresenter( ) = ShowLocationPresenter(
mode = mode, mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter }, permissionsPresenterFactory = { fakePermissionsPresenter },
locationActions = locationActions, locationActions = locationActions,
buildMeta = fakeBuildMeta, buildMeta = fakeBuildMeta,
dateFormatter = fakeDateFormatter, dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider() stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
) )
@Test @Test
@ -318,4 +328,159 @@ class ShowLocationPresenterTest {
assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1) 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 -> { is TimelineItemLocationContent -> {
val mode = ShowLocationMode.Static( val mode = when (event.content.mode) {
location = event.content.location, is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId)
senderName = event.safeSenderName, is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static(
senderId = event.senderId, location = event.content.mode.location,
senderAvatarUrl = event.senderAvatar.url, senderName = event.safeSenderName,
timestamp = event.sentTimeMillis, senderId = event.senderId,
assetType = event.content.assetType, senderAvatarUrl = event.senderAvatar.url,
) timestamp = event.sentTimeMillis,
NavTarget.LocationViewer( assetType = event.content.assetType,
mode = mode )
).takeIf { locationService.isServiceAvailable() } }
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
} }
else -> null else -> null
} }

View file

@ -289,7 +289,11 @@ private fun MessageSummary(
is TimelineItemRedactedContent, is TimelineItemRedactedContent,
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) } is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
is TimelineItemLocationContent -> { 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 -> { is TimelineItemImageContent -> {
content = { ContentForBody(event.content.bestDescription) } content = { ContentForBody(event.content.bestDescription) }

View file

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

View file

@ -200,6 +200,7 @@ class TimelinePresenter(
is TimelineEvent.EditPoll -> { is TimelineEvent.EditPoll -> {
navigator.navigateToEditPoll(event.pollStartId) navigator.navigateToEditPoll(event.pollStartId)
} }
is TimelineEvent.StopLiveLocationShare -> Unit
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch { is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce) focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
delay(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.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
@ -677,6 +678,7 @@ private fun MessageEventBubbleContent(
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) )
} }
TimestampPosition.Hidden -> Box(modifier) { content {} }
} }
} }
@ -772,11 +774,17 @@ private fun MessageEventBubbleContent(
} }
} }
val timestampPosition = when (event.content) { val timestampPosition = when (val content = event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
is TimelineItemStickerContent, is TimelineItemStickerContent -> TimestampPosition.Overlay
is TimelineItemLocationContent -> 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

@ -22,7 +22,12 @@ enum class TimestampPosition {
/** /**
* Timestamp should always be rendered below the timeline event content (eg. poll). * Timestamp should always be rendered below the timeline event content (eg. poll).
*/ */
Below; Below,
/**
* Timestamp should be hidden.
*/
Hidden;
companion object { 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.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,10 +72,13 @@ fun TimelineItemEventContentView(
onContentLayoutChange = onContentLayoutChange, onContentLayoutChange = onContentLayoutChange,
modifier = modifier modifier = modifier
) )
is TimelineItemLocationContent -> TimelineItemLocationView( is TimelineItemLocationContent -> {
content = content, TimelineItemLocationView(
modifier = modifier content = content.ensureActiveLiveLocation(),
) onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
modifier = modifier
)
}
is TimelineItemImageContent -> TimelineItemImageView( is TimelineItemImageContent -> TimelineItemImageView(
content = content, content = content,
hideMediaContent = hideMediaContent, hideMediaContent = hideMediaContent,

View file

@ -8,33 +8,153 @@
package io.element.android.features.messages.impl.timeline.components.event 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.fillMaxWidth
import androidx.compose.foundation.layout.heightIn 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.runtime.Composable
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.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.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.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.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 @Composable
fun TimelineItemLocationView( fun TimelineItemLocationView(
content: TimelineItemLocationContent, content: TimelineItemLocationContent,
onStopLiveLocationClick: () -> Unit,
modifier: Modifier = Modifier, 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 modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 188.dp), .background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
pinVariant = content.pinVariant, .padding(horizontal = 8.dp, vertical = 8.dp),
lat = content.location.lat, verticalAlignment = Alignment.CenterVertically,
lon = content.location.lon, ) {
zoom = 15.0, val iconShape = RoundedCornerShape(8.dp)
contentDescription = content.body 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 @PreviewsDayNight
@ -43,5 +163,6 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat
ElementPreview { ElementPreview {
TimelineItemLocationView( TimelineItemLocationView(
content = content, 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.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent 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.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.EventId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId 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.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent 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.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
@Inject @Inject
class TimelineItemContentFactory( class TimelineItemContentFactory(
@ -50,6 +54,8 @@ class TimelineItemContentFactory(
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory, private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory, private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
private val sessionId: SessionId, private val sessionId: SessionId,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
) { ) {
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
return create( return create(
@ -103,19 +109,24 @@ class TimelineItemContentFactory(
val lastKnownLocation = itemContent.locations.mapNotNull { beacon -> val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
Location.fromGeoUri(beacon.geoUri) Location.fromGeoUri(beacon.geoUri)
}.lastOrNull() }.lastOrNull()
if (lastKnownLocation != null) {
TimelineItemLocationContent( val endsAt = dateFormatter.format(
body = itemContent.body.trimEnd(), timestamp = itemContent.endTimestamp,
description = itemContent.description?.trimEnd(), mode = DateFormatterMode.TimeOnly
assetType = itemContent.assetType, )
senderId = sender, // Always create content, location can be null for "loading/waiting" state
senderProfile = senderProfile, TimelineItemLocationContent(
location = lastKnownLocation, description = itemContent.description?.trimEnd(),
mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive) assetType = itemContent.assetType,
) senderId = sender,
} else { senderProfile = senderProfile,
TimelineItemUnknownContent 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 { } else {
TimelineItemLocationContent( TimelineItemLocationContent(
body = body,
location = location,
description = messageType.description, description = messageType.description,
senderId = senderId, senderId = senderId,
senderProfile = senderProfile, senderProfile = senderProfile,
assetType = messageType.assetType, assetType = messageType.assetType,
mode = TimelineItemLocationContent.Mode.Static mode = TimelineItemLocationContent.Mode.Static(location = location)
) )
} }
} }

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)), 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,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.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 body: String,
val senderId: UserId, val senderId: UserId,
val senderProfile: ProfileDetails, val senderProfile: ProfileDetails,
val location: Location,
val description: String? = null, val description: String? = null,
val assetType: AssetType? = null, val assetType: AssetType? = null,
val mode: Mode, val mode: Mode,
) : TimelineItemEventContent { ) : 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 -> { is Mode.Live -> {
if (mode.isActive) { if (mode.isActive) {
PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true) PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true)
@ -35,7 +45,7 @@ data class TimelineItemLocationContent(
PinVariant.StaleLocation PinVariant.StaleLocation
} }
} }
Mode.Static -> { is Mode.Static -> {
when (assetType) { when (assetType) {
AssetType.PIN -> PinVariant.PinnedLocation AssetType.PIN -> PinVariant.PinnedLocation
AssetType.SENDER, AssetType.SENDER,
@ -53,9 +63,57 @@ data class TimelineItemLocationContent(
) )
sealed interface Mode { sealed interface Mode {
data object Static : Mode data class Static(
data class Live(val isActive: Boolean) : Mode 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" 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> override val values: Sequence<TimelineItemLocationContent>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemLocationContent(), aTimelineItemLocationContent(),
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)), aTimelineItemLocationContent(
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)), 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( fun aTimelineItemLocationContent(
body: String = "",
senderId: UserId = UserId("@sender:matrix.org"), senderId: UserId = UserId("@sender:matrix.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(), senderProfile: ProfileDetails = aProfileDetailsReady(),
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static, description: String? = null,
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
) = TimelineItemLocationContent( ) = TimelineItemLocationContent(
body = body,
location = Location(
lat = 52.2445,
lon = 0.7186,
accuracy = 5000f,
),
senderId = senderId, senderId = senderId,
senderProfile = senderProfile, 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 TimelineItemTextBasedContent -> content.plainText
is TimelineItemProfileChangeContent -> content.body is TimelineItemProfileChangeContent -> content.body
is TimelineItemStateContent -> 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 TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed) is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent -> content.question 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.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
@ -72,6 +73,8 @@ internal fun aTimelineItemContentFactory(
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
sessionId = matrixClient.sessionId, sessionId = matrixClient.sessionId,
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
) )
internal fun TestScope.aTimelineItemsFactory( internal fun TestScope.aTimelineItemsFactory(

View file

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

View file

@ -117,7 +117,7 @@ class DefaultRoomLatestEventFormatter(
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
} }
is LiveLocationContent -> { is LiveLocationContent -> {
val message = sp.getString(CommonStrings.common_shared_location) val message = sp.getString(CommonStrings.common_shared_live_location)
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
} }
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) 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( data class LiveLocationShare(
/** The user who is sharing their location. */ /** The user who is sharing their location. */
val userId: UserId, val userId: UserId,
/** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */ /** The last known location if any. */
val lastGeoUri: String, val lastLocation: LastLocation?,
/** The timestamp of the last location update. */ /** The timestamp when location sharing started, in milliseconds.*/
val lastTimestamp: Long, val startTimestamp: Long,
/** Whether the live location share is still active. */ /** The timestamp when location sharing ends, in milliseconds. */
val isLive: Boolean, 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 ) : EventContent
data class LiveLocationContent( data class LiveLocationContent(
val body: String,
val isLive: Boolean, val isLive: Boolean,
val description: String?, val description: String?,
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 endTimestamp = startTimestamp + timeout
}
data object LegacyCallInviteContent : EventContent 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.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.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.room.threads.RustThreadsListService import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.roomdirectory.map
@ -511,7 +513,7 @@ class JoinedRustRoom(
} }
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> { 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) { 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.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.ThreadId 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.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.EmbeddedEventInfo
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary 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.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent 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.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.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent 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.media.map
import io.element.android.libraries.matrix.impl.poll.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.join.map
import io.element.android.libraries.matrix.impl.room.location.into
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.BeaconInfo
import org.matrix.rustcomponents.sdk.EmbeddedEventDetails import org.matrix.rustcomponents.sdk.EmbeddedEventDetails
import org.matrix.rustcomponents.sdk.MsgLikeContent import org.matrix.rustcomponents.sdk.MsgLikeContent
import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.MsgLikeKind
@ -108,8 +112,14 @@ class TimelineEventContentMapper(
) )
} }
is MsgLikeKind.LiveLocation -> { is MsgLikeKind.LiveLocation -> {
// Live location messages are a special kind of message that we want to treat as unknown content for now LiveLocationContent(
UnknownContent 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 is MsgLikeKind.Other -> UnknownContent
} }
@ -259,3 +269,11 @@ private fun RustEncryptedMessage.map(): UnableToDecryptContent.Data {
RustEncryptedMessage.Unknown -> UnableToDecryptContent.Data.Unknown 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.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,8 @@ 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()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22 oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7
size 19228 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 version https://git-lfs.github.com/spec/v1
oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474 oid sha256:4356f7de986f803b890d3bc95afdefd01e9eb42d775836c647a6d9fafe3bcc4a
size 36084 size 19189

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a
size 18842 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 version https://git-lfs.github.com/spec/v1
oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 oid sha256:3daba890afd2533e1746f1ffd0c535674a4a238e4591da2a6bcbe31716d26858
size 144841 size 143676

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 oid sha256:ae3a754c163f83e69ab062c8e638a6e620948081d70105ddb00c32dda7c2ba74
size 144841 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 version https://git-lfs.github.com/spec/v1
oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 oid sha256:463a2e544b2806f4082c813ec3ba8c6ce0ad0ebcd8ee32666806757a90c248f5
size 58482 size 57131

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 oid sha256:b41879f5159a126b0641c66bed13470756a914cc038dd239d40d359661784b71
size 58482 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 version https://git-lfs.github.com/spec/v1
oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 oid sha256:4a0827eb23b48bd03fde5078598cb7827459a213ff0a693890581628330f6939
size 374820 size 66845

View file

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

View file

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

View file

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