diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index a2c9a3702d..b72c01e697 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -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,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.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.matrix.api.room.location.AssetType 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.toPersistentList +import kotlinx.coroutines.flow.combine @AssistedInject class ShowLocationPresenter( @@ -46,6 +55,7 @@ class ShowLocationPresenter( private val buildMeta: BuildMeta, private val dateFormatter: DateFormatter, private val stringProvider: StringProvider, + private val joinedRoom: JoinedRoom, ) : Presenter { @AssistedFactory fun interface Factory { @@ -96,9 +106,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,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 } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index 451531fc7e..91df447e2a 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -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(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 931dd55cea..81ec465686 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -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.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.LiveLocationShare 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.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -51,13 +57,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 +326,171 @@ class ShowLocationPresenterTest { 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()) + 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() + } + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 38d0504258..2d6a0f8c68 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -558,17 +558,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 + 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 } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt index 7e841639bd..5f9cd41462 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt @@ -19,6 +19,4 @@ data class LiveLocationShare( val lastGeoUri: String, /** The timestamp of the last location update. */ val lastTimestamp: Long, - /** Whether the live location share is still active. */ - val isLive: Boolean, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 644c5aefc2..0c41824dde 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -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.join.map 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.roomdirectory.map 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.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.KnockRequestsListener -import org.matrix.rustcomponents.sdk.LiveLocationShareListener import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate import org.matrix.rustcomponents.sdk.SendQueueListener @@ -504,13 +503,7 @@ class JoinedRustRoom( } override fun subscribeToLiveLocationShares(): Flow> { - return mxCallbackFlow { - innerRoom.subscribeToLiveLocationShares(object : LiveLocationShareListener { - override fun call(liveLocationShares: List) { - trySend(liveLocationShares.map { it.map() }) - } - }) - } + return innerRoom.liveLocationSharesFlow() } override suspend fun startLiveLocationShare(durationMillis: Long): Result = withContext(roomDispatcher) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt deleted file mode 100644 index 3b80c1c61f..0000000000 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt +++ /dev/null @@ -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, - ) -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt new file mode 100644 index 0000000000..7b3a29cf4a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt @@ -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> { + fun MutableList.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 = ArrayList() + subscribeToLiveLocationShares(object : LiveLocationShareListener { + override fun onUpdate(updates: List) { + 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, + ) +} +