Update live location shares when reaching timeout (before actual stop event)
This commit is contained in:
parent
8cefebdc36
commit
e162e667be
17 changed files with 331 additions and 64 deletions
|
|
@ -142,7 +142,7 @@ class ShowLocationPresenter(
|
||||||
val member = members.find { it.userId == share.userId }
|
val member = members.find { it.userId == share.userId }
|
||||||
val displayName = member?.getBestName() ?: share.userId.value
|
val displayName = member?.getBestName() ?: share.userId.value
|
||||||
val avatarUrl = member?.avatarUrl
|
val avatarUrl = member?.avatarUrl
|
||||||
val relativeTime = dateFormatter.format(timestamp = share.lastLocation?.timestamp, mode = DateFormatterMode.Full, useRelative = true)
|
val relativeTime = dateFormatter.format(timestamp = lastLocation.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,
|
||||||
relativeTime
|
relativeTime
|
||||||
|
|
|
||||||
|
|
@ -24,19 +24,21 @@ 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.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.LastLocation
|
||||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
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.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.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class ShowLocationPresenterTest {
|
class ShowLocationPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
@ -330,7 +332,7 @@ class ShowLocationPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `live mode emits empty location shares initially`() = runTest {
|
fun `live mode emits empty location shares initially`() = runTest {
|
||||||
val presenter = createShowLocationPresenter(
|
val presenter = createShowLocationPresenter(
|
||||||
mode = ShowLocationMode.Live,
|
mode = ShowLocationMode.Live(senderId = UserId("@alice:matrix.org")),
|
||||||
joinedRoom = FakeJoinedRoom(),
|
joinedRoom = FakeJoinedRoom(),
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
|
|
@ -345,22 +347,13 @@ class ShowLocationPresenterTest {
|
||||||
val userId = UserId("@bob:matrix.org")
|
val userId = UserId("@bob:matrix.org")
|
||||||
val liveSharesFlow = MutableStateFlow(
|
val liveSharesFlow = MutableStateFlow(
|
||||||
listOf(
|
listOf(
|
||||||
LiveLocationShare(
|
aLiveLocationShare(userId = userId)
|
||||||
userId = userId,
|
|
||||||
lastGeoUri = "geo:48.8584,2.2945",
|
|
||||||
lastTimestamp = 1234567890L,
|
|
||||||
isLive = true,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val fakeRoom = FakeJoinedRoom(
|
|
||||||
liveLocationShareService = FakeLiveLocationShareService(
|
|
||||||
liveLocationSharesFlow = liveSharesFlow
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
|
||||||
|
|
||||||
val presenter = createShowLocationPresenter(
|
val presenter = createShowLocationPresenter(
|
||||||
mode = ShowLocationMode.Live,
|
mode = ShowLocationMode.Live(senderId = userId),
|
||||||
joinedRoom = fakeRoom,
|
joinedRoom = fakeRoom,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
|
|
@ -384,28 +377,14 @@ class ShowLocationPresenterTest {
|
||||||
val invalidUserId = UserId("@bob:matrix.org")
|
val invalidUserId = UserId("@bob:matrix.org")
|
||||||
val liveSharesFlow = MutableStateFlow(
|
val liveSharesFlow = MutableStateFlow(
|
||||||
listOf(
|
listOf(
|
||||||
LiveLocationShare(
|
aLiveLocationShare(userId = validUserId),
|
||||||
userId = validUserId,
|
aLiveLocationShare(userId = invalidUserId, geoUri = "invalid-geo-uri"),
|
||||||
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 fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
|
||||||
|
|
||||||
val presenter = createShowLocationPresenter(
|
val presenter = createShowLocationPresenter(
|
||||||
mode = ShowLocationMode.Live,
|
mode = ShowLocationMode.Live(senderId = validUserId),
|
||||||
joinedRoom = fakeRoom,
|
joinedRoom = fakeRoom,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
|
|
@ -423,14 +402,10 @@ class ShowLocationPresenterTest {
|
||||||
fun `live mode updates when shares change`() = runTest {
|
fun `live mode updates when shares change`() = runTest {
|
||||||
val userId = UserId("@bob:matrix.org")
|
val userId = UserId("@bob:matrix.org")
|
||||||
val liveSharesFlow = MutableStateFlow(emptyList<LiveLocationShare>())
|
val liveSharesFlow = MutableStateFlow(emptyList<LiveLocationShare>())
|
||||||
val fakeRoom = FakeJoinedRoom(
|
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
|
||||||
liveLocationShareService = FakeLiveLocationShareService(
|
|
||||||
liveLocationSharesFlow = liveSharesFlow
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val presenter = createShowLocationPresenter(
|
val presenter = createShowLocationPresenter(
|
||||||
mode = ShowLocationMode.Live,
|
mode = ShowLocationMode.Live(senderId = userId),
|
||||||
joinedRoom = fakeRoom,
|
joinedRoom = fakeRoom,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
|
|
@ -440,12 +415,7 @@ class ShowLocationPresenterTest {
|
||||||
|
|
||||||
// Emit a new live share
|
// Emit a new live share
|
||||||
liveSharesFlow.value = listOf(
|
liveSharesFlow.value = listOf(
|
||||||
LiveLocationShare(
|
aLiveLocationShare(userId = userId)
|
||||||
userId = userId,
|
|
||||||
lastGeoUri = "geo:48.8584,2.2945",
|
|
||||||
lastTimestamp = 1234567890L,
|
|
||||||
isLive = true,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val updatedState = awaitItem()
|
val updatedState = awaitItem()
|
||||||
|
|
@ -494,3 +464,21 @@ class ShowLocationPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun aLiveLocationShare(
|
||||||
|
userId: UserId,
|
||||||
|
geoUri: String = "geo:48.8584,2.2945",
|
||||||
|
timestamp: Long = 1234567890L,
|
||||||
|
endTimestamp: Long = Long.MAX_VALUE,
|
||||||
|
assetType: AssetType = AssetType.SENDER,
|
||||||
|
): LiveLocationShare {
|
||||||
|
return LiveLocationShare(
|
||||||
|
userId = userId,
|
||||||
|
lastLocation = LastLocation(
|
||||||
|
geoUri = geoUri,
|
||||||
|
timestamp = timestamp,
|
||||||
|
assetType = assetType,
|
||||||
|
),
|
||||||
|
endTimestamp = endTimestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||||
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
|
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
|
||||||
|
|
@ -777,7 +778,13 @@ private fun MessageEventBubbleContent(
|
||||||
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||||
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||||
is TimelineItemStickerContent -> TimestampPosition.Overlay
|
is TimelineItemStickerContent -> TimestampPosition.Overlay
|
||||||
is TimelineItemLocationContent -> if (content.hideTimestamp) TimestampPosition.Hidden else TimestampPosition.Overlay
|
is TimelineItemLocationContent -> {
|
||||||
|
val content = content.ensureActiveLiveLocation()
|
||||||
|
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
|
||||||
|
content.mode.isActive &&
|
||||||
|
content.mode.canStop
|
||||||
|
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
|
||||||
|
}
|
||||||
is TimelineItemPollContent -> TimestampPosition.Below
|
is TimelineItemPollContent -> TimestampPosition.Below
|
||||||
else -> TimestampPosition.Default
|
else -> TimestampPosition.Default
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
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.architecture.Presenter
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import io.element.android.wysiwyg.link.Link
|
import io.element.android.wysiwyg.link.Link
|
||||||
|
|
@ -71,11 +72,13 @@ fun TimelineItemEventContentView(
|
||||||
onContentLayoutChange = onContentLayoutChange,
|
onContentLayoutChange = onContentLayoutChange,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
is TimelineItemLocationContent -> TimelineItemLocationView(
|
is TimelineItemLocationContent -> {
|
||||||
content = content,
|
TimelineItemLocationView(
|
||||||
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
content = content.ensureActiveLiveLocation(),
|
||||||
modifier = modifier
|
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||||
)
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
is TimelineItemImageContent -> TimelineItemImageView(
|
is TimelineItemImageContent -> TimelineItemImageView(
|
||||||
content = content,
|
content = content,
|
||||||
hideMediaContent = hideMediaContent,
|
hideMediaContent = hideMediaContent,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
|
@ -32,12 +33,14 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.location.api.StaticMapView
|
import io.element.android.features.location.api.StaticMapView
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemLocationView(
|
fun TimelineItemLocationView(
|
||||||
|
|
@ -121,7 +124,12 @@ private fun LiveLocationOverlay(
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = if (mode.isActive) "Live location" else "Live location ended",
|
text = if (mode.isActive) {
|
||||||
|
stringResource(CommonStrings.common_live_location)
|
||||||
|
} else {
|
||||||
|
stringResource(CommonStrings.common_live_location_ended)
|
||||||
|
},
|
||||||
|
|
||||||
style = ElementTheme.typography.fontBodySmMedium,
|
style = ElementTheme.typography.fontBodySmMedium,
|
||||||
color = ElementTheme.colors.textPrimary,
|
color = ElementTheme.colors.textPrimary,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ class TimelineItemContentFactory(
|
||||||
}.lastOrNull()
|
}.lastOrNull()
|
||||||
|
|
||||||
val endsAt = dateFormatter.format(
|
val endsAt = dateFormatter.format(
|
||||||
timestamp = itemContent.endsAt,
|
timestamp = itemContent.endTimestamp,
|
||||||
mode = DateFormatterMode.TimeOnly
|
mode = DateFormatterMode.TimeOnly
|
||||||
)
|
)
|
||||||
// Always create content, location can be null for "loading/waiting" state
|
// Always create content, location can be null for "loading/waiting" state
|
||||||
|
|
@ -124,6 +124,7 @@ class TimelineItemContentFactory(
|
||||||
lastKnownLocation = lastKnownLocation,
|
lastKnownLocation = lastKnownLocation,
|
||||||
isActive = itemContent.isLive,
|
isActive = itemContent.isLive,
|
||||||
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
|
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
|
||||||
|
endTimestamp = itemContent.endTimestamp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||||
aTimelineItemUnknownContent(),
|
aTimelineItemUnknownContent(),
|
||||||
aTimelineItemTextContent().copy(isEdited = true),
|
aTimelineItemTextContent().copy(isEdited = true),
|
||||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
||||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", lastKnownLocation = null)),
|
aTimelineItemLocationContent(
|
||||||
|
mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.libraries.designsystem.components.PinVariant
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
|
@ -17,6 +19,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
data class TimelineItemLocationContent(
|
data class TimelineItemLocationContent(
|
||||||
val senderId: UserId,
|
val senderId: UserId,
|
||||||
|
|
@ -25,9 +28,6 @@ data class TimelineItemLocationContent(
|
||||||
val assetType: AssetType? = null,
|
val assetType: AssetType? = null,
|
||||||
val mode: Mode,
|
val mode: Mode,
|
||||||
) : TimelineItemEventContent {
|
) : TimelineItemEventContent {
|
||||||
|
|
||||||
val hideTimestamp = mode is Mode.Live && mode.canStop
|
|
||||||
|
|
||||||
val location = when (mode) {
|
val location = when (mode) {
|
||||||
is Mode.Live -> mode.lastKnownLocation
|
is Mode.Live -> mode.lastKnownLocation
|
||||||
is Mode.Static -> mode.location
|
is Mode.Static -> mode.location
|
||||||
|
|
@ -71,7 +71,8 @@ data class TimelineItemLocationContent(
|
||||||
val lastKnownLocation: Location?,
|
val lastKnownLocation: Location?,
|
||||||
val isActive: Boolean,
|
val isActive: Boolean,
|
||||||
val endsAt: String,
|
val endsAt: String,
|
||||||
val canStop: Boolean = false
|
val endTimestamp: Long,
|
||||||
|
val canStop: Boolean = false,
|
||||||
) : Mode {
|
) : Mode {
|
||||||
val isLoading = lastKnownLocation == null && isActive
|
val isLoading = lastKnownLocation == null && isActive
|
||||||
}
|
}
|
||||||
|
|
@ -79,3 +80,41 @@ data class TimelineItemLocationContent(
|
||||||
|
|
||||||
override val type: String = "TimelineItemLocationContent"
|
override val type: String = "TimelineItemLocationContent"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides the isActive value if needed, to make sure endTimestamp is used in absence of stop event.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineItemLocationContent.ensureActiveLiveLocation(
|
||||||
|
currentTimeMillis: () -> Long = System::currentTimeMillis,
|
||||||
|
): TimelineItemLocationContent {
|
||||||
|
return when (val mode = mode) {
|
||||||
|
is TimelineItemLocationContent.Mode.Live -> {
|
||||||
|
val isActive = rememberIsLiveLocationActive(mode, currentTimeMillis)
|
||||||
|
copy(mode = mode.copy(isActive = isActive))
|
||||||
|
}
|
||||||
|
is TimelineItemLocationContent.Mode.Static -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberIsLiveLocationActive(
|
||||||
|
mode: TimelineItemLocationContent.Mode.Live,
|
||||||
|
currentTimeMillis: () -> Long,
|
||||||
|
): Boolean {
|
||||||
|
|
||||||
|
fun TimelineItemLocationContent.Mode.Live.isActive(): Boolean {
|
||||||
|
return isActive && endTimestamp > currentTimeMillis()
|
||||||
|
}
|
||||||
|
return produceState(
|
||||||
|
initialValue = mode.isActive(),
|
||||||
|
key1 = mode.endTimestamp,
|
||||||
|
key2 = mode.isActive,
|
||||||
|
) {
|
||||||
|
if (mode.isActive) {
|
||||||
|
val remainingMillis = mode.endTimestamp - currentTimeMillis()
|
||||||
|
delay(remainingMillis)
|
||||||
|
}
|
||||||
|
value = false
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
||||||
mode = TimelineItemLocationContent.Mode.Live(
|
mode = TimelineItemLocationContent.Mode.Live(
|
||||||
isActive = true,
|
isActive = true,
|
||||||
endsAt = "Ends at 12:34",
|
endsAt = "Ends at 12:34",
|
||||||
|
endTimestamp = 0L,
|
||||||
canStop = true,
|
canStop = true,
|
||||||
lastKnownLocation = aLocation()
|
lastKnownLocation = aLocation()
|
||||||
),
|
),
|
||||||
|
|
@ -30,6 +31,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
||||||
mode = TimelineItemLocationContent.Mode.Live(
|
mode = TimelineItemLocationContent.Mode.Live(
|
||||||
isActive = true,
|
isActive = true,
|
||||||
endsAt = "Ends at 12:34",
|
endsAt = "Ends at 12:34",
|
||||||
|
endTimestamp = 0L,
|
||||||
lastKnownLocation = aLocation()
|
lastKnownLocation = aLocation()
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -37,6 +39,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
||||||
mode = TimelineItemLocationContent.Mode.Live(
|
mode = TimelineItemLocationContent.Mode.Live(
|
||||||
isActive = true,
|
isActive = true,
|
||||||
endsAt = "Ends at 12:34",
|
endsAt = "Ends at 12:34",
|
||||||
|
endTimestamp = 0L,
|
||||||
lastKnownLocation = null
|
lastKnownLocation = null
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -44,6 +47,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
||||||
mode = TimelineItemLocationContent.Mode.Live(
|
mode = TimelineItemLocationContent.Mode.Live(
|
||||||
isActive = false,
|
isActive = false,
|
||||||
endsAt = "",
|
endsAt = "",
|
||||||
|
endTimestamp = 0L,
|
||||||
lastKnownLocation = aLocation()
|
lastKnownLocation = aLocation()
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||||
|
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
||||||
|
|
@ -79,6 +80,8 @@ internal fun TestScope.aTimelineItemsFactory(
|
||||||
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
|
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
|
||||||
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
||||||
sessionId = matrixClient.sessionId,
|
sessionId = matrixClient.sessionId,
|
||||||
|
dateFormatter = FakeDateFormatter(),
|
||||||
|
stringProvider = FakeStringProvider(),
|
||||||
),
|
),
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
dateFormatter = FakeDateFormatter(),
|
dateFormatter = FakeDateFormatter(),
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,9 @@ class TimelineItemContentMessageFactoryTest {
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemLocationContent(
|
val expected = TimelineItemLocationContent(
|
||||||
location = Location(lat = 1.0, lon = 2.0, accuracy = null),
|
|
||||||
description = "description",
|
description = "description",
|
||||||
assetType = assetType,
|
assetType = assetType,
|
||||||
mode = TimelineItemLocationContent.Mode.Static,
|
mode = TimelineItemLocationContent.Mode.Static(location = Location(lat = 1.0, lon = 2.0, accuracy = null)),
|
||||||
senderId = A_USER_ID,
|
senderId = A_USER_ID,
|
||||||
senderProfile = aProfileDetails(),
|
senderProfile = aProfileDetails(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -107,12 +107,12 @@ data class FailedToParseStateContent(
|
||||||
data class LiveLocationContent(
|
data class LiveLocationContent(
|
||||||
val isLive: Boolean,
|
val isLive: Boolean,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val timestamp: Long,
|
val startTimestamp: Long,
|
||||||
val timeout: Long,
|
val timeout: Long,
|
||||||
val assetType: AssetType?,
|
val assetType: AssetType?,
|
||||||
val locations: List<LiveLocationInfo>,
|
val locations: List<LiveLocationInfo>,
|
||||||
) : EventContent {
|
) : EventContent {
|
||||||
val endsAt = timestamp + timeout
|
val endTimestamp = startTimestamp + timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
data object LegacyCallInviteContent : EventContent
|
data object LegacyCallInviteContent : EventContent
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ 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.liveLocationSharesFlow
|
import io.element.android.libraries.matrix.impl.room.location.liveLocationSharesFlow
|
||||||
|
import io.element.android.libraries.matrix.impl.room.location.timedByExpiry
|
||||||
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
|
||||||
|
|
@ -503,7 +504,7 @@ class JoinedRustRoom(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
|
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
|
||||||
return innerRoom.liveLocationSharesFlow()
|
return innerRoom.liveLocationSharesFlow().timedByExpiry(systemClock::epochMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
* 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.room.location.LiveLocationShare
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes sure to filter and emit live location based on the endTimestamp.
|
||||||
|
*/
|
||||||
|
internal fun Flow<List<LiveLocationShare>>.timedByExpiry(
|
||||||
|
currentTimeMillis: () -> Long = System::currentTimeMillis,
|
||||||
|
): Flow<List<LiveLocationShare>> = channelFlow {
|
||||||
|
var timerJob: Job? = null
|
||||||
|
|
||||||
|
fun List<LiveLocationShare>.nextExpiryAfter(timestamp: Long): Long? {
|
||||||
|
return this
|
||||||
|
.asSequence()
|
||||||
|
.map { it.endTimestamp }
|
||||||
|
.filter { it > timestamp }
|
||||||
|
.minOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun List<LiveLocationShare>.filterLive(): List<LiveLocationShare> {
|
||||||
|
val currentTimeMillis = currentTimeMillis()
|
||||||
|
return filter { it.endTimestamp > currentTimeMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reschedule(shares: List<LiveLocationShare>) {
|
||||||
|
timerJob?.cancel()
|
||||||
|
timerJob = launch {
|
||||||
|
val currentTimeMillis = currentTimeMillis()
|
||||||
|
val nextExpiry = shares.nextExpiryAfter(currentTimeMillis) ?: return@launch
|
||||||
|
delay((nextExpiry - currentTimeMillis).coerceAtLeast(0))
|
||||||
|
val liveShares = shares.filterLive()
|
||||||
|
send(liveShares)
|
||||||
|
reschedule(liveShares)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collect { shares ->
|
||||||
|
val liveShares = shares.filterLive()
|
||||||
|
send(liveShares)
|
||||||
|
reschedule(liveShares)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ class TimelineEventContentMapper(
|
||||||
is MsgLikeKind.LiveLocation -> {
|
is MsgLikeKind.LiveLocation -> {
|
||||||
LiveLocationContent(
|
LiveLocationContent(
|
||||||
isLive = kind.content.isLive,
|
isLive = kind.content.isLive,
|
||||||
timestamp = kind.content.ts.toLong(),
|
startTimestamp = kind.content.ts.toLong(),
|
||||||
description = kind.content.description,
|
description = kind.content.description,
|
||||||
timeout = kind.content.timeoutMs.toLong(),
|
timeout = kind.content.timeoutMs.toLong(),
|
||||||
assetType = kind.content.assetType.into(),
|
assetType = kind.content.assetType.into(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
/*
|
||||||
|
* 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 app.cash.turbine.test
|
||||||
|
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 kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class TimedLiveLocationSharesFlowTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it keeps emitting shares for subsequent expiries without upstream changes`() = runTest {
|
||||||
|
val shares = listOf(
|
||||||
|
aLiveLocationShare(userId = "@alice:server", endTimestamp = 1_000),
|
||||||
|
aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000),
|
||||||
|
aLiveLocationShare(userId = "@carol:server", endTimestamp = 3_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
flowOf(shares)
|
||||||
|
.timedByExpiry(currentTimeMillis = { testScheduler.currentTime })
|
||||||
|
.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(shares)
|
||||||
|
|
||||||
|
advanceTimeBy(1_000)
|
||||||
|
assertThat(awaitItem()).isEqualTo(shares.drop(1))
|
||||||
|
|
||||||
|
advanceTimeBy(999)
|
||||||
|
expectNoEvents()
|
||||||
|
|
||||||
|
advanceTimeBy(1)
|
||||||
|
assertThat(awaitItem()).isEqualTo(shares.drop(2))
|
||||||
|
|
||||||
|
advanceTimeBy(999)
|
||||||
|
expectNoEvents()
|
||||||
|
|
||||||
|
advanceTimeBy(1)
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it does not double-emit when a share is already expired on receipt`() = runTest {
|
||||||
|
val shares = listOf(
|
||||||
|
aLiveLocationShare(userId = "@alice:server", endTimestamp = 500),
|
||||||
|
aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
flowOf(shares)
|
||||||
|
.timedByExpiry(currentTimeMillis = { 1_000 + testScheduler.currentTime })
|
||||||
|
.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(shares.drop(1))
|
||||||
|
expectNoEvents()
|
||||||
|
|
||||||
|
advanceTimeBy(999)
|
||||||
|
expectNoEvents()
|
||||||
|
|
||||||
|
advanceTimeBy(1)
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it reschedules timed emission when upstream shares change`() = runTest {
|
||||||
|
val upstream = MutableSharedFlow<List<LiveLocationShare>>(extraBufferCapacity = 1)
|
||||||
|
val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000))
|
||||||
|
val updatedShares = listOf(
|
||||||
|
aLiveLocationShare(userId = "@alice:server", endTimestamp = 10_000),
|
||||||
|
aLiveLocationShare(userId = "@bob:server", endTimestamp = 6_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream
|
||||||
|
.timedByExpiry(currentTimeMillis = { testScheduler.currentTime })
|
||||||
|
.test {
|
||||||
|
upstream.emit(initialShares)
|
||||||
|
assertThat(awaitItem()).isEqualTo(initialShares)
|
||||||
|
|
||||||
|
advanceTimeBy(5_000)
|
||||||
|
upstream.emit(updatedShares)
|
||||||
|
assertThat(awaitItem()).isEqualTo(updatedShares)
|
||||||
|
|
||||||
|
advanceTimeBy(999)
|
||||||
|
expectNoEvents()
|
||||||
|
|
||||||
|
advanceTimeBy(1)
|
||||||
|
assertThat(awaitItem()).isEqualTo(updatedShares.take(1))
|
||||||
|
|
||||||
|
advanceTimeBy(3_999)
|
||||||
|
expectNoEvents()
|
||||||
|
|
||||||
|
advanceTimeBy(1)
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it completes after the last scheduled re-emission when upstream completes`() = runTest {
|
||||||
|
val shares = listOf(aLiveLocationShare(endTimestamp = 1_000))
|
||||||
|
flowOf(shares)
|
||||||
|
.timedByExpiry(currentTimeMillis = { testScheduler.currentTime })
|
||||||
|
.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(shares)
|
||||||
|
|
||||||
|
advanceTimeBy(1_000)
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `it completes immediately when upstream emits nothing`() = runTest {
|
||||||
|
emptyFlow<List<LiveLocationShare>>()
|
||||||
|
.timedByExpiry(currentTimeMillis = { testScheduler.currentTime })
|
||||||
|
.test {
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aLiveLocationShare(
|
||||||
|
userId: String = "@user:server",
|
||||||
|
endTimestamp: Long,
|
||||||
|
): LiveLocationShare {
|
||||||
|
return LiveLocationShare(
|
||||||
|
userId = UserId(userId),
|
||||||
|
lastLocation = null,
|
||||||
|
endTimestamp = endTimestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.activity.ComponentActivity
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.ui.test.SemanticsMatcher
|
import androidx.compose.ui.test.SemanticsMatcher
|
||||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.hasAnyAncestor
|
import androidx.compose.ui.test.hasAnyAncestor
|
||||||
import androidx.compose.ui.test.hasClickAction
|
import androidx.compose.ui.test.hasClickAction
|
||||||
import androidx.compose.ui.test.hasContentDescription
|
import androidx.compose.ui.test.hasContentDescription
|
||||||
|
|
@ -60,3 +61,10 @@ fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNoNodeWith
|
||||||
val text = activity.getString(res)
|
val text = activity.getString(res)
|
||||||
onNodeWithText(text).assertDoesNotExist()
|
onNodeWithText(text).assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNodeWithTextIsDisplayed(@StringRes res: Int) {
|
||||||
|
val text = activity.getString(res)
|
||||||
|
onNodeWithText(text).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue