Update live location shares when reaching timeout (before actual stop event)
This commit is contained in:
parent
11866afb03
commit
704ddc9132
17 changed files with 331 additions and 64 deletions
|
|
@ -142,7 +142,7 @@ class ShowLocationPresenter(
|
|||
val member = members.find { it.userId == share.userId }
|
||||
val displayName = member?.getBestName() ?: share.userId.value
|
||||
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(
|
||||
CommonStrings.screen_static_location_sheet_timestamp_description,
|
||||
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.room.JoinedRoom
|
||||
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.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.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ShowLocationPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
|
@ -330,7 +332,7 @@ class ShowLocationPresenterTest {
|
|||
@Test
|
||||
fun `live mode emits empty location shares initially`() = runTest {
|
||||
val presenter = createShowLocationPresenter(
|
||||
mode = ShowLocationMode.Live,
|
||||
mode = ShowLocationMode.Live(senderId = UserId("@alice:matrix.org")),
|
||||
joinedRoom = FakeJoinedRoom(),
|
||||
)
|
||||
presenter.test {
|
||||
|
|
@ -345,22 +347,13 @@ class ShowLocationPresenterTest {
|
|||
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
|
||||
aLiveLocationShare(userId = userId)
|
||||
)
|
||||
)
|
||||
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
|
||||
|
||||
val presenter = createShowLocationPresenter(
|
||||
mode = ShowLocationMode.Live,
|
||||
mode = ShowLocationMode.Live(senderId = userId),
|
||||
joinedRoom = fakeRoom,
|
||||
)
|
||||
presenter.test {
|
||||
|
|
@ -384,28 +377,14 @@ class ShowLocationPresenterTest {
|
|||
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
|
||||
aLiveLocationShare(userId = validUserId),
|
||||
aLiveLocationShare(userId = invalidUserId, geoUri = "invalid-geo-uri"),
|
||||
)
|
||||
)
|
||||
val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
|
||||
|
||||
val presenter = createShowLocationPresenter(
|
||||
mode = ShowLocationMode.Live,
|
||||
mode = ShowLocationMode.Live(senderId = validUserId),
|
||||
joinedRoom = fakeRoom,
|
||||
)
|
||||
presenter.test {
|
||||
|
|
@ -423,14 +402,10 @@ class ShowLocationPresenterTest {
|
|||
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 fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
|
||||
|
||||
val presenter = createShowLocationPresenter(
|
||||
mode = ShowLocationMode.Live,
|
||||
mode = ShowLocationMode.Live(senderId = userId),
|
||||
joinedRoom = fakeRoom,
|
||||
)
|
||||
presenter.test {
|
||||
|
|
@ -440,12 +415,7 @@ class ShowLocationPresenterTest {
|
|||
|
||||
// Emit a new live share
|
||||
liveSharesFlow.value = listOf(
|
||||
LiveLocationShare(
|
||||
userId = userId,
|
||||
lastGeoUri = "geo:48.8584,2.2945",
|
||||
lastTimestamp = 1234567890L,
|
||||
isLive = true,
|
||||
)
|
||||
aLiveLocationShare(userId = userId)
|
||||
)
|
||||
|
||||
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.aTimelineItemImageContent
|
||||
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.TimelineProtectionState
|
||||
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 TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else 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
|
||||
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.TimelineItemVideoContent
|
||||
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.wysiwyg.link.Link
|
||||
|
|
@ -71,11 +72,13 @@ fun TimelineItemEventContentView(
|
|||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemLocationContent -> TimelineItemLocationView(
|
||||
content = content,
|
||||
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemLocationContent -> {
|
||||
TimelineItemLocationView(
|
||||
content = content.ensureActiveLiveLocation(),
|
||||
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
is TimelineItemImageContent -> TimelineItemImageView(
|
||||
content = content,
|
||||
hideMediaContent = hideMediaContent,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.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.ensureActiveLiveLocation
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
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.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun TimelineItemLocationView(
|
||||
|
|
@ -121,7 +124,12 @@ private fun LiveLocationOverlay(
|
|||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
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,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ class TimelineItemContentFactory(
|
|||
}.lastOrNull()
|
||||
|
||||
val endsAt = dateFormatter.format(
|
||||
timestamp = itemContent.endsAt,
|
||||
timestamp = itemContent.endTimestamp,
|
||||
mode = DateFormatterMode.TimeOnly
|
||||
)
|
||||
// Always create content, location can be null for "loading/waiting" state
|
||||
|
|
@ -124,6 +124,7 @@ class TimelineItemContentFactory(
|
|||
lastKnownLocation = lastKnownLocation,
|
||||
isActive = itemContent.isLive,
|
||||
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
|
||||
endTimestamp = itemContent.endTimestamp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||
aTimelineItemUnknownContent(),
|
||||
aTimelineItemTextContent().copy(isEdited = true),
|
||||
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
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.produceState
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.libraries.designsystem.components.PinVariant
|
||||
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.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
data class TimelineItemLocationContent(
|
||||
val senderId: UserId,
|
||||
|
|
@ -25,9 +28,6 @@ data class TimelineItemLocationContent(
|
|||
val assetType: AssetType? = null,
|
||||
val mode: Mode,
|
||||
) : TimelineItemEventContent {
|
||||
|
||||
val hideTimestamp = mode is Mode.Live && mode.canStop
|
||||
|
||||
val location = when (mode) {
|
||||
is Mode.Live -> mode.lastKnownLocation
|
||||
is Mode.Static -> mode.location
|
||||
|
|
@ -71,7 +71,8 @@ data class TimelineItemLocationContent(
|
|||
val lastKnownLocation: Location?,
|
||||
val isActive: Boolean,
|
||||
val endsAt: String,
|
||||
val canStop: Boolean = false
|
||||
val endTimestamp: Long,
|
||||
val canStop: Boolean = false,
|
||||
) : Mode {
|
||||
val isLoading = lastKnownLocation == null && isActive
|
||||
}
|
||||
|
|
@ -79,3 +80,41 @@ data class 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(
|
||||
isActive = true,
|
||||
endsAt = "Ends at 12:34",
|
||||
endTimestamp = 0L,
|
||||
canStop = true,
|
||||
lastKnownLocation = aLocation()
|
||||
),
|
||||
|
|
@ -30,6 +31,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
|||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = true,
|
||||
endsAt = "Ends at 12:34",
|
||||
endTimestamp = 0L,
|
||||
lastKnownLocation = aLocation()
|
||||
),
|
||||
),
|
||||
|
|
@ -37,6 +39,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
|||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = true,
|
||||
endsAt = "Ends at 12:34",
|
||||
endTimestamp = 0L,
|
||||
lastKnownLocation = null
|
||||
),
|
||||
),
|
||||
|
|
@ -44,6 +47,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
|||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = false,
|
||||
endsAt = "",
|
||||
endTimestamp = 0L,
|
||||
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.permalink.FakePermalinkParser
|
||||
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 kotlinx.coroutines.test.TestScope
|
||||
|
||||
|
|
@ -79,6 +80,8 @@ internal fun TestScope.aTimelineItemsFactory(
|
|||
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
|
||||
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
||||
sessionId = matrixClient.sessionId,
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
stringProvider = FakeStringProvider(),
|
||||
),
|
||||
matrixClient = matrixClient,
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
|
|
|
|||
|
|
@ -110,10 +110,9 @@ class TimelineItemContentMessageFactoryTest {
|
|||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemLocationContent(
|
||||
location = Location(lat = 1.0, lon = 2.0, accuracy = null),
|
||||
description = "description",
|
||||
assetType = assetType,
|
||||
mode = TimelineItemLocationContent.Mode.Static,
|
||||
mode = TimelineItemLocationContent.Mode.Static(location = Location(lat = 1.0, lon = 2.0, accuracy = null)),
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -107,12 +107,12 @@ data class FailedToParseStateContent(
|
|||
data class LiveLocationContent(
|
||||
val isLive: Boolean,
|
||||
val description: String?,
|
||||
val timestamp: Long,
|
||||
val startTimestamp: Long,
|
||||
val timeout: Long,
|
||||
val assetType: AssetType?,
|
||||
val locations: List<LiveLocationInfo>,
|
||||
) : EventContent {
|
||||
val endsAt = timestamp + timeout
|
||||
val endTimestamp = startTimestamp + timeout
|
||||
}
|
||||
|
||||
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.knock.RustKnockRequest
|
||||
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.roomdirectory.map
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
|
||||
|
|
@ -503,7 +504,7 @@ class JoinedRustRoom(
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
LiveLocationContent(
|
||||
isLive = kind.content.isLive,
|
||||
timestamp = kind.content.ts.toLong(),
|
||||
startTimestamp = kind.content.ts.toLong(),
|
||||
description = kind.content.description,
|
||||
timeout = kind.content.timeoutMs.toLong(),
|
||||
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.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
|
|
@ -60,3 +61,10 @@ fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.assertNoNodeWith
|
|||
val text = activity.getString(res)
|
||||
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