Live location : start collecting live location

This commit is contained in:
ganfra 2026-04-03 18:21:37 +02:00
parent 969460d3da
commit 22bfb7feb6
8 changed files with 295 additions and 49 deletions

View file

@ -13,11 +13,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
@ -29,14 +31,21 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.combine
@AssistedInject @AssistedInject
class ShowLocationPresenter( class ShowLocationPresenter(
@ -46,6 +55,7 @@ class ShowLocationPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val joinedRoom: JoinedRoom,
) : Presenter<ShowLocationState> { ) : Presenter<ShowLocationState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@ -96,9 +106,9 @@ class ShowLocationPresenter(
} }
} }
val locationShares = remember { val locationShares = when (mode) {
when (mode) { is ShowLocationMode.Static -> {
is ShowLocationMode.Static -> { remember {
val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true) val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true)
val formattedTimestamp = stringProvider.getString( val formattedTimestamp = stringProvider.getString(
CommonStrings.screen_static_location_sheet_timestamp_description, CommonStrings.screen_static_location_sheet_timestamp_description,
@ -121,7 +131,35 @@ class ShowLocationPresenter(
) )
) )
} }
ShowLocationMode.Live -> persistentListOf() }
ShowLocationMode.Live -> {
val liveShares by produceState(persistentListOf()) {
val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares()
val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() }
combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members ->
liveShares.mapNotNull { share ->
val location = Location.fromGeoUri(share.lastGeoUri) ?: return@mapNotNull null
val member = members.find { it.userId == share.userId }
val displayName = member?.getBestName() ?: share.userId.value
val avatarUrl = member?.avatarUrl
LocationShareItem(
userId = share.userId,
displayName = displayName,
avatarData = AvatarData(
id = share.userId.value,
name = displayName,
url = avatarUrl,
size = AvatarSize.UserListItem,
),
formattedTimestamp = "Sharing live location",
location = location,
isLive = true,
assetType = AssetType.SENDER,
)
}.toPersistentList()
}.collect { value = it }
}
liveShares
} }
} }

View file

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

View file

@ -22,11 +22,17 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.FakeLiveLocationShareService
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -51,13 +57,15 @@ class ShowLocationPresenterTest {
assetType = null, assetType = null,
), ),
locationActions: FakeLocationActions = fakeLocationActions, locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
) = ShowLocationPresenter( ) = ShowLocationPresenter(
mode = mode, mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter }, permissionsPresenterFactory = { fakePermissionsPresenter },
locationActions = locationActions, locationActions = locationActions,
buildMeta = fakeBuildMeta, buildMeta = fakeBuildMeta,
dateFormatter = fakeDateFormatter, dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider() stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
) )
@Test @Test
@ -318,4 +326,171 @@ class ShowLocationPresenterTest {
assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1) assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
} }
} }
@Test
fun `live mode emits empty location shares initially`() = runTest {
val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live,
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(
LiveLocationShare(
userId = userId,
lastGeoUri = "geo:48.8584,2.2945",
lastTimestamp = 1234567890L,
isLive = true,
)
)
)
val fakeRoom = FakeJoinedRoom(
liveLocationShareService = FakeLiveLocationShareService(
liveLocationSharesFlow = liveSharesFlow
)
)
val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live,
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(
LiveLocationShare(
userId = validUserId,
lastGeoUri = "geo:48.8584,2.2945",
lastTimestamp = 1234567890L,
isLive = true,
),
LiveLocationShare(
userId = invalidUserId,
lastGeoUri = "invalid-geo-uri",
lastTimestamp = 1234567890L,
isLive = true,
),
)
)
val fakeRoom = FakeJoinedRoom(
liveLocationShareService = FakeLiveLocationShareService(
liveLocationSharesFlow = liveSharesFlow
)
)
val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live,
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(
liveLocationShareService = FakeLiveLocationShareService(
liveLocationSharesFlow = liveSharesFlow
)
)
val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live,
joinedRoom = fakeRoom,
)
presenter.test {
// Initial state is empty
val initialState = awaitItem()
assertThat(initialState.locationShares).isEmpty()
// Emit a new live share
liveSharesFlow.value = listOf(
LiveLocationShare(
userId = userId,
lastGeoUri = "geo:48.8584,2.2945",
lastTimestamp = 1234567890L,
isLive = true,
)
)
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 = 1234567890L,
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()
}
}
} }

View file

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

View file

@ -19,6 +19,4 @@ data class LiveLocationShare(
val lastGeoUri: String, val lastGeoUri: String,
/** The timestamp of the last location update. */ /** The timestamp of the last location update. */
val lastTimestamp: Long, val lastTimestamp: Long,
/** Whether the live location share is still active. */
val isLive: Boolean,
) )

View file

@ -43,7 +43,7 @@ import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
import io.element.android.libraries.matrix.impl.room.location.map import io.element.android.libraries.matrix.impl.room.location.liveLocationSharesFlow
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline import io.element.android.libraries.matrix.impl.timeline.RustTimeline
@ -68,7 +68,6 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.KnockRequestsListener import org.matrix.rustcomponents.sdk.KnockRequestsListener
import org.matrix.rustcomponents.sdk.LiveLocationShareListener
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
import org.matrix.rustcomponents.sdk.SendQueueListener import org.matrix.rustcomponents.sdk.SendQueueListener
@ -504,13 +503,7 @@ class JoinedRustRoom(
} }
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> { override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
return mxCallbackFlow { return innerRoom.liveLocationSharesFlow()
innerRoom.subscribeToLiveLocationShares(object : LiveLocationShareListener {
override fun call(liveLocationShares: List<org.matrix.rustcomponents.sdk.LiveLocationShare>) {
trySend(liveLocationShares.map { it.map() })
}
})
}
} }
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) { override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {

View file

@ -1,21 +0,0 @@
/*
* Copyright (c) 2025 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.LiveLocationShare
import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare
fun RustLiveLocationShare.map(): LiveLocationShare {
return LiveLocationShare(
userId = UserId(userId),
lastGeoUri = lastLocation.location.geoUri,
lastTimestamp = lastLocation.ts.toLong(),
isLive = isLive,
)
}

View file

@ -0,0 +1,60 @@
/*
* 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.LiveLocationShare
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare
import org.matrix.rustcomponents.sdk.LiveLocationShareListener
import org.matrix.rustcomponents.sdk.LiveLocationShareUpdate
import org.matrix.rustcomponents.sdk.RoomInterface
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 mxCallbackFlow {
val shares: MutableList<LiveLocationShare> = ArrayList()
subscribeToLiveLocationShares(object : LiveLocationShareListener {
override fun onUpdate(updates: List<LiveLocationShareUpdate>) {
for (update in updates) {
shares.applyUpdate(update)
}
trySend(shares)
}
})
}.buffer(Channel.UNLIMITED)
}
private fun RustLiveLocationShare.into(): LiveLocationShare {
return LiveLocationShare(
userId = UserId(userId),
lastGeoUri = lastLocation?.location?.geoUri.orEmpty(),
lastTimestamp = lastLocation?.ts?.toLong() ?: 0,
)
}