Feature : share live location (#6741)

* First live location sharing sending implementation

* Simplify logic around canStop sharing

* Add some debug logs around LiveLocationSharingService

* Add LiveLocationException

* Expose beaconId to identify the current share

* Throttle live location instead of debouncing

* Keep sync alive when sharing live location

* Improve LiveLocation sharing

* Show LiveLocationDisclaimer

* Read minDistanceUpdate in LiveLocationSharingService

* Set minDistanceUpdate in AdvancedSettings

* Display banner in room when sharing live location

* Fix tests around LiveLocationSharing

* Ensure shares are properly restarted/stopped when app is re-launched

* Ensure LLS data is cleared when session is removed

* Update and fix LLS tests

* Handle Start LLS in ui

* Add check LLS permissions

* Remove hardcoded strings

* Fix quality and format

* Create DeviceLocationProvider so we can share location data between sources (presenter/live location service)

* Update screenshots

* Fix warning

* Do not try to stop if it was not sharing

* Revert "Create DeviceLocationProvider so we can share location data between sources (presenter/live location service)"

This reverts commit ba12bd968e82941cc231bdbb449310b24c97c5b8.

* Tweak location provider config values

* Address PR review remarks

* Fix ktlint

* Update screenshots

* Fix some tests after merging develop

* Adjust TimelineItemLocationView ui to match figma

* Update screenshots

* Documentation and cleanup

* Remove temporary resource

---------

Co-authored-by: ElementBot <android@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
Co-authored-by: Benoit Marty <benoitm@matrix.org>
This commit is contained in:
ganfra 2026-05-11 10:19:28 +02:00 committed by GitHub
parent 0c657c258a
commit e49e183178
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
145 changed files with 2913 additions and 278 deletions

View file

@ -18,6 +18,8 @@ sealed interface MessagesEvent {
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
}

View file

@ -278,6 +278,10 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
}
override fun navigateToCurrentLiveLocation() {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callData = CallData(
sessionId = sessionId,
@ -513,6 +517,10 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
}
override fun navigateToCurrentLiveLocation() {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callData = CallData(
sessionId = sessionId,

View file

@ -26,5 +26,6 @@ interface MessagesNavigator {
fun navigateToMember(userId: UserId)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
fun navigateToCurrentLiveLocation()
fun close()
}

View file

@ -127,6 +127,7 @@ class MessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToCurrentLiveLocation()
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToRoomDetails()
@ -239,6 +240,10 @@ class MessagesNode(
callback.navigateToDeveloperSettings()
}
override fun navigateToCurrentLiveLocation() {
callback.navigateToCurrentLiveLocation()
}
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}

View file

@ -27,6 +27,8 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.api.live.isCurrentlySharing
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.MessagesState.Threads
import io.element.android.features.messages.impl.actionlist.ActionListState
@ -79,6 +81,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -126,6 +129,7 @@ class MessagesPresenter(
private val featureFlagService: FeatureFlagService,
private val addRecentEmoji: AddRecentEmoji,
private val markAsFullyRead: MarkAsFullyRead,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<MessagesState> {
@AssistedFactory
@ -172,6 +176,7 @@ class MessagesPresenter(
}
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState()
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
@ -260,6 +265,18 @@ class MessagesPresenter(
is MessagesEvent.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
}
MessagesEvent.StopLiveLocationShare -> {
localCoroutineScope.launch {
liveLocationShareManager.stopShare(room.roomId)
.onFailure {
Timber.e(it, "Failed to stop live location share for roomId=${room.roomId}")
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
}
}
}
MessagesEvent.ShowLiveLocationShare -> {
navigator.navigateToCurrentLiveLocation()
}
is MessagesEvent.MarkAsFullyReadAndExit -> if (!markingAsReadAndExiting.getAndSet(true)) {
coroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
@ -311,6 +328,7 @@ class MessagesPresenter(
// TODO calculate this properly based on the thread list and the read state of each thread
hasUnreadThreads = false,
),
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
eventSink = ::handleEvent,
)
}

View file

@ -58,6 +58,7 @@ data class MessagesState(
val topBarSharedHistoryIcon: SharedHistoryIcon,
val successorRoom: SuccessorRoom?,
val threads: Threads,
val showLiveLocationShareBanner: Boolean,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null

View file

@ -80,6 +80,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
currentPinnedMessageIndex = 0,
),
),
aMessagesState(isCurrentlySharingLiveLocationInRoom = true),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
aMessagesState(
timelineState = aTimelineState(
@ -127,6 +128,7 @@ fun aMessagesState(
hasThreads = false,
hasUnreadThreads = false,
),
isCurrentlySharingLiveLocationInRoom: Boolean = false,
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@ -156,6 +158,7 @@ fun aMessagesState(
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = successorRoom,
threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
eventSink = eventSink,
)

View file

@ -56,6 +56,7 @@ 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.LiveLocationSharingBanner
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.impl.actionlist.ActionListEvent
import io.element.android.features.messages.impl.actionlist.ActionListView
@ -205,15 +206,15 @@ fun MessagesView(
val expandableState = rememberExpandableBottomSheetLayoutState()
ExpandableBottomSheetLayout(
modifier = modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
@ -250,8 +251,8 @@ fun MessagesView(
content = { padding ->
Box(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.padding(padding)
.consumeWindowInsets(padding)
) {
MessagesViewContent(
state = state,
@ -288,10 +289,10 @@ fun MessagesView(
SuggestionsPickerView(
modifier = Modifier
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
roomId = state.roomId,
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
@ -467,9 +468,9 @@ private fun MessagesViewContent(
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
) {
AttachmentsBottomSheet(
state = state.composerState,
@ -520,25 +521,34 @@ private fun MessagesViewContent(
)
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
Column {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
if (state.showLiveLocationShareBanner) {
LiveLocationSharingBanner(
onClick = { state.eventSink(MessagesEvent.ShowLiveLocationShare) },
onStopClick = { state.eventSink(MessagesEvent.StopLiveLocationShare) }
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
knockRequestsBannerView()
}
knockRequestsBannerView()
}
}
}
@ -587,9 +597,9 @@ private fun MessagesViewComposerBottomSheetContents(
private fun CantSendMessageBanner() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {

View file

@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
@ -127,7 +128,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
content = aTimelineItemLocationContent(),
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
@ -140,7 +141,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
content = aTimelineItemLocationContent(),
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",

View file

@ -136,6 +136,7 @@ class ThreadedMessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToCurrentLiveLocation()
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
@ -248,6 +249,11 @@ class ThreadedMessagesNode(
callback.navigateToDeveloperSettings()
}
override fun navigateToCurrentLiveLocation() {
// Shouldn't happen because LiveLocationSharingBanner is not shown in threads.
callback.navigateToCurrentLiveLocation()
}
override fun close() = navigateUp()
@Composable

View file

@ -23,6 +23,7 @@ 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.live.ActiveLiveLocationShareManager
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
@ -94,6 +95,7 @@ class TimelinePresenter(
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@ -200,7 +202,9 @@ class TimelinePresenter(
is TimelineEvent.EditPoll -> {
navigator.navigateToEditPoll(event.pollStartId)
}
is TimelineEvent.StopLiveLocationShare -> Unit
is TimelineEvent.StopLiveLocationShare -> sessionCoroutineScope.launch {
liveLocationShareManager.stopShare(room.roomId)
}
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
delay(event.debounce)

View file

@ -783,7 +783,7 @@ private fun MessageEventBubbleContent(
val content = content.ensureActiveLiveLocation()
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
content.mode.isActive &&
content.mode.canStop
content.mode.isOwnUser
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
}
is TimelineItemPollContent -> TimestampPosition.Below

View file

@ -13,14 +13,12 @@ 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.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -77,13 +75,14 @@ private fun LiveLocationOverlay(
Row(
modifier = modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
.padding(horizontal = 8.dp, vertical = 8.dp),
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f)),
verticalAlignment = Alignment.CenterVertically,
) {
val iconShape = RoundedCornerShape(8.dp)
Box(
modifier = Modifier
// Ensure this Box uses same spacings than the Stop IconButton.
.minimumInteractiveComponentSize()
.size(32.dp)
.border(
width = 1.dp,
@ -120,7 +119,6 @@ private fun LiveLocationOverlay(
)
}
}
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (mode.isActive) {
@ -140,13 +138,16 @@ private fun LiveLocationOverlay(
}
}
if (mode.isActive && mode.canStop) {
if (mode.canStopSharing) {
IconButton(
onClick = onStopClick,
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgCriticalPrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
),
modifier = Modifier
.minimumInteractiveComponentSize()
.size(30.dp)
) {
Icon(
imageVector = CompoundIcons.Stop(),

View file

@ -8,7 +8,9 @@
package io.element.android.features.messages.impl.timeline.di
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
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.libraries.voiceplayer.api.aVoiceMessageState
@ -18,6 +20,12 @@ import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
*/
fun aFakeTimelineItemPresenterFactories() = TimelineItemPresenterFactories(
mapOf(
Pair(
TimelineItemLocationContent::class,
TimelineItemPresenterFactory<TimelineItemLocationContent, TimelineItemLocationContent> { content ->
Presenter { content.ensureActiveLiveLocation() }
},
),
Pair(
TimelineItemVoiceContent::class,
TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> { Presenter { aVoiceMessageState() } },

View file

@ -127,6 +127,7 @@ class TimelineItemContentFactory(
isActive = itemContent.isLive,
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
endTimestamp = itemContent.endTimestamp,
isOwnUser = sessionId == sender
),
)
}

View file

@ -27,7 +27,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemAudioContent(),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
aTimelineItemVoiceContent(),
aTimelineItemLocationContent(),
aTimelineItemLocationContent(mode = aStaticLocationMode()),
aTimelineItemPollContent(),
aTimelineItemNoticeContent(),
aTimelineItemRedactedContent(),
@ -36,7 +36,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemTextContent().copy(isEdited = true),
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
mode = aLiveLocationMode(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
),
)
}

View file

@ -72,9 +72,10 @@ data class TimelineItemLocationContent(
val isActive: Boolean,
val endsAt: String,
val endTimestamp: Long,
val canStop: Boolean = false,
val isOwnUser: Boolean,
) : Mode {
val isLoading = lastKnownLocation == null && isActive
val canStopSharing = isActive && isOwnUser
}
}

View file

@ -17,48 +17,44 @@ import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsRead
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
override val values: Sequence<TimelineItemLocationContent>
get() = sequenceOf(
aTimelineItemLocationContent(),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
canStop = true,
lastKnownLocation = aLocation()
),
mode = aStaticLocationMode()
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
mode = aLiveLocationMode(isActive = true)
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = null
),
mode = aLiveLocationMode(isActive = true, lastKnownLocation = null)
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = false,
endsAt = "",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
mode = aLiveLocationMode(isActive = true, isOwnUser = false)
),
aTimelineItemLocationContent(
mode = aLiveLocationMode(isActive = false)
),
)
}
fun aLiveLocationMode(
isActive: Boolean,
isOwnUser: Boolean = true,
lastKnownLocation: Location? = aLocation(),
endsAt: String = "Ends at 12:34",
endTimestamp: Long = 0L,
): TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Live(
isActive = isActive,
endsAt = endsAt,
endTimestamp = endTimestamp,
isOwnUser = isOwnUser,
lastKnownLocation = lastKnownLocation
)
fun aStaticLocationMode(location: Location = aLocation()) = TimelineItemLocationContent.Mode.Static(location)
fun aTimelineItemLocationContent(
senderId: UserId = UserId("@sender:matrix.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(),
description: String? = null,
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
mode: TimelineItemLocationContent.Mode,
) = TimelineItemLocationContent(
senderId = senderId,
senderProfile = senderProfile,

View file

@ -28,6 +28,7 @@ class FakeMessagesNavigator(
private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() },
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
private val closeLambda: () -> Unit = { lambdaError() },
private val navigateToCurrentLiveLocationLambda: () -> Unit = { lambdaError() },
) : MessagesNavigator {
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickLambda(eventId, debugInfo)
@ -65,6 +66,10 @@ class FakeMessagesNavigator(
navigateToDeveloperSettingsLambda()
}
override fun navigateToCurrentLiveLocation() {
navigateToCurrentLiveLocationLambda()
}
override fun close() {
closeLambda()
}

View file

@ -13,6 +13,7 @@ package io.element.android.features.messages.impl
import androidx.lifecycle.Lifecycle
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.impl.actionlist.ActionListEvent
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
@ -120,6 +121,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class MessagesPresenterTest {
@ -140,6 +142,39 @@ class MessagesPresenterTest {
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.showReinvitePrompt).isFalse()
assertThat(initialState.showLiveLocationShareBanner).isFalse()
}
}
@Test
fun `present - exposes live location sharing banner visibility for current room`() = runTest {
val liveLocationShareManager = FakeActiveLiveLocationShareManager(
startShareLambda = { _, _ -> Result.success(Unit) },
)
liveLocationShareManager.startShare(A_ROOM_ID, 60.seconds)
val presenter = createMessagesPresenter(liveLocationShareManager = liveLocationShareManager)
presenter.testWithLifecycleOwner {
val state = consumeItemsUntilTimeout().last()
assertThat(state.showLiveLocationShareBanner).isTrue()
}
}
@Test
fun `present - stop live location share delegates to manager for current room`() = runTest {
val stopShareLambda = lambdaRecorder<RoomId, Result<Unit>> { Result.success(Unit) }
val liveLocationShareManager = FakeActiveLiveLocationShareManager(
stopShareLambda = stopShareLambda
)
val presenter = createMessagesPresenter(liveLocationShareManager = liveLocationShareManager)
presenter.testWithLifecycleOwner {
val state = consumeItemsUntilTimeout().last()
state.eventSink(MessagesEvent.StopLiveLocationShare)
advanceUntilIdle()
assert(stopShareLambda)
.isCalledOnce()
.with(value(A_ROOM_ID))
}
}
@ -1347,6 +1382,7 @@ class MessagesPresenterTest {
actionListEventSink: (ActionListEvent) -> Unit = {},
addRecentEmoji: AddRecentEmoji = AddRecentEmoji { _ -> lambdaError() },
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
): MessagesPresenter {
return MessagesPresenter(
navigator = navigator,
@ -1376,6 +1412,7 @@ class MessagesPresenterTest {
featureFlagService = featureFlagService,
addRecentEmoji = addRecentEmoji,
markAsFullyRead = markAsFullyRead,
liveLocationShareManager = liveLocationShareManager,
sessionCoroutineScope = backgroundScope,
)
}

View file

@ -643,6 +643,44 @@ class MessagesViewTest {
assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message)
assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action)
}
@Test
fun `live location banner is visible when current room is sharing`() = runAndroidComposeUiTest {
val state = aMessagesState(isCurrentlySharingLiveLocationInRoom = true)
setMessagesView(state = state)
onNodeWithText(activity!!.getString(CommonStrings.screen_room_live_location_banner)).assertExists()
}
@Test
fun `live location banner is hidden when current room is not sharing`() = runAndroidComposeUiTest {
val state = aMessagesState(isCurrentlySharingLiveLocationInRoom = false)
setMessagesView(state = state)
onNodeWithText(activity!!.getString(CommonStrings.screen_room_live_location_banner)).assertDoesNotExist()
}
@Test
fun `clicking stop on live location banner emits expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(
isCurrentlySharingLiveLocationInRoom = true,
eventSink = eventsRecorder,
)
setMessagesView(state = state)
clickOn(CommonStrings.action_stop)
eventsRecorder.assertSingle(MessagesEvent.StopLiveLocationShare)
}
@Test
fun `clicking live location banner emit expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(
isCurrentlySharingLiveLocationInRoom = true,
eventSink = eventsRecorder,
)
setMessagesView(state = state)
clickOn(CommonStrings.screen_room_live_location_banner)
eventsRecorder.assertSingle(MessagesEvent.ShowLiveLocationShare)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
@ -1012,6 +1013,7 @@ class TimelinePresenterTest {
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@ -1030,6 +1032,7 @@ class TimelinePresenterTest {
roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService,
analyticsService = FakeAnalyticsService(),
liveLocationShareManager = liveLocationShareManager,
)
}
}