Merge pull request #6611 from element-hq/feature/fga/live_location_rendering
WIP : live location rendering
This commit is contained in:
commit
1195499eb7
74 changed files with 1256 additions and 273 deletions
|
|
@ -24,5 +24,7 @@ sealed interface ShowLocationMode : Parcelable {
|
||||||
) : ShowLocationMode
|
) : ShowLocationMode
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object Live : ShowLocationMode
|
data class Live(
|
||||||
|
val senderId: UserId
|
||||||
|
) : ShowLocationMode
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
BIN
features/location/api/src/main/res/drawable-night/stale_map.png
Normal file
BIN
features/location/api/src/main/res/drawable-night/stale_map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
features/location/api/src/main/res/drawable/stale_map.png
Normal file
BIN
features/location/api/src/main/res/drawable/stale_map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73
|
||||||
|
size 19104
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22
|
||||||
|
size 19228
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7
|
||||||
|
size 18715
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b
|
||||||
|
size 18842
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:757d555f948a637952aed4b0ad2745212f6a9ab279d172b0a1649fdf547c5a0c
|
||||||
|
size 120027
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2f0cac0a30818ae7a2e1439d403654052bad931c8ba136bdaf37f16272a0b858
|
||||||
|
size 20160
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:38fa52e6e755d86aa79a029427f04146a3939ac2038cc15717f5aec84ce3be20
|
||||||
|
size 40870
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:09893d3f2bfa2c1f5b99368c21d070ea1aa460d576722410fc2ca85c4eab238c
|
||||||
|
size 15361
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue