Update live location shares when reaching timeout (before actual stop event)

This commit is contained in:
ganfra 2026-04-15 22:06:00 +02:00
parent 8cefebdc36
commit e162e667be
17 changed files with 331 additions and 64 deletions

View file

@ -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
}

View file

@ -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,

View file

@ -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,
)

View file

@ -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,
),
)
}

View file

@ -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)
),
)
}

View file

@ -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
}

View file

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

View file

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

View file

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