Live location : ensure it's not sorted randomly

This commit is contained in:
ganfra 2026-04-17 14:54:53 +02:00
parent f2b563763f
commit 4853aee3f2
6 changed files with 124 additions and 26 deletions

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.show
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
class LiveLocationShareComparator(private val currentUser: UserId) : Comparator<LiveLocationShare> {
override fun compare(p0: LiveLocationShare, p1: LiveLocationShare): Int {
val p0IsCurrentUser = p0.userId == currentUser
val p1IsCurrentUser = p1.userId == currentUser
if (p0IsCurrentUser != p1IsCurrentUser) return if (p0IsCurrentUser) -1 else 1
return p1.startTimestamp.compareTo(p0.startTimestamp)
}
}

View file

@ -133,35 +133,39 @@ class ShowLocationPresenter(
} }
is ShowLocationMode.Live -> { is ShowLocationMode.Live -> {
produceState(persistentListOf()) { produceState(persistentListOf()) {
val comparator = LiveLocationShareComparator(currentUser = joinedRoom.sessionId)
val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares() val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares()
val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() } val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() }
combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members -> combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members ->
liveShares.mapNotNull { share -> liveShares
val lastLocation = share.lastLocation ?: return@mapNotNull null .sortedWith(comparator)
val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null .mapNotNull { share ->
val member = members.find { it.userId == share.userId } val lastLocation = share.lastLocation ?: return@mapNotNull null
val displayName = member?.getBestName() ?: share.userId.value val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null
val avatarUrl = member?.avatarUrl val member = members.find { it.userId == share.userId }
val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true) val displayName = member?.getBestName() ?: share.userId.value
val formattedTimestamp = stringProvider.getString( val avatarUrl = member?.avatarUrl
CommonStrings.screen_static_location_sheet_timestamp_description, val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true)
relativeTime val formattedTimestamp = stringProvider.getString(
) CommonStrings.screen_static_location_sheet_timestamp_description,
LocationShareItem( relativeTime
userId = share.userId, )
displayName = displayName, LocationShareItem(
avatarData = AvatarData( userId = share.userId,
id = share.userId.value, displayName = displayName,
name = displayName, avatarData = AvatarData(
url = avatarUrl, id = share.userId.value,
size = AvatarSize.UserListItem, name = displayName,
), url = avatarUrl,
formattedTimestamp = formattedTimestamp, size = AvatarSize.UserListItem,
location = location, ),
isLive = true, formattedTimestamp = formattedTimestamp,
assetType = lastLocation.assetType, location = location,
) isLive = true,
}.toImmutableList() assetType = lastLocation.assetType,
)
}
.toImmutableList()
}.collect { value = it } }.collect { value = it }
}.value }.value
} }

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.show
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import org.junit.Test
class LiveLocationShareComparatorTest {
private val currentUser = UserId("@me:matrix.org")
private val comparator = LiveLocationShareComparator(currentUser)
@Test
fun `compare returns zero when comparing the same current user share`() {
val share = aLiveLocationShare(userId = currentUser, startTimestamp = 123L)
val result = comparator.compare(share, share)
assertThat(result).isEqualTo(0)
}
@Test
fun `compare orders current user share before another user share`() {
val otherShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L)
val currentUserShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L)
val sortedShares = listOf(otherShare, currentUserShare).sortedWith(comparator)
assertThat(sortedShares).containsExactly(currentUserShare, otherShare).inOrder()
}
@Test
fun `compare orders current user shares by newest start timestamp first`() {
val newerShare = aLiveLocationShare(userId = currentUser, startTimestamp = 200L)
val olderShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L)
val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator)
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
}
@Test
fun `compare orders non current user shares by newest start timestamp first`() {
val newerShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L)
val olderShare = aLiveLocationShare(userId = UserId("@bob:matrix.org"), startTimestamp = 100L)
val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator)
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
}
}
private fun aLiveLocationShare(
userId: UserId,
startTimestamp: Long,
): LiveLocationShare {
return LiveLocationShare(
userId = userId,
lastLocation = null,
startTimestamp = startTimestamp,
endTimestamp = startTimestamp + 1_000L,
)
}

View file

@ -469,6 +469,7 @@ private fun aLiveLocationShare(
userId: UserId, userId: UserId,
geoUri: String = "geo:48.8584,2.2945", geoUri: String = "geo:48.8584,2.2945",
timestamp: Long = 0L, timestamp: Long = 0L,
startTimestamp: Long = 0L,
endTimestamp: Long = Long.MAX_VALUE, endTimestamp: Long = Long.MAX_VALUE,
assetType: AssetType = AssetType.SENDER, assetType: AssetType = AssetType.SENDER,
): LiveLocationShare { ): LiveLocationShare {
@ -479,6 +480,7 @@ private fun aLiveLocationShare(
timestamp = timestamp, timestamp = timestamp,
assetType = assetType, assetType = assetType,
), ),
startTimestamp = startTimestamp,
endTimestamp = endTimestamp, endTimestamp = endTimestamp,
) )
} }

View file

@ -17,6 +17,8 @@ data class LiveLocationShare(
val userId: UserId, val userId: UserId,
/** The last known location if any. */ /** The last known location if any. */
val lastLocation: LastLocation?, val lastLocation: LastLocation?,
/** The timestamp when location sharing started, in milliseconds.*/
val startTimestamp: Long,
/** The timestamp when location sharing ends, in milliseconds. */ /** The timestamp when location sharing ends, in milliseconds. */
val endTimestamp: Long, val endTimestamp: Long,
) )

View file

@ -68,6 +68,7 @@ private fun RustLiveLocationShare.into(): LiveLocationShare {
assetType = it.location.asset.into(), assetType = it.location.asset.into(),
) )
}, },
startTimestamp = startTs.toLong(),
endTimestamp = (startTs + timeout).toLong() endTimestamp = (startTs + timeout).toLong()
) )
} }