Live location : start collecting live location

This commit is contained in:
ganfra 2026-04-03 18:21:37 +02:00
parent a7e254cc84
commit 4e0165458a
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.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<ShowLocationState> {
@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
}
}

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.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(),

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.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<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()
}
}
}