Merge pull request #6611 from element-hq/feature/fga/live_location_rendering
WIP : live location rendering
This commit is contained in:
commit
e5f85592d9
74 changed files with 1256 additions and 273 deletions
|
|
@ -24,5 +24,7 @@ sealed interface ShowLocationMode : Parcelable {
|
|||
) : ShowLocationMode
|
||||
|
||||
@Parcelize
|
||||
data object Live : ShowLocationMode
|
||||
data class Live(
|
||||
val senderId: UserId
|
||||
) : ShowLocationMode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
package io.element.android.features.location.api
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.Extras
|
||||
import coil3.compose.AsyncImagePainter
|
||||
|
|
@ -38,11 +42,16 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|||
|
||||
/**
|
||||
* Shows a static map image downloaded via a third party service's static maps API.
|
||||
*
|
||||
* Handles 4 distinct cases:
|
||||
* 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching
|
||||
* 2. Null location - shows blurred placeholder, no pin, no loading
|
||||
* 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
|
||||
* 4. Success (location != null, loaded) - shows actual map with pin
|
||||
*/
|
||||
@Composable
|
||||
fun StaticMapView(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
location: Location?,
|
||||
zoom: Double,
|
||||
pinVariant: PinVariant,
|
||||
contentDescription: String?,
|
||||
|
|
@ -56,50 +65,111 @@ fun StaticMapView(
|
|||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var retryHash by remember { mutableIntStateOf(0) }
|
||||
val builder = remember { StaticMapUrlBuilder() }
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = if (constraints.isZero) {
|
||||
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
|
||||
null
|
||||
} else {
|
||||
ImageRequest.Builder(context)
|
||||
.data(
|
||||
builder.build(
|
||||
lat = lat,
|
||||
lon = lon,
|
||||
zoom = zoom,
|
||||
darkMode = darkMode,
|
||||
width = constraints.maxWidth,
|
||||
height = constraints.maxHeight,
|
||||
density = LocalDensity.current.density,
|
||||
)
|
||||
)
|
||||
.size(width = constraints.maxWidth, height = constraints.maxHeight)
|
||||
.apply {
|
||||
extras.set(Extras.Key("retry_hash"), retryHash).build()
|
||||
}
|
||||
.build()
|
||||
// Case 1: Stale location - show stale map with stale pin, no fetching
|
||||
when {
|
||||
pinVariant is PinVariant.StaleLocation -> {
|
||||
StaleMapContent(
|
||||
pinVariant = pinVariant,
|
||||
contentDescription = contentDescription,
|
||||
width = maxWidth,
|
||||
height = maxHeight,
|
||||
)
|
||||
}
|
||||
)
|
||||
// Case 2: Null location - show blurred placeholder, no pin, no loading
|
||||
location == null -> {
|
||||
StaticMapPlaceholder(
|
||||
painter = painterResource(R.drawable.blurred_map),
|
||||
canReload = false,
|
||||
contentDescription = contentDescription,
|
||||
width = maxWidth,
|
||||
height = maxHeight,
|
||||
onLoadMapClick = {}
|
||||
)
|
||||
}
|
||||
// Cases 3 & 4: Non-null location - fetch map
|
||||
else -> LoadableMapContent(
|
||||
location = location,
|
||||
zoom = zoom,
|
||||
pinVariant = pinVariant,
|
||||
contentDescription = contentDescription,
|
||||
darkMode = darkMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val collectedState = painter.state.collectAsState()
|
||||
if (collectedState.value is AsyncImagePainter.State.Success) {
|
||||
@Composable
|
||||
private fun BoxWithConstraintsScope.StaleMapContent(
|
||||
pinVariant: PinVariant,
|
||||
contentDescription: String?,
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.stale_map),
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
modifier = Modifier.size(width = width, height = height)
|
||||
)
|
||||
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this@StaleMapContent))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxWithConstraintsScope.LoadableMapContent(
|
||||
location: Location,
|
||||
zoom: Double,
|
||||
pinVariant: PinVariant,
|
||||
contentDescription: String?,
|
||||
darkMode: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var retryHash by remember { mutableIntStateOf(0) }
|
||||
val builder = remember { StaticMapUrlBuilder() }
|
||||
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = if (constraints.isZero) {
|
||||
// Avoid building a URL if any of the size constraints is zero
|
||||
null
|
||||
} else {
|
||||
ImageRequest.Builder(context)
|
||||
.data(
|
||||
builder.build(
|
||||
lat = location.lat,
|
||||
lon = location.lon,
|
||||
zoom = zoom,
|
||||
darkMode = darkMode,
|
||||
width = constraints.maxWidth,
|
||||
height = constraints.maxHeight,
|
||||
density = LocalDensity.current.density,
|
||||
)
|
||||
)
|
||||
.size(width = constraints.maxWidth, height = constraints.maxHeight)
|
||||
.apply {
|
||||
extras.set(Extras.Key("retry_hash"), retryHash).build()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
)
|
||||
|
||||
val state by painter.state.collectAsState()
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(width = maxWidth, height = maxHeight),
|
||||
// The returned image can be smaller than the requested size due to the static maps API having
|
||||
// a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
|
||||
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
|
||||
// a max width and height of 2048 px. We apply ContentScale.Fit to handle this.
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
|
||||
} else {
|
||||
}
|
||||
else -> {
|
||||
StaticMapPlaceholder(
|
||||
showProgress = collectedState.value.isLoading(),
|
||||
canReload = builder.isServiceAvailable(),
|
||||
painter = painterResource(R.drawable.blurred_map),
|
||||
canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
|
||||
contentDescription = contentDescription,
|
||||
width = maxWidth,
|
||||
height = maxHeight,
|
||||
|
|
@ -109,17 +179,11 @@ fun StaticMapView(
|
|||
}
|
||||
}
|
||||
|
||||
private fun AsyncImagePainter.State.isLoading(): Boolean {
|
||||
return this is AsyncImagePainter.State.Empty ||
|
||||
this is AsyncImagePainter.State.Loading
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun StaticMapViewPreview() = ElementPreview {
|
||||
StaticMapView(
|
||||
lat = 0.0,
|
||||
lon = 0.0,
|
||||
location = Location(0.0, 0.0),
|
||||
zoom = 0.0,
|
||||
contentDescription = null,
|
||||
pinVariant = PinVariant.PinnedLocation,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -27,14 +28,13 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
|||
import io.element.android.features.location.api.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun StaticMapPlaceholder(
|
||||
showProgress: Boolean,
|
||||
painter: Painter,
|
||||
canReload: Boolean,
|
||||
contentDescription: String?,
|
||||
width: Dp,
|
||||
|
|
@ -46,17 +46,15 @@ internal fun StaticMapPlaceholder(
|
|||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.size(width = width, height = height)
|
||||
.then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick))
|
||||
.clickable(enabled = canReload, onClick = onLoadMapClick)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.blurred_map),
|
||||
painter = painter,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
modifier = Modifier.size(width = width, height = height)
|
||||
)
|
||||
if (showProgress) {
|
||||
CircularProgressIndicator()
|
||||
} else if (canReload) {
|
||||
if (canReload) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
|
@ -77,13 +75,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview {
|
|||
modifier = Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
listOf(
|
||||
true to false,
|
||||
false to true,
|
||||
false to false,
|
||||
).forEach { (showProgress, canReload) ->
|
||||
listOf(false, true)
|
||||
.forEach { canReload ->
|
||||
StaticMapPlaceholder(
|
||||
showProgress = showProgress,
|
||||
painter = painterResource(R.drawable.blurred_map),
|
||||
canReload = canReload,
|
||||
contentDescription = null,
|
||||
width = 400.dp,
|
||||
|
|
|
|||
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 = item.formattedTimestamp,
|
||||
text = if (item.isLive) stringResource(CommonStrings.screen_room_live_location_banner) else item.formattedTimestamp,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
color = if (item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ package io.element.android.features.location.impl.common.ui
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
|
|
@ -43,6 +45,7 @@ import androidx.compose.ui.unit.max
|
|||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||
import io.element.android.features.location.impl.common.MapDefaults
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
||||
import org.maplibre.compose.camera.CameraState
|
||||
import org.maplibre.compose.camera.rememberCameraState
|
||||
|
|
@ -112,8 +115,11 @@ fun MapBottomSheetScaffold(
|
|||
modifier = Modifier,
|
||||
sheetPeekHeight = sheetPeekHeight,
|
||||
sheetContent = {
|
||||
sheetContent(sheetPadding)
|
||||
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
|
||||
Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
|
||||
sheetContent(sheetPadding)
|
||||
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
},
|
||||
scaffoldState = scaffoldState,
|
||||
sheetDragHandle = sheetDragHandle,
|
||||
|
|
|
|||
|
|
@ -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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.ShowLocationMode
|
||||
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
||||
import io.element.android.features.location.impl.common.MapDefaults
|
||||
|
|
@ -29,14 +31,20 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
|
|||
import io.element.android.features.location.impl.common.toDialogState
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.getBestName
|
||||
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
@AssistedInject
|
||||
class ShowLocationPresenter(
|
||||
|
|
@ -46,6 +54,7 @@ class ShowLocationPresenter(
|
|||
private val buildMeta: BuildMeta,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val stringProvider: StringProvider,
|
||||
private val joinedRoom: JoinedRoom,
|
||||
) : Presenter<ShowLocationState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
|
|
@ -96,9 +105,9 @@ class ShowLocationPresenter(
|
|||
}
|
||||
}
|
||||
|
||||
val locationShares = remember {
|
||||
when (mode) {
|
||||
is ShowLocationMode.Static -> {
|
||||
val locationShares = when (mode) {
|
||||
is ShowLocationMode.Static -> {
|
||||
remember {
|
||||
val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true)
|
||||
val formattedTimestamp = stringProvider.getString(
|
||||
CommonStrings.screen_static_location_sheet_timestamp_description,
|
||||
|
|
@ -121,15 +130,59 @@ class ShowLocationPresenter(
|
|||
)
|
||||
)
|
||||
}
|
||||
ShowLocationMode.Live -> persistentListOf()
|
||||
}
|
||||
is ShowLocationMode.Live -> {
|
||||
produceState(persistentListOf()) {
|
||||
val comparator = LiveLocationShareComparator(currentUser = joinedRoom.sessionId)
|
||||
val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares()
|
||||
val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() }
|
||||
combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members ->
|
||||
liveShares
|
||||
.sortedWith(comparator)
|
||||
.mapNotNull { share ->
|
||||
val lastLocation = share.lastLocation ?: return@mapNotNull null
|
||||
val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null
|
||||
val member = members.find { it.userId == share.userId }
|
||||
val displayName = member?.getBestName() ?: share.userId.value
|
||||
val avatarUrl = member?.avatarUrl
|
||||
val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true)
|
||||
val formattedTimestamp = stringProvider.getString(
|
||||
CommonStrings.screen_static_location_sheet_timestamp_description,
|
||||
relativeTime
|
||||
)
|
||||
LocationShareItem(
|
||||
userId = share.userId,
|
||||
displayName = displayName,
|
||||
avatarData = AvatarData(
|
||||
id = share.userId.value,
|
||||
name = displayName,
|
||||
url = avatarUrl,
|
||||
size = AvatarSize.UserListItem,
|
||||
),
|
||||
formattedTimestamp = formattedTimestamp,
|
||||
location = location,
|
||||
isLive = true,
|
||||
assetType = lastLocation.assetType,
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
}.collect { value = it }
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
val focusedLocation = when (mode) {
|
||||
is ShowLocationMode.Static -> locationShares.firstOrNull()
|
||||
is ShowLocationMode.Live -> locationShares.firstOrNull { it.userId == mode.senderId }
|
||||
}
|
||||
|
||||
return ShowLocationState(
|
||||
dialogState = dialogState,
|
||||
locationShares = locationShares,
|
||||
focusedLocation = focusedLocation,
|
||||
hasLocationPermission = permissionsState.isAnyGranted,
|
||||
isTrackMyLocation = isTrackMyLocation,
|
||||
isLive = mode is ShowLocationMode.Live,
|
||||
appName = appName,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,14 +18,16 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
|
|||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ShowLocationState(
|
||||
val isLive: Boolean,
|
||||
val dialogState: LocationConstraintsDialogState,
|
||||
val locationShares: ImmutableList<LocationShareItem>,
|
||||
val focusedLocation: LocationShareItem?,
|
||||
val hasLocationPermission: Boolean,
|
||||
val isTrackMyLocation: Boolean,
|
||||
val appName: String,
|
||||
val eventSink: (ShowLocationEvent) -> Unit,
|
||||
) {
|
||||
val isSheetDraggable = locationShares.any { item -> item.isLive }
|
||||
val isSheetDraggable = isLive && locationShares.isNotEmpty()
|
||||
}
|
||||
|
||||
data class LocationShareItem(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
|||
override val values: Sequence<ShowLocationState>
|
||||
get() = sequenceOf(
|
||||
aShowLocationState(),
|
||||
aShowLocationState(isLive = true),
|
||||
aShowLocationState(isLive = true, locationShares = emptyList()),
|
||||
aShowLocationState(
|
||||
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
|
||||
),
|
||||
|
|
@ -44,8 +46,10 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
|||
private const val APP_NAME = "ApplicationName"
|
||||
|
||||
fun aShowLocationState(
|
||||
isLive: Boolean = false,
|
||||
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
|
||||
locationShares: List<LocationShareItem> = listOf(aLocationShareItem()),
|
||||
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
|
||||
focusedLocation: LocationShareItem? = locationShares.firstOrNull(),
|
||||
hasLocationPermission: Boolean = false,
|
||||
isTrackMyLocation: Boolean = false,
|
||||
appName: String = APP_NAME,
|
||||
|
|
@ -54,9 +58,11 @@ fun aShowLocationState(
|
|||
return ShowLocationState(
|
||||
dialogState = constraintsDialogState,
|
||||
locationShares = locationShares.toImmutableList(),
|
||||
focusedLocation = focusedLocation,
|
||||
hasLocationPermission = hasLocationPermission,
|
||||
isTrackMyLocation = isTrackMyLocation,
|
||||
appName = appName,
|
||||
isLive = isLive,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -70,10 +76,10 @@ fun aLocationShareItem(
|
|||
url = null,
|
||||
size = AvatarSize.UserListItem,
|
||||
),
|
||||
formattedTimestamp: String = "Shared 1 min ago",
|
||||
location: Location = Location(1.23, 2.34, 4f),
|
||||
isLive: Boolean = false,
|
||||
assetType: AssetType? = null,
|
||||
formattedTimestamp: String = "Shared 1 min ago",
|
||||
location: Location = Location(1.23, 2.34, 4f),
|
||||
) = LocationShareItem(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ package io.element.android.features.location.impl.show
|
|||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetValue
|
||||
|
|
@ -21,11 +24,15 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
|
|||
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
|
|
@ -65,35 +72,33 @@ fun ShowLocationView(
|
|||
onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) },
|
||||
)
|
||||
|
||||
val initialPosition = remember {
|
||||
if (state.locationShares.isEmpty()) {
|
||||
MapDefaults.defaultCameraPosition
|
||||
} else {
|
||||
val firstLocation = state.locationShares.first().location
|
||||
CameraPosition(
|
||||
target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon),
|
||||
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
|
||||
var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(state.focusedLocation) {
|
||||
if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) {
|
||||
hasAnimatedToFocusedLocation = true
|
||||
val position = CameraPosition(
|
||||
target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon),
|
||||
zoom = MapDefaults.DEFAULT_ZOOM
|
||||
)
|
||||
cameraState.position = position
|
||||
}
|
||||
}
|
||||
val cameraState = rememberCameraState(firstPosition = initialPosition)
|
||||
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
|
||||
LaunchedEffect(cameraState.isCameraMoving) {
|
||||
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
|
||||
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||
}
|
||||
}
|
||||
|
||||
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
|
||||
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||
bottomSheetState = rememberStandardBottomSheetState(
|
||||
initialValue =
|
||||
if (state.isSheetDraggable) {
|
||||
SheetValue.PartiallyExpanded
|
||||
} else {
|
||||
SheetValue.Expanded
|
||||
}
|
||||
)
|
||||
bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
|
||||
)
|
||||
LaunchedEffect(state.isSheetDraggable) {
|
||||
if (!state.isSheetDraggable) {
|
||||
scaffoldState.bottomSheetState.expand()
|
||||
}
|
||||
}
|
||||
MapBottomSheetScaffold(
|
||||
sheetDragHandle = if (state.isSheetDraggable) {
|
||||
{ BottomSheetDefaults.DragHandle() }
|
||||
|
|
@ -116,29 +121,46 @@ fun ShowLocationView(
|
|||
},
|
||||
sheetContent = { sheetPaddings ->
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
text = stringResource(CommonStrings.screen_static_location_sheet_title),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
state.locationShares.forEach { locationShare ->
|
||||
LocationShareRow(
|
||||
item = locationShare,
|
||||
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||
val position = CameraPosition(
|
||||
padding = sheetPaddings,
|
||||
target = Position(locationShare.location.lon, locationShare.location.lat),
|
||||
zoom = MapDefaults.DEFAULT_ZOOM
|
||||
)
|
||||
coroutineScope.launch {
|
||||
cameraState.animateTo(finalPosition = position)
|
||||
}
|
||||
}
|
||||
if (!state.isSheetDraggable) {
|
||||
// If sheet is draggable the DragHandle has already some padding
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
if (state.locationShares.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.screen_live_location_sheet_nobody_sharing),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 16.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.screen_static_location_sheet_title),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn {
|
||||
items(state.locationShares) { locationShare ->
|
||||
LocationShareRow(
|
||||
item = locationShare,
|
||||
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||
val position = CameraPosition(
|
||||
padding = sheetPaddings,
|
||||
target = Position(locationShare.location.lon, locationShare.location.lat),
|
||||
zoom = MapDefaults.DEFAULT_ZOOM
|
||||
)
|
||||
coroutineScope.launch {
|
||||
cameraState.animateTo(finalPosition = position)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mapContent = {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
|
|||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
|
|
@ -43,7 +44,8 @@ class DefaultShowLocationEntryPointTest {
|
|||
locationActions = FakeLocationActions(),
|
||||
buildMeta = aBuildMeta(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
stringProvider = FakeStringProvider()
|
||||
stringProvider = FakeStringProvider(),
|
||||
joinedRoom = FakeJoinedRoom(),
|
||||
)
|
||||
},
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
|
|
|
|||
|
|
@ -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.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.location.LastLocation
|
||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ShowLocationPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
|
@ -51,13 +59,15 @@ class ShowLocationPresenterTest {
|
|||
assetType = null,
|
||||
),
|
||||
locationActions: FakeLocationActions = fakeLocationActions,
|
||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||
) = ShowLocationPresenter(
|
||||
mode = mode,
|
||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||
locationActions = locationActions,
|
||||
buildMeta = fakeBuildMeta,
|
||||
dateFormatter = fakeDateFormatter,
|
||||
stringProvider = FakeStringProvider()
|
||||
stringProvider = FakeStringProvider(),
|
||||
joinedRoom = joinedRoom,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -318,4 +328,159 @@ class ShowLocationPresenterTest {
|
|||
assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live mode emits empty location shares initially`() = runTest {
|
||||
val presenter = createShowLocationPresenter(
|
||||
mode = ShowLocationMode.Live(senderId = UserId("@alice:matrix.org")),
|
||||
joinedRoom = FakeJoinedRoom(),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.locationShares).isEmpty()
|
||||
assertThat(initialState.isSheetDraggable).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live mode collects live shares from room`() = runTest {
|
||||
val userId = UserId("@bob:matrix.org")
|
||||
val liveSharesFlow = MutableStateFlow(
|
||||
listOf(
|
||||
aLiveLocationShare(userId = userId)
|
||||
)
|
||||
)
|
||||
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
|
||||
|
||||
val presenter = createShowLocationPresenter(
|
||||
mode = ShowLocationMode.Live(senderId = userId),
|
||||
joinedRoom = fakeRoom,
|
||||
)
|
||||
presenter.test {
|
||||
// Skip initial empty state from collectAsState(initial = emptyList())
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
|
||||
assertThat(state.locationShares).hasSize(1)
|
||||
val item = state.locationShares.first()
|
||||
assertThat(item.userId).isEqualTo(userId)
|
||||
assertThat(item.location.lat).isEqualTo(48.8584)
|
||||
assertThat(item.location.lon).isEqualTo(2.2945)
|
||||
assertThat(item.isLive).isTrue()
|
||||
assertThat(state.isSheetDraggable).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live mode handles invalid geo uri gracefully`() = runTest {
|
||||
val validUserId = UserId("@alice:matrix.org")
|
||||
val invalidUserId = UserId("@bob:matrix.org")
|
||||
val liveSharesFlow = MutableStateFlow(
|
||||
listOf(
|
||||
aLiveLocationShare(userId = validUserId),
|
||||
aLiveLocationShare(userId = invalidUserId, geoUri = "invalid-geo-uri"),
|
||||
)
|
||||
)
|
||||
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
|
||||
|
||||
val presenter = createShowLocationPresenter(
|
||||
mode = ShowLocationMode.Live(senderId = validUserId),
|
||||
joinedRoom = fakeRoom,
|
||||
)
|
||||
presenter.test {
|
||||
// Skip initial empty state from collectAsState(initial = emptyList())
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
|
||||
// Only the valid location share should be present
|
||||
assertThat(state.locationShares).hasSize(1)
|
||||
assertThat(state.locationShares.first().userId).isEqualTo(validUserId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live mode updates when shares change`() = runTest {
|
||||
val userId = UserId("@bob:matrix.org")
|
||||
val liveSharesFlow = MutableStateFlow(emptyList<LiveLocationShare>())
|
||||
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
|
||||
|
||||
val presenter = createShowLocationPresenter(
|
||||
mode = ShowLocationMode.Live(senderId = userId),
|
||||
joinedRoom = fakeRoom,
|
||||
)
|
||||
presenter.test {
|
||||
// Initial state is empty
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.locationShares).isEmpty()
|
||||
|
||||
// Emit a new live share
|
||||
liveSharesFlow.value = listOf(
|
||||
aLiveLocationShare(userId = userId)
|
||||
)
|
||||
|
||||
val updatedState = awaitItem()
|
||||
assertThat(updatedState.locationShares).hasSize(1)
|
||||
assertThat(updatedState.locationShares.first().userId).isEqualTo(userId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `static mode emits location share with correct data`() = runTest {
|
||||
val senderId = UserId("@alice:matrix.org")
|
||||
val senderName = "Alice"
|
||||
val avatarUrl = "https://example.com/avatar.png"
|
||||
val mode = ShowLocationMode.Static(
|
||||
location = location,
|
||||
senderName = senderName,
|
||||
senderId = senderId,
|
||||
senderAvatarUrl = avatarUrl,
|
||||
timestamp = 0L,
|
||||
assetType = AssetType.SENDER,
|
||||
)
|
||||
|
||||
val presenter = createShowLocationPresenter(mode = mode)
|
||||
presenter.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state.locationShares).hasSize(1)
|
||||
|
||||
val item = state.locationShares.first()
|
||||
assertThat(item.userId).isEqualTo(senderId)
|
||||
assertThat(item.displayName).isEqualTo(senderName)
|
||||
assertThat(item.location).isEqualTo(location)
|
||||
assertThat(item.isLive).isFalse()
|
||||
assertThat(item.assetType).isEqualTo(AssetType.SENDER)
|
||||
assertThat(item.avatarData.id).isEqualTo(senderId.value)
|
||||
assertThat(item.avatarData.name).isEqualTo(senderName)
|
||||
assertThat(item.avatarData.url).isEqualTo(avatarUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `static mode has non-draggable sheet`() = runTest {
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state.isSheetDraggable).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun aLiveLocationShare(
|
||||
userId: UserId,
|
||||
geoUri: String = "geo:48.8584,2.2945",
|
||||
timestamp: Long = 0L,
|
||||
startTimestamp: Long = 0L,
|
||||
endTimestamp: Long = Long.MAX_VALUE,
|
||||
assetType: AssetType = AssetType.SENDER,
|
||||
): LiveLocationShare {
|
||||
return LiveLocationShare(
|
||||
userId = userId,
|
||||
lastLocation = LastLocation(
|
||||
geoUri = geoUri,
|
||||
timestamp = timestamp,
|
||||
assetType = assetType,
|
||||
),
|
||||
startTimestamp = startTimestamp,
|
||||
endTimestamp = endTimestamp,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -586,17 +586,18 @@ class MessagesFlowNode(
|
|||
)
|
||||
}
|
||||
is TimelineItemLocationContent -> {
|
||||
val mode = ShowLocationMode.Static(
|
||||
location = event.content.location,
|
||||
senderName = event.safeSenderName,
|
||||
senderId = event.senderId,
|
||||
senderAvatarUrl = event.senderAvatar.url,
|
||||
timestamp = event.sentTimeMillis,
|
||||
assetType = event.content.assetType,
|
||||
)
|
||||
NavTarget.LocationViewer(
|
||||
mode = mode
|
||||
).takeIf { locationService.isServiceAvailable() }
|
||||
val mode = when (event.content.mode) {
|
||||
is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId)
|
||||
is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static(
|
||||
location = event.content.mode.location,
|
||||
senderName = event.safeSenderName,
|
||||
senderId = event.senderId,
|
||||
senderAvatarUrl = event.senderAvatar.url,
|
||||
timestamp = event.sentTimeMillis,
|
||||
assetType = event.content.assetType,
|
||||
)
|
||||
}
|
||||
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,7 +289,11 @@ private fun MessageSummary(
|
|||
is TimelineItemRedactedContent,
|
||||
is TimelineItemUnknownContent -> content = { ContentForBody(textContent) }
|
||||
is TimelineItemLocationContent -> {
|
||||
content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) }
|
||||
val body = when (event.content.mode) {
|
||||
is TimelineItemLocationContent.Mode.Live -> stringResource(CommonStrings.common_shared_live_location)
|
||||
is TimelineItemLocationContent.Mode.Static -> stringResource(CommonStrings.common_shared_location)
|
||||
}
|
||||
content = { ContentForBody(body) }
|
||||
}
|
||||
is TimelineItemImageContent -> {
|
||||
content = { ContentForBody(event.content.bestDescription) }
|
||||
|
|
|
|||
|
|
@ -57,4 +57,6 @@ sealed interface TimelineEvent {
|
|||
data class EditPoll(
|
||||
val pollStartId: EventId,
|
||||
) : TimelineItemPollEvent
|
||||
|
||||
data object StopLiveLocationShare : TimelineItemEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ class TimelinePresenter(
|
|||
is TimelineEvent.EditPoll -> {
|
||||
navigator.navigateToEditPoll(event.pollStartId)
|
||||
}
|
||||
is TimelineEvent.StopLiveLocationShare -> Unit
|
||||
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
|
||||
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
|
||||
delay(event.debounce)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
|
||||
|
|
@ -677,6 +678,7 @@ private fun MessageEventBubbleContent(
|
|||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
TimestampPosition.Hidden -> Box(modifier) { content {} }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -772,11 +774,17 @@ private fun MessageEventBubbleContent(
|
|||
}
|
||||
}
|
||||
|
||||
val timestampPosition = when (event.content) {
|
||||
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemLocationContent -> TimestampPosition.Overlay
|
||||
val timestampPosition = when (val content = event.content) {
|
||||
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemStickerContent -> TimestampPosition.Overlay
|
||||
is TimelineItemLocationContent -> {
|
||||
val content = content.ensureActiveLiveLocation()
|
||||
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
|
||||
content.mode.isActive &&
|
||||
content.mode.canStop
|
||||
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
|
||||
}
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ enum class TimestampPosition {
|
|||
/**
|
||||
* Timestamp should always be rendered below the timeline event content (eg. poll).
|
||||
*/
|
||||
Below;
|
||||
Below,
|
||||
|
||||
/**
|
||||
* Timestamp should be hidden.
|
||||
*/
|
||||
Hidden;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
|
@ -71,10 +72,13 @@ fun TimelineItemEventContentView(
|
|||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemLocationContent -> TimelineItemLocationView(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemLocationContent -> {
|
||||
TimelineItemLocationView(
|
||||
content = content.ensureActiveLiveLocation(),
|
||||
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
is TimelineItemImageContent -> TimelineItemImageView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
|
|
|
|||
|
|
@ -8,33 +8,153 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.location.api.StaticMapView
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun TimelineItemLocationView(
|
||||
content: TimelineItemLocationContent,
|
||||
onStopLiveLocationClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
StaticMapView(
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
StaticMapView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 188.dp),
|
||||
pinVariant = content.pinVariant,
|
||||
location = content.location,
|
||||
zoom = 15.0,
|
||||
contentDescription = content.description
|
||||
)
|
||||
|
||||
if (content.mode is TimelineItemLocationContent.Mode.Live) {
|
||||
LiveLocationOverlay(
|
||||
mode = content.mode,
|
||||
onStopClick = onStopLiveLocationClick,
|
||||
modifier = Modifier.align(Alignment.BottomStart)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveLocationOverlay(
|
||||
mode: TimelineItemLocationContent.Mode.Live,
|
||||
onStopClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 188.dp),
|
||||
pinVariant = content.pinVariant,
|
||||
lat = content.location.lat,
|
||||
lon = content.location.lon,
|
||||
zoom = 15.0,
|
||||
contentDescription = content.body
|
||||
)
|
||||
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val iconShape = RoundedCornerShape(8.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = if (mode.isActive) ElementTheme.colors.iconQuaternaryAlpha else Color.Transparent,
|
||||
shape = iconShape,
|
||||
)
|
||||
.background(
|
||||
color = if (mode.isActive) {
|
||||
ElementTheme.colors.bgCanvasDefault
|
||||
} else {
|
||||
ElementTheme.colors.bgSubtleSecondary
|
||||
},
|
||||
shape = iconShape
|
||||
)
|
||||
) {
|
||||
if (mode.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
color = ElementTheme.colors.iconSecondary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(20.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.LocationPinSolid(),
|
||||
contentDescription = null,
|
||||
tint = if (mode.isActive) {
|
||||
ElementTheme.colors.iconAccentPrimary
|
||||
} else {
|
||||
ElementTheme.colors.iconDisabled
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (mode.isActive) {
|
||||
stringResource(CommonStrings.common_live_location)
|
||||
} else {
|
||||
stringResource(CommonStrings.common_live_location_ended)
|
||||
},
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
if (mode.isActive) {
|
||||
Text(
|
||||
text = mode.endsAt,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (mode.isActive && mode.canStop) {
|
||||
IconButton(
|
||||
onClick = onStopClick,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = ElementTheme.colors.bgCriticalPrimary,
|
||||
contentColor = ElementTheme.colors.iconOnSolidPrimary,
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Stop(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
|
@ -43,5 +163,6 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat
|
|||
ElementPreview {
|
||||
TimelineItemLocationView(
|
||||
content = content,
|
||||
onStopLiveLocationClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -36,6 +38,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
||||
@Inject
|
||||
class TimelineItemContentFactory(
|
||||
|
|
@ -50,6 +54,8 @@ class TimelineItemContentFactory(
|
|||
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
|
||||
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
|
||||
private val sessionId: SessionId,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val stringProvider: StringProvider,
|
||||
) {
|
||||
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
|
||||
return create(
|
||||
|
|
@ -103,19 +109,24 @@ class TimelineItemContentFactory(
|
|||
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
|
||||
Location.fromGeoUri(beacon.geoUri)
|
||||
}.lastOrNull()
|
||||
if (lastKnownLocation != null) {
|
||||
TimelineItemLocationContent(
|
||||
body = itemContent.body.trimEnd(),
|
||||
description = itemContent.description?.trimEnd(),
|
||||
assetType = itemContent.assetType,
|
||||
senderId = sender,
|
||||
senderProfile = senderProfile,
|
||||
location = lastKnownLocation,
|
||||
mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive)
|
||||
)
|
||||
} else {
|
||||
TimelineItemUnknownContent
|
||||
}
|
||||
|
||||
val endsAt = dateFormatter.format(
|
||||
timestamp = itemContent.endTimestamp,
|
||||
mode = DateFormatterMode.TimeOnly
|
||||
)
|
||||
// Always create content, location can be null for "loading/waiting" state
|
||||
TimelineItemLocationContent(
|
||||
description = itemContent.description?.trimEnd(),
|
||||
assetType = itemContent.assetType,
|
||||
senderId = sender,
|
||||
senderProfile = senderProfile,
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
lastKnownLocation = lastKnownLocation,
|
||||
isActive = itemContent.isLive,
|
||||
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
|
||||
endTimestamp = itemContent.endTimestamp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,13 +150,11 @@ class TimelineItemContentMessageFactory(
|
|||
)
|
||||
} else {
|
||||
TimelineItemLocationContent(
|
||||
body = body,
|
||||
location = location,
|
||||
description = messageType.description,
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
assetType = messageType.assetType,
|
||||
mode = TimelineItemLocationContent.Mode.Static
|
||||
mode = TimelineItemLocationContent.Mode.Static(location = location)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||
aTimelineItemUnknownContent(),
|
||||
aTimelineItemTextContent().copy(isEdited = true),
|
||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.produceState
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.libraries.designsystem.components.PinVariant
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
|
|
@ -17,17 +19,25 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
data class TimelineItemLocationContent(
|
||||
val body: String,
|
||||
val senderId: UserId,
|
||||
val senderProfile: ProfileDetails,
|
||||
val location: Location,
|
||||
val description: String? = null,
|
||||
val assetType: AssetType? = null,
|
||||
val mode: Mode,
|
||||
) : TimelineItemEventContent {
|
||||
val pinVariant = when (mode) {
|
||||
val location = when (mode) {
|
||||
is Mode.Live -> mode.lastKnownLocation
|
||||
is Mode.Static -> mode.location
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin variant to display on the map.
|
||||
* Returns a default variant when location is null (map will show loading placeholder anyway).
|
||||
*/
|
||||
val pinVariant: PinVariant = when (mode) {
|
||||
is Mode.Live -> {
|
||||
if (mode.isActive) {
|
||||
PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true)
|
||||
|
|
@ -35,7 +45,7 @@ data class TimelineItemLocationContent(
|
|||
PinVariant.StaleLocation
|
||||
}
|
||||
}
|
||||
Mode.Static -> {
|
||||
is Mode.Static -> {
|
||||
when (assetType) {
|
||||
AssetType.PIN -> PinVariant.PinnedLocation
|
||||
AssetType.SENDER,
|
||||
|
|
@ -53,9 +63,57 @@ data class TimelineItemLocationContent(
|
|||
)
|
||||
|
||||
sealed interface Mode {
|
||||
data object Static : Mode
|
||||
data class Live(val isActive: Boolean) : Mode
|
||||
data class Static(
|
||||
val location: Location,
|
||||
) : Mode
|
||||
|
||||
data class Live(
|
||||
val lastKnownLocation: Location?,
|
||||
val isActive: Boolean,
|
||||
val endsAt: String,
|
||||
val endTimestamp: Long,
|
||||
val canStop: Boolean = false,
|
||||
) : Mode {
|
||||
val isLoading = lastKnownLocation == null && isActive
|
||||
}
|
||||
}
|
||||
|
||||
override val type: String = "TimelineItemLocationContent"
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the isActive value if needed, to make sure endTimestamp is used in absence of stop event.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TimelineItemLocationContent.ensureActiveLiveLocation(
|
||||
currentTimeMillis: () -> Long = System::currentTimeMillis,
|
||||
): TimelineItemLocationContent {
|
||||
return when (mode) {
|
||||
is TimelineItemLocationContent.Mode.Live -> {
|
||||
val isActive = rememberIsLiveLocationActive(mode, currentTimeMillis)
|
||||
copy(mode = mode.copy(isActive = isActive))
|
||||
}
|
||||
is TimelineItemLocationContent.Mode.Static -> this
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberIsLiveLocationActive(
|
||||
mode: TimelineItemLocationContent.Mode.Live,
|
||||
currentTimeMillis: () -> Long,
|
||||
): Boolean {
|
||||
fun TimelineItemLocationContent.Mode.Live.isActive(): Boolean {
|
||||
return isActive && endTimestamp > currentTimeMillis()
|
||||
}
|
||||
return produceState(
|
||||
initialValue = mode.isActive(),
|
||||
key1 = mode.endTimestamp,
|
||||
key2 = mode.isActive,
|
||||
) {
|
||||
if (mode.isActive) {
|
||||
val remainingMillis = mode.endTimestamp - currentTimeMillis()
|
||||
delay(remainingMillis)
|
||||
}
|
||||
value = false
|
||||
}.value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,24 +18,56 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
|||
override val values: Sequence<TimelineItemLocationContent>
|
||||
get() = sequenceOf(
|
||||
aTimelineItemLocationContent(),
|
||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
|
||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = true,
|
||||
endsAt = "Ends at 12:34",
|
||||
endTimestamp = 0L,
|
||||
canStop = true,
|
||||
lastKnownLocation = aLocation()
|
||||
),
|
||||
),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = true,
|
||||
endsAt = "Ends at 12:34",
|
||||
endTimestamp = 0L,
|
||||
lastKnownLocation = aLocation()
|
||||
),
|
||||
),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = true,
|
||||
endsAt = "Ends at 12:34",
|
||||
endTimestamp = 0L,
|
||||
lastKnownLocation = null
|
||||
),
|
||||
),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = false,
|
||||
endsAt = "",
|
||||
endTimestamp = 0L,
|
||||
lastKnownLocation = aLocation()
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aTimelineItemLocationContent(
|
||||
body: String = "",
|
||||
senderId: UserId = UserId("@sender:matrix.org"),
|
||||
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static,
|
||||
description: String? = null,
|
||||
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
|
||||
) = TimelineItemLocationContent(
|
||||
body = body,
|
||||
location = Location(
|
||||
lat = 52.2445,
|
||||
lon = 0.7186,
|
||||
accuracy = 5000f,
|
||||
),
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
mode = mode
|
||||
description = description,
|
||||
mode = mode,
|
||||
)
|
||||
|
||||
fun aLocation() = Location(
|
||||
lat = 52.2445,
|
||||
lon = 0.7186,
|
||||
accuracy = 5000f,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@ class DefaultMessageSummaryFormatter(
|
|||
is TimelineItemTextBasedContent -> content.plainText
|
||||
is TimelineItemProfileChangeContent -> content.body
|
||||
is TimelineItemStateContent -> content.body
|
||||
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
|
||||
is TimelineItemLocationContent -> when (content.mode) {
|
||||
is TimelineItemLocationContent.Mode.Live -> context.getString(CommonStrings.common_shared_live_location)
|
||||
is TimelineItemLocationContent.Mode.Static -> context.getString(CommonStrings.common_shared_location)
|
||||
}
|
||||
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
|
||||
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
|
||||
is TimelineItemPollContent -> content.question
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
|||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
|
||||
|
|
@ -72,6 +73,8 @@ internal fun aTimelineItemContentFactory(
|
|||
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
|
||||
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
||||
sessionId = matrixClient.sessionId,
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
stringProvider = FakeStringProvider(),
|
||||
)
|
||||
|
||||
internal fun TestScope.aTimelineItemsFactory(
|
||||
|
|
|
|||
|
|
@ -78,9 +78,7 @@ import org.robolectric.RobolectricTestRunner
|
|||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@Suppress("LargeClass")
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class TimelineItemContentMessageFactoryTest {
|
||||
@Suppress("LargeClass") @RunWith(RobolectricTestRunner::class) class TimelineItemContentMessageFactoryTest {
|
||||
@Test
|
||||
fun `test create OtherMessageType`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
|
|
@ -110,11 +108,9 @@ class TimelineItemContentMessageFactoryTest {
|
|||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemLocationContent(
|
||||
body = "body",
|
||||
location = Location(lat = 1.0, lon = 2.0, accuracy = null),
|
||||
description = "description",
|
||||
assetType = assetType,
|
||||
mode = TimelineItemLocationContent.Mode.Static,
|
||||
mode = TimelineItemLocationContent.Mode.Static(location = Location(lat = 1.0, lon = 2.0, accuracy = null)),
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
)
|
||||
|
|
@ -166,16 +162,11 @@ class TimelineItemContentMessageFactoryTest {
|
|||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
) as TimelineItemTextContent
|
||||
val expected = TimelineItemTextContent(
|
||||
body = "https://www.example.org",
|
||||
htmlDocument = null,
|
||||
isEdited = false,
|
||||
formattedBody = buildSpannedString {
|
||||
inSpans(URLSpan("https://www.example.org")) {
|
||||
append("https://www.example.org")
|
||||
}
|
||||
val expected = TimelineItemTextContent(body = "https://www.example.org", htmlDocument = null, isEdited = false, formattedBody = buildSpannedString {
|
||||
inSpans(URLSpan("https://www.example.org")) {
|
||||
append("https://www.example.org")
|
||||
}
|
||||
)
|
||||
})
|
||||
assertThat(result.body).isEqualTo(expected.body)
|
||||
assertThat(result.htmlDocument).isEqualTo(expected.htmlDocument)
|
||||
assertThat(result.plainText).isEqualTo(expected.plainText)
|
||||
|
|
@ -200,9 +191,7 @@ class TimelineItemContentMessageFactoryTest {
|
|||
append("and manually added link")
|
||||
}
|
||||
}.toSpannable()
|
||||
val sut = createTimelineItemContentMessageFactory(
|
||||
domConverterTransform = { expected }
|
||||
)
|
||||
val sut = createTimelineItemContentMessageFactory(domConverterTransform = { expected })
|
||||
val result = sut.create(
|
||||
content = createMessageContent(
|
||||
type = TextMessageType(
|
||||
|
|
@ -219,9 +208,7 @@ class TimelineItemContentMessageFactoryTest {
|
|||
|
||||
@Test
|
||||
fun `test create TextMessageType with unknown formatted body does nothing`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory(
|
||||
htmlConverterTransform = { it }
|
||||
)
|
||||
val sut = createTimelineItemContentMessageFactory(htmlConverterTransform = { it })
|
||||
val result = sut.create(
|
||||
content = createMessageContent(
|
||||
type = TextMessageType(
|
||||
|
|
@ -356,10 +343,10 @@ class TimelineItemContentMessageFactoryTest {
|
|||
formattedCaption = null,
|
||||
source = MediaSource("url"),
|
||||
info = AudioInfo(
|
||||
duration = 1.minutes,
|
||||
size = 123L,
|
||||
mimetype = MimeTypes.Mp3,
|
||||
)
|
||||
duration = 1.minutes,
|
||||
size = 123L,
|
||||
mimetype = MimeTypes.Mp3,
|
||||
)
|
||||
),
|
||||
isEdited = true,
|
||||
),
|
||||
|
|
@ -597,16 +584,16 @@ class TimelineItemContentMessageFactoryTest {
|
|||
formattedCaption = null,
|
||||
source = MediaSource("url"),
|
||||
info = FileInfo(
|
||||
mimetype = MimeTypes.Pdf,
|
||||
size = 123L,
|
||||
thumbnailInfo = ThumbnailInfo(
|
||||
height = 10L,
|
||||
width = 5L,
|
||||
mimetype = MimeTypes.Jpeg,
|
||||
size = 111L,
|
||||
),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
)
|
||||
mimetype = MimeTypes.Pdf,
|
||||
size = 123L,
|
||||
thumbnailInfo = ThumbnailInfo(
|
||||
height = 10L,
|
||||
width = 5L,
|
||||
mimetype = MimeTypes.Jpeg,
|
||||
size = 111L,
|
||||
),
|
||||
thumbnailSource = MediaSource("url_thumbnail"),
|
||||
)
|
||||
),
|
||||
isEdited = true,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ class DefaultRoomLatestEventFormatter(
|
|||
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||
}
|
||||
is LiveLocationContent -> {
|
||||
val message = sp.getString(CommonStrings.common_shared_location)
|
||||
val message = sp.getString(CommonStrings.common_shared_live_location)
|
||||
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||
}
|
||||
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
|
||||
|
|
|
|||
|
|
@ -15,10 +15,19 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||
data class LiveLocationShare(
|
||||
/** The user who is sharing their location. */
|
||||
val userId: UserId,
|
||||
/** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */
|
||||
val lastGeoUri: String,
|
||||
/** The timestamp of the last location update. */
|
||||
val lastTimestamp: Long,
|
||||
/** Whether the live location share is still active. */
|
||||
val isLive: Boolean,
|
||||
/** The last known location if any. */
|
||||
val lastLocation: LastLocation?,
|
||||
/** The timestamp when location sharing started, in milliseconds.*/
|
||||
val startTimestamp: Long,
|
||||
/** The timestamp when location sharing ends, in milliseconds. */
|
||||
val endTimestamp: Long,
|
||||
)
|
||||
|
||||
data class LastLocation(
|
||||
/** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */
|
||||
val geoUri: String,
|
||||
/** The timestamp of the last location update. */
|
||||
val timestamp: Long,
|
||||
/** The asset of the last location update. */
|
||||
val assetType: AssetType,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -105,13 +105,15 @@ data class FailedToParseStateContent(
|
|||
) : EventContent
|
||||
|
||||
data class LiveLocationContent(
|
||||
val body: String,
|
||||
val isLive: Boolean,
|
||||
val description: String?,
|
||||
val startTimestamp: Long,
|
||||
val timeout: Long,
|
||||
val assetType: AssetType?,
|
||||
val locations: List<LiveLocationInfo>,
|
||||
) : EventContent
|
||||
) : EventContent {
|
||||
val endTimestamp = startTimestamp + timeout
|
||||
}
|
||||
|
||||
data object LegacyCallInviteContent : EventContent
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ import io.element.android.libraries.matrix.impl.mapper.map
|
|||
import io.element.android.libraries.matrix.impl.room.history.map
|
||||
import io.element.android.libraries.matrix.impl.room.join.map
|
||||
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
|
||||
import io.element.android.libraries.matrix.impl.room.location.liveLocationSharesFlow
|
||||
import io.element.android.libraries.matrix.impl.room.location.timedByExpiry
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
|
||||
import io.element.android.libraries.matrix.impl.roomdirectory.map
|
||||
|
|
@ -511,7 +513,7 @@ class JoinedRustRoom(
|
|||
}
|
||||
|
||||
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
|
||||
TODO("Not implemented yet")
|
||||
return innerRoom.liveLocationSharesFlow().timedByExpiry(systemClock::epochMillis)
|
||||
}
|
||||
|
||||
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {
|
||||
|
|
|
|||
|
|
@ -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.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
|
||||
|
|
@ -19,6 +20,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
|
|
@ -33,8 +35,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
|
|||
import io.element.android.libraries.matrix.impl.media.map
|
||||
import io.element.android.libraries.matrix.impl.poll.map
|
||||
import io.element.android.libraries.matrix.impl.room.join.map
|
||||
import io.element.android.libraries.matrix.impl.room.location.into
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import org.matrix.rustcomponents.sdk.BeaconInfo
|
||||
import org.matrix.rustcomponents.sdk.EmbeddedEventDetails
|
||||
import org.matrix.rustcomponents.sdk.MsgLikeContent
|
||||
import org.matrix.rustcomponents.sdk.MsgLikeKind
|
||||
|
|
@ -108,8 +112,14 @@ class TimelineEventContentMapper(
|
|||
)
|
||||
}
|
||||
is MsgLikeKind.LiveLocation -> {
|
||||
// Live location messages are a special kind of message that we want to treat as unknown content for now
|
||||
UnknownContent
|
||||
LiveLocationContent(
|
||||
isLive = kind.content.isLive,
|
||||
startTimestamp = kind.content.ts.toLong(),
|
||||
description = kind.content.description,
|
||||
timeout = kind.content.timeoutMs.toLong(),
|
||||
assetType = kind.content.assetType.into(),
|
||||
locations = kind.content.locations.map { location -> location.map() }
|
||||
)
|
||||
}
|
||||
is MsgLikeKind.Other -> UnknownContent
|
||||
}
|
||||
|
|
@ -259,3 +269,11 @@ private fun RustEncryptedMessage.map(): UnableToDecryptContent.Data {
|
|||
RustEncryptedMessage.Unknown -> UnableToDecryptContent.Data.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
private fun BeaconInfo.map(): LiveLocationInfo {
|
||||
return LiveLocationInfo(
|
||||
description = description,
|
||||
geoUri = geoUri,
|
||||
timestamp = ts.toLong(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
|
|
@ -60,3 +61,8 @@ fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNoNodeWith
|
|||
val text = activity.getString(res)
|
||||
onNodeWithText(text).assertDoesNotExist()
|
||||
}
|
||||
|
||||
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNodeWithTextIsDisplayed(@StringRes res: Int) {
|
||||
val text = activity.getString(res)
|
||||
onNodeWithText(text).assertIsDisplayed()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0c2adbbce73e57c601821ea9b511913b3df0efbdd3d42643fabe0d39655e04e6
|
||||
size 438908
|
||||
oid sha256:855f37c9ca2dc6ddc9a699e42a1a3c784c26911552174735f46f31ad2588e977
|
||||
size 295172
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3baf32fc12535ffe246264dfe1f20db1fddddb970f86953e9877251dc3f74d3d
|
||||
size 173356
|
||||
oid sha256:e99b3d1c62e4907f55beefa2414ff6324c62133ab723c13b68c906331e8ee072
|
||||
size 118086
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:359960ea4b7be5ff9d766565007291f8585223052483736e17d4532c5f8af0c6
|
||||
size 252728
|
||||
oid sha256:56a86695d2c25c94a8e79c27c8f525229cc348201073566909f83a681b37ac30
|
||||
size 251329
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4b775500b1fdc6294d94dcb1f07f74c515c3eae31bc55d0faacfd86ff7bd1da3
|
||||
size 105526
|
||||
oid sha256:0c2b2a7071bd1c21ff706525e2416169507fef364485c651c429baefa15d4c9f
|
||||
size 104240
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2550638ee12b4181cea31caff0b5838a9cdb3a180c01d1188bc7c2726051b863
|
||||
size 16578
|
||||
oid sha256:aaafea9efc1000495ee469797239b82193844caa3d6f98c0c3a4344a536a1798
|
||||
size 17155
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c880e4d01495868b3f0689d20d3cbf2050d6261be936421343bc1ac210aabeec
|
||||
size 15959
|
||||
oid sha256:1f113f8979679c0673e4cc1f691140bc570b6826bea23eecc403f2fbfd3f6d09
|
||||
size 16460
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb
|
||||
size 37572
|
||||
oid sha256:ba0285628cb8f18c5d666e6727b213d3c7674d1782a7364c7b72ff906ad57eff
|
||||
size 19684
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08
|
||||
size 35976
|
||||
oid sha256:ed64e57bea072d5bdf232133c365db043f117e35ef180aa12c9b05bff40a1a92
|
||||
size 16437
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7
|
||||
size 31530
|
||||
oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb
|
||||
size 37572
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73
|
||||
size 19104
|
||||
oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08
|
||||
size 35976
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22
|
||||
size 19228
|
||||
oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7
|
||||
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
|
||||
oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474
|
||||
size 36084
|
||||
oid sha256:4356f7de986f803b890d3bc95afdefd01e9eb42d775836c647a6d9fafe3bcc4a
|
||||
size 19189
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed
|
||||
size 34500
|
||||
oid sha256:0ce9e4f5911a6dfd35655f362d122f95690a651d7c05e5ef6c4ea0621be2e628
|
||||
size 15783
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a
|
||||
size 30345
|
||||
oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474
|
||||
size 36084
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7
|
||||
size 18715
|
||||
oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed
|
||||
size 34500
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b
|
||||
size 18842
|
||||
oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a
|
||||
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
|
||||
oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553
|
||||
size 144841
|
||||
oid sha256:3daba890afd2533e1746f1ffd0c535674a4a238e4591da2a6bcbe31716d26858
|
||||
size 143676
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553
|
||||
size 144841
|
||||
oid sha256:82ed52f907048490ffb63ea9b33274703c1d92d4131cca0320816cc6949bea5e
|
||||
size 113727
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553
|
||||
size 144841
|
||||
oid sha256:ae3a754c163f83e69ab062c8e638a6e620948081d70105ddb00c32dda7c2ba74
|
||||
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
|
||||
oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573
|
||||
size 58482
|
||||
oid sha256:463a2e544b2806f4082c813ec3ba8c6ce0ad0ebcd8ee32666806757a90c248f5
|
||||
size 57131
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573
|
||||
size 58482
|
||||
oid sha256:fb0d86877e5d049d618836846cd8ea97b40d1d66e7bd8d50639280fb3e829372
|
||||
size 38499
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573
|
||||
size 58482
|
||||
oid sha256:b41879f5159a126b0641c66bed13470756a914cc038dd239d40d359661784b71
|
||||
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
|
||||
oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341
|
||||
size 374820
|
||||
oid sha256:4a0827eb23b48bd03fde5078598cb7827459a213ff0a693890581628330f6939
|
||||
size 66845
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341
|
||||
size 374820
|
||||
oid sha256:a8fa43d296a984d242ed56f7879de4c38f3da94147c365c424248eba2c4ad61d
|
||||
size 371263
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c
|
||||
size 153022
|
||||
oid sha256:bea070091132156a809da1c185e2f94333e62f8ea9ae7d6d1b789adffb869522
|
||||
size 55538
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c
|
||||
size 153022
|
||||
oid sha256:bcd9e0934b5f6808d58a3d3506641f173c4b223d78c605de09832d3741e8834f
|
||||
size 149300
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue