From a22c9871e3d271d8044d7545330f5a7b00d8163f Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 17 Mar 2026 12:28:44 +0100 Subject: [PATCH 01/18] Map sdk timeline item for LiveLocation --- .../event/TimelineItemLocationView.kt | 2 +- .../event/TimelineItemContentFactory.kt | 1 - .../TimelineItemContentMessageFactory.kt | 1 - .../event/TimelineItemLocationContent.kt | 1 - .../TimelineItemLocationContentProvider.kt | 4 ++-- .../api/timeline/item/event/EventContent.kt | 1 - .../item/event/TimelineEventContentMapper.kt | 21 +++++++++++++++++-- 7 files changed, 22 insertions(+), 9 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index 592b95a337..576f7bd2d5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -33,7 +33,7 @@ fun TimelineItemLocationView( lat = content.location.lat, lon = content.location.lon, zoom = 15.0, - contentDescription = content.body + contentDescription = content.description ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 2b5c0fa98a..85fa8a0dc1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -105,7 +105,6 @@ class TimelineItemContentFactory( }.lastOrNull() if (lastKnownLocation != null) { TimelineItemLocationContent( - body = itemContent.body.trimEnd(), description = itemContent.description?.trimEnd(), assetType = itemContent.assetType, senderId = sender, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 723ab6feac..a5e2f922ad 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -150,7 +150,6 @@ class TimelineItemContentMessageFactory( ) } else { TimelineItemLocationContent( - body = body, location = location, description = messageType.description, senderId = senderId, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt index aa9fb6b71e..f90dcbef03 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt @@ -19,7 +19,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName data class TimelineItemLocationContent( - val body: String, val senderId: UserId, val senderProfile: ProfileDetails, val location: Location, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt index 362e9b4cda..5c87c5c538 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -24,12 +24,11 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider { - // Live location messages are a special kind of message that we want to treat as unknown content for now - UnknownContent + LiveLocationContent( + isLive = kind.content.isLive, + description = kind.content.description, + timeout = kind.content.timeoutMs.toLong(), + assetType = kind.content.assetType.into(), + locations = kind.content.locations.map { location -> location.map() } + ) } is MsgLikeKind.Other -> UnknownContent } @@ -260,3 +269,11 @@ private fun RustEncryptedMessage.map(): UnableToDecryptContent.Data { RustEncryptedMessage.Unknown -> UnableToDecryptContent.Data.Unknown } } + +private fun BeaconInfo.map(): LiveLocationInfo { + return LiveLocationInfo( + description = description, + geoUri = geoUri, + timestamp = ts.toLong(), + ) +} From b082f59f9c00bd0b2c93c833df0fceaac2190ed6 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 24 Mar 2026 16:38:12 +0100 Subject: [PATCH 02/18] Start implementing LLS timeline item --- .../features/location/api/StaticMapView.kt | 151 ++++++++++++------ .../api/internal/StaticMapPlaceholder.kt | 20 +-- .../src/main/res/drawable-night/stale_map.png | Bin 0 -> 2663 bytes .../api/src/main/res/drawable/stale_map.png | Bin 0 -> 3310 bytes .../messages/impl/timeline/TimelineEvent.kt | 2 + .../impl/timeline/TimelinePresenter.kt | 1 + .../components/TimelineItemEventRow.kt | 55 ++++--- .../timeline/components/TimestampPosition.kt | 7 +- .../event/TimelineItemEventContentView.kt | 1 + .../event/TimelineItemLocationView.kt | 131 ++++++++++++++- .../event/TimelineItemContentFactory.kt | 26 +-- .../TimelineItemContentMessageFactory.kt | 3 +- .../event/TimelineItemEventContentProvider.kt | 2 +- .../event/TimelineItemLocationContent.kt | 31 +++- .../TimelineItemLocationContentProvider.kt | 46 ++++-- .../TimelineItemContentMessageFactoryTest.kt | 1 - 16 files changed, 356 insertions(+), 121 deletions(-) create mode 100644 features/location/api/src/main/res/drawable-night/stale_map.png create mode 100644 features/location/api/src/main/res/drawable/stale_map.png diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index 0657bae634..3ee0af35af 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -9,7 +9,9 @@ package io.element.android.features.location.api import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.Extras import coil3.compose.AsyncImagePainter @@ -38,68 +42,131 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight /** * Shows a static map image downloaded via a third party service's static maps API. + * + * Handles 4 distinct cases: + * 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching + * 2. Null location - shows blurred placeholder, no pin, no loading + * 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator + * 4. Success (location != null, loaded) - shows actual map with pin */ @Composable fun StaticMapView( - lat: Double, - lon: Double, + location: Location?, zoom: Double, pinVariant: PinVariant, contentDescription: String?, modifier: Modifier = Modifier, darkMode: Boolean = !ElementTheme.isLightTheme, ) { - // Using BoxWithConstraints to: - // 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints. - // 2) Request the static map image of the exact required size in Px to fill the AsyncImage. BoxWithConstraints( modifier = modifier, contentAlignment = Alignment.Center ) { - val context = LocalContext.current - var retryHash by remember { mutableIntStateOf(0) } - val builder = remember { StaticMapUrlBuilder() } - val painter = rememberAsyncImagePainter( - model = if (constraints.isZero) { - // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). - null - } else { - ImageRequest.Builder(context) - .data( - builder.build( - lat = lat, - lon = lon, - zoom = zoom, - darkMode = darkMode, - width = constraints.maxWidth, - height = constraints.maxHeight, - density = LocalDensity.current.density, - ) - ) - .size(width = constraints.maxWidth, height = constraints.maxHeight) - .apply { - extras.set(Extras.Key("retry_hash"), retryHash).build() - } - .build() + // Case 1: Stale location - show stale map with stale pin, no fetching + when { + pinVariant is PinVariant.StaleLocation -> { + StaleMapContent( + pinVariant = pinVariant, + contentDescription = contentDescription, + width = maxWidth, + height = maxHeight, + ) } - ) + // Case 2: Null location - show blurred placeholder, no pin, no loading + location == null -> { + StaticMapPlaceholder( + painter = painterResource(R.drawable.blurred_map), + canReload = false, + contentDescription = contentDescription, + width = maxWidth, + height = maxHeight, + onLoadMapClick = {} + ) + } + // Cases 3 & 4: Non-null location - fetch map + else -> LoadableMapContent( + location = location, + zoom = zoom, + pinVariant = pinVariant, + contentDescription = contentDescription, + darkMode = darkMode, + ) + } + } +} - val collectedState = painter.state.collectAsState() - if (collectedState.value is AsyncImagePainter.State.Success) { +@Composable +private fun BoxWithConstraintsScope.StaleMapContent( + pinVariant: PinVariant, + contentDescription: String?, + width: Dp, + height: Dp, +) { + Box(contentAlignment = Alignment.Center) { + Image( + painter = painterResource(R.drawable.stale_map), + contentDescription = contentDescription, + contentScale = ContentScale.FillBounds, + modifier = Modifier.size(width = width, height = height) + ) + LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this@StaleMapContent)) + } +} + +@Composable +private fun BoxWithConstraintsScope.LoadableMapContent( + location: Location, + zoom: Double, + pinVariant: PinVariant, + contentDescription: String?, + darkMode: Boolean, +) { + val context = LocalContext.current + var retryHash by remember { mutableIntStateOf(0) } + val builder = remember { StaticMapUrlBuilder() } + + val painter = rememberAsyncImagePainter( + model = if (constraints.isZero) { + // Avoid building a URL if any of the size constraints is zero + null + } else { + ImageRequest.Builder(context) + .data( + builder.build( + lat = location.lat, + lon = location.lon, + zoom = zoom, + darkMode = darkMode, + width = constraints.maxWidth, + height = constraints.maxHeight, + density = LocalDensity.current.density, + ) + ) + .size(width = constraints.maxWidth, height = constraints.maxHeight) + .apply { + extras.set(Extras.Key("retry_hash"), retryHash).build() + } + .build() + } + ) + + val state by painter.state.collectAsState() + when (state) { + is AsyncImagePainter.State.Success -> { Image( painter = painter, contentDescription = contentDescription, modifier = Modifier.size(width = maxWidth, height = maxHeight), // The returned image can be smaller than the requested size due to the static maps API having - // a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details. - // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. + // a max width and height of 2048 px. We apply ContentScale.Fit to handle this. contentScale = ContentScale.Fit, ) LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) - } else { + } + else -> { StaticMapPlaceholder( - showProgress = collectedState.value.isLoading(), - canReload = builder.isServiceAvailable(), + painter = painterResource(R.drawable.blurred_map), + canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error, contentDescription = contentDescription, width = maxWidth, height = maxHeight, @@ -109,17 +176,11 @@ fun StaticMapView( } } -private fun AsyncImagePainter.State.isLoading(): Boolean { - return this is AsyncImagePainter.State.Empty || - this is AsyncImagePainter.State.Loading -} - @PreviewsDayNight @Composable internal fun StaticMapViewPreview() = ElementPreview { StaticMapView( - lat = 0.0, - lon = 0.0, + location = Location(0.0, 0.0), zoom = 0.0, contentDescription = null, pinVariant = PinVariant.PinnedLocation, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt index 81b80c8dc3..735a25fef9 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -34,7 +35,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable internal fun StaticMapPlaceholder( - showProgress: Boolean, + painter: Painter, canReload: Boolean, contentDescription: String?, width: Dp, @@ -46,17 +47,15 @@ internal fun StaticMapPlaceholder( contentAlignment = Alignment.Center, modifier = modifier .size(width = width, height = height) - .then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick)) + .clickable(enabled = canReload, onClick = onLoadMapClick) ) { Image( - painter = painterResource(id = R.drawable.blurred_map), + painter = painter, contentDescription = contentDescription, contentScale = ContentScale.FillBounds, modifier = Modifier.size(width = width, height = height) ) - if (showProgress) { - CircularProgressIndicator() - } else if (canReload) { + if (canReload) { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -77,13 +76,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview { modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - listOf( - true to false, - false to true, - false to false, - ).forEach { (showProgress, canReload) -> + listOf(false, true) + .forEach { canReload -> StaticMapPlaceholder( - showProgress = showProgress, + painter = painterResource(R.drawable.blurred_map), canReload = canReload, contentDescription = null, width = 400.dp, diff --git a/features/location/api/src/main/res/drawable-night/stale_map.png b/features/location/api/src/main/res/drawable-night/stale_map.png new file mode 100644 index 0000000000000000000000000000000000000000..9e367592037fb72087f0c58d5bd89b01d5ed04af GIT binary patch literal 2663 zcmXX|d0Z3M77mRiDuyULg+_FI;(`JSHdRD2EMf++P(TD)c7qtL$RY|&89>nhB^Z_} zQkEEoN&wm1P>{tRVo>{p=&-neL_i@y7G)Gigm=gH{+PMv+ji#jwKgwxN*Xfz#t>eHhAm2U_pwT^ip4z$WgMk7F( zchKI&o<^&Y=*$Fv3v}J+T|Uu!!$YHEf};-5c!vsB$w=L4u$yB+O4W3}G)>io1E zSuRqw-gzCQyDqb=%%gK|ZeZ2cyGBR!o*k^!@4DCgtgoOTrtfoBkq0WZ!gol*jjCC* zGa6~m>Y~Y5zeUL{uez%Ozoq4A?+S^Di5Ze^4s84vgTeTg_InXa5oBQ!KRffYUtpj? zdwcs(BCUEY88I{4JjYCVF!15Sd6Fg`3A1{@az$zD2x}uPLaQh1Zp3^rWd0K|4`*aF z4vKclS*yZ!>mqh2?U1w3syG08IFv=w^fG25=CLzA#RdEm`n{SyoSUm!oPH)=afYOk z2M$}E5~9OlYI@4UK?-h4F$#GJdQVcGYCgyCc_n=Uvw(mAIV%WBx*y`P za`l(Vb-3c=_{)cuGB`0*+uAI!Dc+oixvQ?jgRfs-iWLfr;6&{Q3M-DG@DOjDn4FjZ zJ~3zFBRmrZ>`*tzPCtCs>kJ8)v>%z#_^6Xe8vIVx9_a?*jOzO(wlp3vYOcFyyWJVR zUBUV?xS?O^qF}sjm{~(W5wl~S5{WO=uXx?O(t&J5%{)@ zC4&PrNeZ~XwJ#vFWFJONy89N58K$u3f0p6j2LzUhG2vg<yx{)3KNkM9*pY(db;azWTs|6-u+$xR@$Aj(v1Aa7j+Nftx`aJ?amA+bC<^j( z6WWnsYvMCP(> z>rHBv6Vq`eIAZO$2yw+gJ?gKON~QJjS3=ZS;8PZI6q9wIP&T|(se(V9Z*ZN02LQ-F9rrY0~XCKR>@e#}%5ikoGt4_ixQ}hgP(OS{bJAQyq7iiWjxW9+DkC z!_g!KetN_*u|9 z+>-rS25-a_WB(M`@GveY%No|W3ilCTR_XT&@s4IxV?0jf)u zZK{|Tr`p#!b=n?NwxM6kobU3uBvUz@19yYF+%F)YE`D@icD9jf7R2Ss`YfeW5*)K) z89H0WN(*dhoFabZluEpJ7drEdxiWZNcB)1JdqToo=k(M!q|;c)7WT+OV#&(S6Td)F z$Byl_;c{pPAuEIjwQn3idlbgt4 z;@TGWZ!&lWN5a>iSGMUBDhw8Gzb@v@$|bc#=0lyrJyTVul@nM1gdoV6W?*u%CaxjR z$dTJ17X3tqM+=3&`1$+as$wynbcrQzuO8&Y=`;oJL2q{@4=LcH{QW$-7LlpoLj?)< z#M)id9Uxm$;{_EIhVtl;3@-b=DBSmQCvs4_7CX64&dMW{_e(=L)#mCJFZ9}Mt|hx? zDG^)(|H1|R{mc$64umSutw3}{i~XUB{3ssXe*q!WlEIHlB@MQ8pz3V?&&5eA7`_DS z$t4Z0JFt@%qMzsI=l@AxjUx#}&3ohkrz6=7688T|-rNvaDU*;rBl!31;pK#T!OpLD zZT($fuU9WPmr*KV@Z@XMdH3s&vIkr`4j)At>-SWKq)Lg`+K%EYBOl21-g zaksFmMdWZJ4Eq5d=iQ;+PDMY)Y7;cq2~mNgTyjFqAJ}NYZc9I!fIfFXJqcM=g7iJN zXX?>+%)t~gwn8!zUFVHnzD&ooSaQi14c{#R5o_PR2zzPxurz!hG~{f-o^b9?{r}yW zR-xuM3!3XE=Go)Od(vuppuH*3ka}FFn z_A`1RkO>0SUn7w~pDnmXV(mId^mZT=nw+GxGs6`k(qz$o&SKvNcq)q7r)hZsdS^Jh zvR*0h3`nwe4A8XTV8-d>J%{dBAzLDSp->Unc-W>pIaL+J$=Fy$WX=GC>SkYb4TWwt z=?UG}Wnfz6HaL>iat8$Dg~s?6Wt;7%F)pP9%m@@KCDen*#5Nc6ZbXkVOml;Ba&lT@ zZThCyY+p7712mh*1Z<@H@a4_xK2QUN+TNvUdAxl%%!=|{ZEW))SoJ(708pZNb!rAi zN6kK6U4nwee$-2CtOmrI-ws+hRY+ow7WUeHe|V(5stpHTHyB`*67%kq#)I zf(>@t)V9aTzF+H4RAj3esct@a2hw)$a&@e9 H2s-s&mXafS literal 0 HcmV?d00001 diff --git a/features/location/api/src/main/res/drawable/stale_map.png b/features/location/api/src/main/res/drawable/stale_map.png new file mode 100644 index 0000000000000000000000000000000000000000..87fa0188c9f884944a463a24de5de4306d72c1f2 GIT binary patch literal 3310 zcmY*ceLPg__8&*36V0Sl(y(>uCTBF1T%ui~nO>OR-REuCU_mM=u zQ$GM>$r<~j_9Rj{syXMk6xig*{T`7np{FCG{K5lBjKDMJ{2r8Na7ZMrz=MwVt~|BT zeoc2hrY?WHXQ?=Ewc{n@qr`mu@kdhOenB%Y@yhS5NvU^D44>KV_%x|{9(b$E>gTy= zub$0M>uT=_+l{d=bL{>;vRiG2Dar3pi$1vk!?O)iZO_Ms=LZFr+%jg78rcN5?^`Gy z|1gLflP?Mxi$vr)@myC+s?84FDv9lR%RD&#rt2>1bleh5eR+z8BuxOvb9pWi==z;ro-6^) zh18-Q99A6^v-fJ22vx(?(l;d~`~P$3y1pieVCpm}e}>}+_eX8m22AhBKTUr}M0pID z40)3><}n&E@4ZE?@mbA(+s*|K$QD3*|0GGk1M7?c&QHx|QaJwWW(ne*yYm9;Ykb5$Qx8ysBtl@}G3BF$3Lg zqErsVBy4~sp1J=w=YqBT7kvrr-6x*ep1bo9@Jo2*I#FZ#^-9{BinvJAsTD&Z$dgD! z%VsJe-jz)i1tMP}k%yO55-;EWoJoIE$HBFKXlJzeJU@35(x`zio@yyYpTDbvE4zv* zyt0WwZV&Owwq4aCq6~4l58}2Y5%GL+%&5mtTMh~-lM?X(9bC;P+KsvmOC;j!#YjcW zU%>271`YH#>-fol!P~ore>Ftty{btWt{U2>U?vi8Q#{kRRRe8pQJW@C)$E6bKFU)( z%(B|~gjFHzob(oq)tK@1QZ#o^HFUVF^tD+kl1H^)54PkzMmeV-iLARWex38>ykyC6 zm-K7dk1|}Qz{~qX!au5Cxn5}PH4`J)@CTA3kV{= z+$DV-S`m%DQ9s=#8Z@<}kTVpGJhsx4Yk2!wzuAjq zzRH6?ua;P-1GyVqw4^ECk0^l@v0S52H`OMKsIF|AwPxMOZoP*RBuP0bg7r3Z{+ujI z7jv+>%5fTzP0cFwn-YV^0uXL{&Jryt)qo*^H{atcinrjhz~RkS%BkxcBo;=DA!Nkr zWU=Br68YuVpGHE~ZTY{2nt{0FG?~`)- zIF>C;ETGx5Ge2cfv2&c2dizVDOc+Y@$u=qqLU}`;aKBv zYuy{Gj(qgZoIDj{8;u+hQbMn7Br}X?m2glETz8?<6j!cfNq#+hImmy{h5pfu*Q0C* zUbTZdp2Ar0@2PNoyE%JNF_phJ^B3 zh~>o`A)~f%pe(Jno#0xx*B=AN%y1I1V_UP#oZfcxTgKPgY%>aIi;3qGi8g-L8mqBl zI4s)k>A3-H#?ZLWPu|c@%mI~V5++9?C7VJlsU5`3v~S+nmw_|Lv&?F|ysl;+qGMd5 zylJP0hGutHZOHK!4&BxS;~n@9xBA}7KkA*K7i(Vv;f`fh?Hq`X7NRv`!aP?qF#CO5 zvM7iR9lkRcVZQug(uNyVS+=d%A&1eiSauJOHepK{xu8#$RyKZl8Jz3LZ(8a@O=+HO zjiYx|b=2=!hD|wLKw@XxSub~DLxM}&$PCU!IbTHCZ74aSkj4`puD>+>dX{#Tc!gU$ z=q}HL*RB6&n7Q5ydmuyd3K8hUa6T+V_b+v$rZB?56T~t2RYya>q5%amnX5o-`j01} zTwBj6#ZdV>!n&$WTmV0qr~`o$!??xqSw6S~Mx1!aM`x()a}97~S-o|#1Eb~W^@YR! zNamK*fEqZ}dTP9(chJ{z&UfJ>XWpXQ)=t|KI!#-%d)+{h6lTczcR{9^=UvR*ZnYsV z7ET5snVWXo*|f!(J%PnPfT{ASguhe;PRG6HK#FjcB=+eXE-n@(AqPXpj6` zLC#r)LM6C4;#5xJWEuIYwmbLM8r4xDai9>MIMTzv#BY|yrL1~lKRQ# zr^P@?*v4g~pHd310IAg5hVgv)n|yd`6S&F7Frs^T8N9*_eDNtCZhVY3f_fWhLeyL( zxCu!7vK%wYAv|J0BRzC*Z6}0Q0dmtcab*-jTP%Zi+Y@TfogR0F40fv`w*qbR4vdh~ zF@AOb{mMgZOu{E-xFFgj`)Nle+P07;LI?gas1g*YI3DBWC}pjNAmM+Gssu$UqqSbp zNCEQFhF-XD!q4n$Ti&|g&gQM?Ago{E<0^ItlKHRHb4YNhI~&pjQEE{4e_uc)4~fiY zprV<^HwRZ^lDV1Jz1u3_5mP{|?q?X>URglQ_%kK>@-hF z@K``{|Np*`N}Eu`4zygI?Y#I);<1Wjt=Bu-+8Sy*|Ghx%Ac+2U-VC(02CnRV`7{;j z1|=D0MW+P|jv=jH&{l!kht~aJY>Xo!4vaAbAm*JA?ZJ$m*6BOxu6(<7^4hbCr=nfC zR;Kl@kGr+*2%(pS$Un5bSl?FHDG6KuIS6P@#>f94mh%9^^hAPp0!knQTH!3{n98U@ z>1*k%?*iT1_+wB}?baIjJ8&+lcm4}JXeHB63-(Z4(s1op;28P*!lVP2dAaDHf;Nd8 z=e9514!F*}>tZ@6n16o=f2Rw=7H%Jh9)N>5fqS5Yg7Se{b}Z^YFO0R-^o$5&-c=Q0GR}I?EnA( literal 0 HcmV?d00001 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 1591cbf6cc..e9a6ce5549 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -57,4 +57,6 @@ sealed interface TimelineEvent { data class EditPoll( val pollStartId: EventId, ) : TimelineItemPollEvent + + data object StopLiveLocationShare : TimelineItemEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 12e4e0b1d1..b83adca5ec 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -197,6 +197,7 @@ class TimelinePresenter( is TimelineEvent.EditPoll -> { navigator.navigateToEditPoll(event.pollStartId) } + is TimelineEvent.StopLiveLocationShare -> Unit is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch { focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce) delay(event.debounce) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index b253f45937..421c52c9ef 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -269,7 +269,9 @@ fun TimelineItemEventRow( if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) { ThreadSummaryView( modifier = if (event.isMine) { - Modifier.align(Alignment.End).padding(end = 16.dp) + Modifier + .align(Alignment.End) + .padding(end = 16.dp) } else { if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp) }.padding(top = 2.dp), @@ -674,6 +676,7 @@ private fun MessageEventBubbleContent( .padding(horizontal = 8.dp, vertical = 4.dp) ) } + TimestampPosition.Hidden -> Box(modifier) { content {} } } } @@ -763,11 +766,11 @@ private fun MessageEventBubbleContent( } } - val timestampPosition = when (event.content) { - is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay - is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay - is TimelineItemStickerContent, - is TimelineItemLocationContent -> TimestampPosition.Overlay + val timestampPosition = when (val content = event.content) { + 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 TimelineItemPollContent -> TimestampPosition.Below else -> TimestampPosition.Default } @@ -833,25 +836,27 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview { groupPosition = TimelineItemGroupPosition.First, threadInfo = TimelineItemThreadInfo.ThreadRoot( latestEventText = "This is the latest message in the thread", - summary = ThreadSummary(AsyncData.Success( - EmbeddedEventInfo( - eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")), - content = MessageContent( - body = "This is the latest message in the thread", - inReplyTo = null, - isEdited = false, - threadInfo = null, - type = TextMessageType("This is the latest message in the thread", null) - ), - senderId = UserId("@user:id"), - senderProfile = ProfileDetails.Ready( - displayName = "Alice", - avatarUrl = null, - displayNameAmbiguous = false, - ), - timestamp = 0L, - ) - ), numberOfReplies = 20L) + summary = ThreadSummary( + AsyncData.Success( + EmbeddedEventInfo( + eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")), + content = MessageContent( + body = "This is the latest message in the thread", + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = TextMessageType("This is the latest message in the thread", null) + ), + senderId = UserId("@user:id"), + senderProfile = ProfileDetails.Ready( + displayName = "Alice", + avatarUrl = null, + displayNameAmbiguous = false, + ), + timestamp = 0L, + ) + ), numberOfReplies = 20L + ) ) ), displayThreadSummaries = true, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt index 605db65da3..505edeef15 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt @@ -22,7 +22,12 @@ enum class TimestampPosition { /** * Timestamp should always be rendered below the timeline event content (eg. poll). */ - Below; + Below, + + /** + * Timestamp should be hidden. + */ + Hidden; companion object { /** diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 4fc243864c..1de73f3658 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -73,6 +73,7 @@ fun TimelineItemEventContentView( ) is TimelineItemLocationContent -> TimelineItemLocationView( content = content, + onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) }, modifier = modifier ) is TimelineItemImageContent -> TimelineItemImageView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index 576f7bd2d5..c9e3152afb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -8,33 +8,147 @@ package io.element.android.features.messages.impl.timeline.components.event +import androidx.compose.foundation.background +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.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color 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.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.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 @Composable fun TimelineItemLocationView( content: TimelineItemLocationContent, + onStopLiveLocationClick: () -> Unit, modifier: Modifier = Modifier, ) { - StaticMapView( + Box(modifier = modifier.fillMaxWidth()) { + StaticMapView( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 188.dp), + pinVariant = content.pinVariant, + location = content.location, + zoom = 15.0, + contentDescription = content.description + ) + + if (content.mode is TimelineItemLocationContent.Mode.Live) { + LiveLocationOverlay( + mode = content.mode, + onStopClick = onStopLiveLocationClick, + modifier = Modifier.align(Alignment.BottomStart) + ) + } + } +} + +@Composable +private fun LiveLocationOverlay( + mode: TimelineItemLocationContent.Mode.Live, + onStopClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( modifier = modifier .fillMaxWidth() - .heightIn(max = 188.dp), - pinVariant = content.pinVariant, - lat = content.location.lat, - lon = content.location.lon, - zoom = 15.0, - contentDescription = content.description - ) + .background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f)) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val iconShape = RoundedCornerShape(8.dp) + Box( + modifier = Modifier + .size(32.dp) + .border( + width = 1.dp, + color = if (mode.isActive) ElementTheme.colors.iconQuaternaryAlpha else Color.Transparent, + shape = iconShape, + ) + .background( + color = if (mode.isActive) { + ElementTheme.colors.bgCanvasDefault + } else { + ElementTheme.colors.bgSubtleSecondary + }, + shape = iconShape + ) + ) { + if (mode.isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = ElementTheme.colors.iconSecondary, + modifier = Modifier + .align(Alignment.Center) + .size(20.dp) + ) + } else { + Icon( + imageVector = CompoundIcons.LocationPinSolid(), + contentDescription = null, + tint = if (mode.isActive) { + ElementTheme.colors.iconAccentPrimary + } else { + ElementTheme.colors.iconDisabled + }, + modifier = Modifier.align(Alignment.Center) + ) + } + } + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (mode.isActive) "Live location" else "Live location ended", + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textPrimary, + ) + if (mode.isActive) { + Text( + text = mode.endsAt, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textPrimary, + ) + } + } + + if (mode.isActive && mode.canStop) { + IconButton( + onClick = onStopClick, + colors = IconButtonDefaults.iconButtonColors( + containerColor = ElementTheme.colors.bgCriticalPrimary, + contentColor = ElementTheme.colors.iconOnSolidPrimary, + ) + ) { + Icon( + imageVector = CompoundIcons.Stop(), + contentDescription = null, + ) + } + } + } } @PreviewsDayNight @@ -43,5 +157,6 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat ElementPreview { TimelineItemLocationView( content = content, + onStopLiveLocationClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 85fa8a0dc1..a9457edfa4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -15,6 +15,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId @@ -103,18 +105,18 @@ class TimelineItemContentFactory( val lastKnownLocation = itemContent.locations.mapNotNull { beacon -> Location.fromGeoUri(beacon.geoUri) }.lastOrNull() - if (lastKnownLocation != null) { - TimelineItemLocationContent( - description = itemContent.description?.trimEnd(), - assetType = itemContent.assetType, - senderId = sender, - senderProfile = senderProfile, - location = lastKnownLocation, - mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive) - ) - } else { - TimelineItemUnknownContent - } + // Always create content - location can be null for "loading/waiting" state + TimelineItemLocationContent( + description = itemContent.description?.trimEnd(), + assetType = itemContent.assetType, + senderId = sender, + senderProfile = senderProfile, + mode = TimelineItemLocationContent.Mode.Live( + lastKnownLocation = lastKnownLocation, + isActive = itemContent.isLive, + endsAt = "", + ), + ) } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index a5e2f922ad..e2e5d0c03e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -150,12 +150,11 @@ class TimelineItemContentMessageFactory( ) } else { TimelineItemLocationContent( - location = location, description = messageType.description, senderId = senderId, senderProfile = senderProfile, assetType = messageType.assetType, - mode = TimelineItemLocationContent.Mode.Static + mode = TimelineItemLocationContent.Mode.Static(location = location) ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index f3d70f44e7..9683a2c149 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -35,7 +35,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider mode.lastKnownLocation + is Mode.Static -> mode.location + } + + /** + * The pin variant to display on the map. + * Returns a default variant when location is null (map will show loading placeholder anyway). + */ + val pinVariant: PinVariant = when (mode) { is Mode.Live -> { if (mode.isActive) { PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true) @@ -34,7 +45,7 @@ data class TimelineItemLocationContent( PinVariant.StaleLocation } } - Mode.Static -> { + is Mode.Static -> { when (assetType) { AssetType.PIN -> PinVariant.PinnedLocation AssetType.SENDER, @@ -52,8 +63,18 @@ data class TimelineItemLocationContent( ) sealed interface Mode { - data object Static : Mode - data class Live(val isActive: Boolean) : Mode + data class Static( + val location: Location, + ) : Mode + + data class Live( + val lastKnownLocation: Location?, + val isActive: Boolean, + val endsAt: String, + val canStop: Boolean = false + ) : Mode { + val isLoading = lastKnownLocation == null && isActive + } } override val type: String = "TimelineItemLocationContent" diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt index 5c87c5c538..e2309c2210 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -18,8 +18,35 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider get() = sequenceOf( aTimelineItemLocationContent(), - aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)), - aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)), + aTimelineItemLocationContent( + mode = TimelineItemLocationContent.Mode.Live( + isActive = true, + endsAt = "Ends at 12:34", + canStop = true, + lastKnownLocation = aLocation() + ), + ), + aTimelineItemLocationContent( + mode = TimelineItemLocationContent.Mode.Live( + isActive = true, + endsAt = "Ends at 12:34", + lastKnownLocation = aLocation() + ), + ), + aTimelineItemLocationContent( + mode = TimelineItemLocationContent.Mode.Live( + isActive = true, + endsAt = "Ends at 12:34", + lastKnownLocation = null + ), + ), + aTimelineItemLocationContent( + mode = TimelineItemLocationContent.Mode.Live( + isActive = false, + endsAt = "", + lastKnownLocation = aLocation() + ), + ), ) } @@ -27,15 +54,16 @@ fun aTimelineItemLocationContent( senderId: UserId = UserId("@sender:matrix.org"), senderProfile: ProfileDetails = aProfileDetailsReady(), description: String? = null, - mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static, + mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()), ) = TimelineItemLocationContent( - location = Location( - lat = 52.2445, - lon = 0.7186, - accuracy = 5000f, - ), senderId = senderId, senderProfile = senderProfile, description = description, - mode = mode + mode = mode, +) + +fun aLocation() = Location( + lat = 52.2445, + lon = 0.7186, + accuracy = 5000f, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 957b01d1ed..969e27e8a4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -110,7 +110,6 @@ class TimelineItemContentMessageFactoryTest { eventId = AN_EVENT_ID, ) val expected = TimelineItemLocationContent( - body = "body", location = Location(lat = 1.0, lon = 2.0, accuracy = null), description = "description", assetType = assetType, From a7e254cc8414c43920c178f9ee57d29b9ca71798 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 25 Mar 2026 19:57:34 +0100 Subject: [PATCH 03/18] Live location : format the endsAt timeline item content --- .../factories/event/TimelineItemContentFactory.kt | 13 +++++++++++-- .../matrix/api/timeline/item/event/EventContent.kt | 5 ++++- .../item/event/TimelineEventContentMapper.kt | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index a9457edfa4..8b81ae0906 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -38,6 +38,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider @Inject class TimelineItemContentFactory( @@ -52,6 +54,8 @@ class TimelineItemContentFactory( private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory, private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory, private val sessionId: SessionId, + private val dateFormatter: DateFormatter, + private val stringProvider: StringProvider, ) { suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { return create( @@ -105,7 +109,12 @@ class TimelineItemContentFactory( val lastKnownLocation = itemContent.locations.mapNotNull { beacon -> Location.fromGeoUri(beacon.geoUri) }.lastOrNull() - // Always create content - location can be null for "loading/waiting" state + + val endsAt = dateFormatter.format( + timestamp = itemContent.endsAt, + mode = DateFormatterMode.TimeOnly + ) + // Always create content, location can be null for "loading/waiting" state TimelineItemLocationContent( description = itemContent.description?.trimEnd(), assetType = itemContent.assetType, @@ -114,7 +123,7 @@ class TimelineItemContentFactory( mode = TimelineItemLocationContent.Mode.Live( lastKnownLocation = lastKnownLocation, isActive = itemContent.isLive, - endsAt = "", + endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt), ), ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index 35c37fc4a9..931287cfe7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -107,10 +107,13 @@ data class FailedToParseStateContent( data class LiveLocationContent( val isLive: Boolean, val description: String?, + val timestamp: Long, val timeout: Long, val assetType: AssetType?, val locations: List, -) : EventContent +) : EventContent { + val endsAt = timestamp + timeout +} data object LegacyCallInviteContent : EventContent diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 24a4c97fbf..11d107cd8b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -114,6 +114,7 @@ class TimelineEventContentMapper( is MsgLikeKind.LiveLocation -> { LiveLocationContent( isLive = kind.content.isLive, + timestamp = kind.content.ts.toLong(), description = kind.content.description, timeout = kind.content.timeoutMs.toLong(), assetType = kind.content.assetType.into(), From 4e0165458a3c15b90ddc45786c5337490f08b1f9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 3 Apr 2026 18:21:37 +0200 Subject: [PATCH 04/18] Live location : start collecting live location --- .../impl/show/ShowLocationPresenter.kt | 46 ++++- .../show/DefaultShowLocationEntryPointTest.kt | 4 +- .../impl/show/ShowLocationPresenterTest.kt | 177 +++++++++++++++++- .../messages/impl/MessagesFlowNode.kt | 23 +-- .../api/room/location/LiveLocationShare.kt | 2 - .../matrix/impl/room/JoinedRustRoom.kt | 11 +- .../room/location/LiveLocationShareMapper.kt | 21 --- .../room/location/LiveLocationSharesFlow.kt | 60 ++++++ 8 files changed, 295 insertions(+), 49 deletions(-) delete mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index a2c9a3702d..b72c01e697 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -13,11 +13,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults @@ -29,14 +31,21 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.getBestName +import io.element.android.libraries.matrix.api.room.joinedRoomMembers +import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.combine @AssistedInject class ShowLocationPresenter( @@ -46,6 +55,7 @@ class ShowLocationPresenter( private val buildMeta: BuildMeta, private val dateFormatter: DateFormatter, private val stringProvider: StringProvider, + private val joinedRoom: JoinedRoom, ) : Presenter { @AssistedFactory fun interface Factory { @@ -96,9 +106,9 @@ class ShowLocationPresenter( } } - val locationShares = remember { - when (mode) { - is ShowLocationMode.Static -> { + val locationShares = when (mode) { + is ShowLocationMode.Static -> { + remember { val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true) val formattedTimestamp = stringProvider.getString( CommonStrings.screen_static_location_sheet_timestamp_description, @@ -121,7 +131,35 @@ class ShowLocationPresenter( ) ) } - ShowLocationMode.Live -> persistentListOf() + } + ShowLocationMode.Live -> { + val liveShares by produceState(persistentListOf()) { + val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares() + val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() } + combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members -> + liveShares.mapNotNull { share -> + val location = Location.fromGeoUri(share.lastGeoUri) ?: return@mapNotNull null + val member = members.find { it.userId == share.userId } + val displayName = member?.getBestName() ?: share.userId.value + val avatarUrl = member?.avatarUrl + LocationShareItem( + userId = share.userId, + displayName = displayName, + avatarData = AvatarData( + id = share.userId.value, + name = displayName, + url = avatarUrl, + size = AvatarSize.UserListItem, + ), + formattedTimestamp = "Sharing live location", + location = location, + isLive = true, + assetType = AssetType.SENDER, + ) + }.toPersistentList() + }.collect { value = it } + } + liveShares } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index 451531fc7e..91df447e2a 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -19,6 +19,7 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.node.TestParentNode @@ -43,7 +44,8 @@ class DefaultShowLocationEntryPointTest { locationActions = FakeLocationActions(), buildMeta = aBuildMeta(), dateFormatter = FakeDateFormatter(), - stringProvider = FakeStringProvider() + stringProvider = FakeStringProvider(), + joinedRoom = FakeJoinedRoom(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 931dd55cea..81ec465686 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -22,11 +22,17 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.FakeLiveLocationShareService import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -51,13 +57,15 @@ class ShowLocationPresenterTest { assetType = null, ), locationActions: FakeLocationActions = fakeLocationActions, + joinedRoom: JoinedRoom = FakeJoinedRoom(), ) = ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { fakePermissionsPresenter }, locationActions = locationActions, buildMeta = fakeBuildMeta, dateFormatter = fakeDateFormatter, - stringProvider = FakeStringProvider() + stringProvider = FakeStringProvider(), + joinedRoom = joinedRoom, ) @Test @@ -318,4 +326,171 @@ class ShowLocationPresenterTest { assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1) } } + + @Test + fun `live mode emits empty location shares initially`() = runTest { + val presenter = createShowLocationPresenter( + mode = ShowLocationMode.Live, + joinedRoom = FakeJoinedRoom(), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.locationShares).isEmpty() + assertThat(initialState.isSheetDraggable).isFalse() + } + } + + @Test + fun `live mode collects live shares from room`() = runTest { + val userId = UserId("@bob:matrix.org") + val liveSharesFlow = MutableStateFlow( + listOf( + LiveLocationShare( + userId = userId, + lastGeoUri = "geo:48.8584,2.2945", + lastTimestamp = 1234567890L, + isLive = true, + ) + ) + ) + val fakeRoom = FakeJoinedRoom( + liveLocationShareService = FakeLiveLocationShareService( + liveLocationSharesFlow = liveSharesFlow + ) + ) + + val presenter = createShowLocationPresenter( + mode = ShowLocationMode.Live, + joinedRoom = fakeRoom, + ) + presenter.test { + // Skip initial empty state from collectAsState(initial = emptyList()) + skipItems(1) + val state = awaitItem() + + assertThat(state.locationShares).hasSize(1) + val item = state.locationShares.first() + assertThat(item.userId).isEqualTo(userId) + assertThat(item.location.lat).isEqualTo(48.8584) + assertThat(item.location.lon).isEqualTo(2.2945) + assertThat(item.isLive).isTrue() + assertThat(state.isSheetDraggable).isTrue() + } + } + + @Test + fun `live mode handles invalid geo uri gracefully`() = runTest { + val validUserId = UserId("@alice:matrix.org") + val invalidUserId = UserId("@bob:matrix.org") + val liveSharesFlow = MutableStateFlow( + listOf( + LiveLocationShare( + userId = validUserId, + lastGeoUri = "geo:48.8584,2.2945", + lastTimestamp = 1234567890L, + isLive = true, + ), + LiveLocationShare( + userId = invalidUserId, + lastGeoUri = "invalid-geo-uri", + lastTimestamp = 1234567890L, + isLive = true, + ), + ) + ) + val fakeRoom = FakeJoinedRoom( + liveLocationShareService = FakeLiveLocationShareService( + liveLocationSharesFlow = liveSharesFlow + ) + ) + + val presenter = createShowLocationPresenter( + mode = ShowLocationMode.Live, + joinedRoom = fakeRoom, + ) + presenter.test { + // Skip initial empty state from collectAsState(initial = emptyList()) + skipItems(1) + val state = awaitItem() + + // Only the valid location share should be present + assertThat(state.locationShares).hasSize(1) + assertThat(state.locationShares.first().userId).isEqualTo(validUserId) + } + } + + @Test + fun `live mode updates when shares change`() = runTest { + val userId = UserId("@bob:matrix.org") + val liveSharesFlow = MutableStateFlow(emptyList()) + val fakeRoom = FakeJoinedRoom( + liveLocationShareService = FakeLiveLocationShareService( + liveLocationSharesFlow = liveSharesFlow + ) + ) + + val presenter = createShowLocationPresenter( + mode = ShowLocationMode.Live, + joinedRoom = fakeRoom, + ) + presenter.test { + // Initial state is empty + val initialState = awaitItem() + assertThat(initialState.locationShares).isEmpty() + + // Emit a new live share + liveSharesFlow.value = listOf( + LiveLocationShare( + userId = userId, + lastGeoUri = "geo:48.8584,2.2945", + lastTimestamp = 1234567890L, + isLive = true, + ) + ) + + val updatedState = awaitItem() + assertThat(updatedState.locationShares).hasSize(1) + assertThat(updatedState.locationShares.first().userId).isEqualTo(userId) + } + } + + @Test + fun `static mode emits location share with correct data`() = runTest { + val senderId = UserId("@alice:matrix.org") + val senderName = "Alice" + val avatarUrl = "https://example.com/avatar.png" + val mode = ShowLocationMode.Static( + location = location, + senderName = senderName, + senderId = senderId, + senderAvatarUrl = avatarUrl, + timestamp = 1234567890L, + assetType = AssetType.SENDER, + ) + + val presenter = createShowLocationPresenter(mode = mode) + presenter.test { + val state = awaitItem() + assertThat(state.locationShares).hasSize(1) + + val item = state.locationShares.first() + assertThat(item.userId).isEqualTo(senderId) + assertThat(item.displayName).isEqualTo(senderName) + assertThat(item.location).isEqualTo(location) + assertThat(item.isLive).isFalse() + assertThat(item.assetType).isEqualTo(AssetType.SENDER) + assertThat(item.avatarData.id).isEqualTo(senderId.value) + assertThat(item.avatarData.name).isEqualTo(senderName) + assertThat(item.avatarData.url).isEqualTo(avatarUrl) + } + } + + @Test + fun `static mode has non-draggable sheet`() = runTest { + val presenter = createShowLocationPresenter() + presenter.test { + val state = awaitItem() + assertThat(state.isSheetDraggable).isFalse() + } + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 38d0504258..2d6a0f8c68 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -558,17 +558,18 @@ class MessagesFlowNode( ) } is TimelineItemLocationContent -> { - val mode = ShowLocationMode.Static( - location = event.content.location, - senderName = event.safeSenderName, - senderId = event.senderId, - senderAvatarUrl = event.senderAvatar.url, - timestamp = event.sentTimeMillis, - assetType = event.content.assetType, - ) - NavTarget.LocationViewer( - mode = mode - ).takeIf { locationService.isServiceAvailable() } + val mode = when(event.content.mode){ + is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live + is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static( + location = event.content.mode.location, + senderName = event.safeSenderName, + senderId = event.senderId, + senderAvatarUrl = event.senderAvatar.url, + timestamp = event.sentTimeMillis, + assetType = event.content.assetType, + ) + } + NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() } } else -> null } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt index 7e841639bd..5f9cd41462 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt @@ -19,6 +19,4 @@ data class LiveLocationShare( val lastGeoUri: String, /** The timestamp of the last location update. */ val lastTimestamp: Long, - /** Whether the live location share is still active. */ - val isLive: Boolean, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 644c5aefc2..0c41824dde 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -43,7 +43,7 @@ import io.element.android.libraries.matrix.impl.mapper.map 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.map +import io.element.android.libraries.matrix.impl.room.location.liveLocationSharesFlow 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 @@ -68,7 +68,6 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.KnockRequestsListener -import org.matrix.rustcomponents.sdk.LiveLocationShareListener import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate import org.matrix.rustcomponents.sdk.SendQueueListener @@ -504,13 +503,7 @@ class JoinedRustRoom( } override fun subscribeToLiveLocationShares(): Flow> { - return mxCallbackFlow { - innerRoom.subscribeToLiveLocationShares(object : LiveLocationShareListener { - override fun call(liveLocationShares: List) { - trySend(liveLocationShares.map { it.map() }) - } - }) - } + return innerRoom.liveLocationSharesFlow() } override suspend fun startLiveLocationShare(durationMillis: Long): Result = withContext(roomDispatcher) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt deleted file mode 100644 index 3b80c1c61f..0000000000 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) 2025 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.core.UserId -import io.element.android.libraries.matrix.api.room.location.LiveLocationShare -import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare - -fun RustLiveLocationShare.map(): LiveLocationShare { - return LiveLocationShare( - userId = UserId(userId), - lastGeoUri = lastLocation.location.geoUri, - lastTimestamp = lastLocation.ts.toLong(), - isLive = isLive, - ) -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt new file mode 100644 index 0000000000..7b3a29cf4a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt @@ -0,0 +1,60 @@ +/* + * 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.core.UserId +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare +import org.matrix.rustcomponents.sdk.LiveLocationShareListener +import org.matrix.rustcomponents.sdk.LiveLocationShareUpdate +import org.matrix.rustcomponents.sdk.RoomInterface + +fun RoomInterface.liveLocationSharesFlow(): Flow> { + fun MutableList.applyUpdate(update: LiveLocationShareUpdate) { + when (update) { + is LiveLocationShareUpdate.Append -> addAll(update.values.map { it.into() }) + is LiveLocationShareUpdate.Clear -> clear() + is LiveLocationShareUpdate.Insert -> add(update.index.toInt(), update.value.into()) + is LiveLocationShareUpdate.PopBack -> if (isNotEmpty()) removeAt(lastIndex) + is LiveLocationShareUpdate.PopFront -> if (isNotEmpty()) removeAt(0) + is LiveLocationShareUpdate.PushBack -> add(update.value.into()) + is LiveLocationShareUpdate.PushFront -> add(0, update.value.into()) + is LiveLocationShareUpdate.Remove -> removeAt(update.index.toInt()) + is LiveLocationShareUpdate.Reset -> { + clear() + addAll(update.values.map { it.into() }) + } + is LiveLocationShareUpdate.Set -> set(update.index.toInt(), update.value.into()) + is LiveLocationShareUpdate.Truncate -> subList(update.length.toInt(), size).clear() + } + } + return mxCallbackFlow { + val shares: MutableList = ArrayList() + subscribeToLiveLocationShares(object : LiveLocationShareListener { + override fun onUpdate(updates: List) { + for (update in updates) { + shares.applyUpdate(update) + } + trySend(shares) + } + }) + }.buffer(Channel.UNLIMITED) +} + +private fun RustLiveLocationShare.into(): LiveLocationShare { + return LiveLocationShare( + userId = UserId(userId), + lastGeoUri = lastLocation?.location?.geoUri.orEmpty(), + lastTimestamp = lastLocation?.ts?.toLong() ?: 0, + ) +} + From 9ba8798175e8ec8e812baf5a316207d594f1d8f2 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 10 Apr 2026 14:43:24 +0200 Subject: [PATCH 05/18] Refactor LiveLocationShare to include structured LastLocation --- .../location/impl/show/ShowLocationPresenter.kt | 5 +++-- .../api/room/location/LiveLocationShare.kt | 17 +++++++++++++---- .../room/location/LiveLocationSharesFlow.kt | 13 ++++++++++--- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index b72c01e697..90796da4ca 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -138,7 +138,8 @@ class ShowLocationPresenter( val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() } combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members -> liveShares.mapNotNull { share -> - val location = Location.fromGeoUri(share.lastGeoUri) ?: return@mapNotNull null + val lastLocation = share.lastLocation ?: return@mapNotNull null + val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null val member = members.find { it.userId == share.userId } val displayName = member?.getBestName() ?: share.userId.value val avatarUrl = member?.avatarUrl @@ -154,7 +155,7 @@ class ShowLocationPresenter( formattedTimestamp = "Sharing live location", location = location, isLive = true, - assetType = AssetType.SENDER, + assetType = lastLocation.assetType, ) }.toPersistentList() }.collect { value = it } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt index 5f9cd41462..59b2381dbf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt @@ -15,8 +15,17 @@ import io.element.android.libraries.matrix.api.core.UserId data class LiveLocationShare( /** The user who is sharing their location. */ val userId: UserId, - /** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */ - val lastGeoUri: String, - /** The timestamp of the last location update. */ - val lastTimestamp: Long, + /** The last known location if any. */ + val lastLocation: LastLocation?, + /** The timestamp when location sharing ends, in milliseconds. */ + val endTimestamp: Long, +) + +data class LastLocation( + /** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */ + val geoUri: String, + /** The timestamp of the last location update. */ + val timestamp: Long, + /** The asset of the last location update. */ + val assetType: AssetType, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt index 7b3a29cf4a..efe2d0cd68 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt @@ -8,15 +8,16 @@ package io.element.android.libraries.matrix.impl.room.location import io.element.android.libraries.matrix.api.core.UserId +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.impl.util.mxCallbackFlow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer -import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare import org.matrix.rustcomponents.sdk.LiveLocationShareListener import org.matrix.rustcomponents.sdk.LiveLocationShareUpdate import org.matrix.rustcomponents.sdk.RoomInterface +import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare fun RoomInterface.liveLocationSharesFlow(): Flow> { fun MutableList.applyUpdate(update: LiveLocationShareUpdate) { @@ -53,8 +54,14 @@ fun RoomInterface.liveLocationSharesFlow(): Flow> { private fun RustLiveLocationShare.into(): LiveLocationShare { return LiveLocationShare( userId = UserId(userId), - lastGeoUri = lastLocation?.location?.geoUri.orEmpty(), - lastTimestamp = lastLocation?.ts?.toLong() ?: 0, + lastLocation = lastLocation?.let { + LastLocation( + geoUri = it.location.geoUri, + timestamp = it.ts.toLong(), + assetType = it.location.asset.into(), + ) + }, + endTimestamp = (startTs + timeout).toLong() ) } From 7c3b9523df00e09fac5c6c3d9e9421590c5ca276 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 10 Apr 2026 20:44:05 +0200 Subject: [PATCH 06/18] Improve live location UI with empty state --- .../impl/common/ui/LocationShareRow.kt | 6 +- .../impl/show/ShowLocationPresenter.kt | 6 +- .../location/impl/show/ShowLocationState.kt | 6 +- .../impl/show/ShowLocationStateProvider.kt | 9 ++- .../location/impl/show/ShowLocationView.kt | 62 ++++++++++++------- 5 files changed, 56 insertions(+), 33 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt index b949f55c76..866c7342ca 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt @@ -91,7 +91,7 @@ fun LocationShareRow( ) } Text( - text = item.formattedTimestamp, + text = item.description, style = ElementTheme.typography.fontBodySmRegular, color = ElementTheme.colors.textSecondary, maxLines = 1, @@ -123,7 +123,7 @@ internal fun LocationShareRowPreview() = ElementPreview { url = null, size = AvatarSize.UserListItem, ), - formattedTimestamp = "Shared 1 min ago", + description = "Shared 1 min ago", isLive = true, assetType = AssetType.SENDER, location = Location(0.0, 0.0) @@ -142,7 +142,7 @@ internal fun LocationShareRowPreview() = ElementPreview { ), isLive = false, assetType = AssetType.PIN, - formattedTimestamp = "Shared 5 hours ago", + description = "Shared 5 hours ago", location = Location(0.0, 0.0) ), onShareClick = {}, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 90796da4ca..c46805684e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -40,7 +40,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.joinedRoomMembers -import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.collections.immutable.persistentListOf @@ -124,7 +123,7 @@ class ShowLocationPresenter( url = mode.senderAvatarUrl, size = AvatarSize.UserListItem, ), - formattedTimestamp = formattedTimestamp, + description = formattedTimestamp, location = mode.location, isLive = false, assetType = mode.assetType, @@ -152,7 +151,7 @@ class ShowLocationPresenter( url = avatarUrl, size = AvatarSize.UserListItem, ), - formattedTimestamp = "Sharing live location", + description = "Sharing live location", location = location, isLive = true, assetType = lastLocation.assetType, @@ -169,6 +168,7 @@ class ShowLocationPresenter( locationShares = locationShares, hasLocationPermission = permissionsState.isAnyGranted, isTrackMyLocation = isTrackMyLocation, + isLive = mode is ShowLocationMode.Live, appName = appName, eventSink = ::handleEvent, ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index 9494db12ec..24090a1504 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.impl.show import io.element.android.features.location.api.Location +import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.libraries.designsystem.components.PinVariant @@ -18,6 +19,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( + val isLive: Boolean, val dialogState: LocationConstraintsDialogState, val locationShares: ImmutableList, val hasLocationPermission: Boolean, @@ -25,14 +27,14 @@ data class ShowLocationState( val appName: String, val eventSink: (ShowLocationEvent) -> Unit, ) { - val isSheetDraggable = locationShares.any { item -> item.isLive } + val isSheetDraggable = isLive && locationShares.isNotEmpty() } data class LocationShareItem( val userId: UserId, val displayName: String, val avatarData: AvatarData, - val formattedTimestamp: String, + val description: String, val location: Location, val isLive: Boolean, val assetType: AssetType?, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index 8bee410715..774a97d284 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -10,6 +10,7 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.api.Location +import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -21,6 +22,8 @@ class ShowLocationStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aShowLocationState(), + aShowLocationState(isLive = true), + aShowLocationState(isLive = true, locationShares = emptyList()), aShowLocationState( constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, ), @@ -44,8 +47,9 @@ class ShowLocationStateProvider : PreviewParameterProvider { private const val APP_NAME = "ApplicationName" fun aShowLocationState( + isLive: Boolean = false, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, - locationShares: List = listOf(aLocationShareItem()), + locationShares: List = listOf(aLocationShareItem(isLive = isLive)), hasLocationPermission: Boolean = false, isTrackMyLocation: Boolean = false, appName: String = APP_NAME, @@ -57,6 +61,7 @@ fun aShowLocationState( hasLocationPermission = hasLocationPermission, isTrackMyLocation = isTrackMyLocation, appName = appName, + isLive = isLive, eventSink = eventSink, ) } @@ -78,7 +83,7 @@ fun aLocationShareItem( userId = userId, displayName = displayName, avatarData = avatarData, - formattedTimestamp = formattedTimestamp, + description = formattedTimestamp, location = location, isLive = isLive, assetType = assetType, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index ad2d4cb8ca..30bb027bb5 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -12,6 +12,7 @@ package io.element.android.features.location.impl.show import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.BottomSheetDefaults @@ -26,6 +27,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -88,7 +90,7 @@ fun ShowLocationView( bottomSheetState = rememberStandardBottomSheetState( initialValue = if (state.isSheetDraggable) { - SheetValue.PartiallyExpanded + SheetValue.Expanded } else { SheetValue.Expanded } @@ -116,29 +118,43 @@ fun ShowLocationView( }, sheetContent = { sheetPaddings -> val coroutineScope = rememberCoroutineScope() - Spacer(Modifier.height(20.dp)) - Text( - text = stringResource(CommonStrings.screen_static_location_sheet_title), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - state.locationShares.forEach { locationShare -> - LocationShareRow( - item = locationShare, - onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, - modifier = Modifier.clickable { - state.eventSink(ShowLocationEvent.TrackMyLocation(false)) - val position = CameraPosition( - padding = sheetPaddings, - target = Position(locationShare.location.lon, locationShare.location.lat), - zoom = MapDefaults.DEFAULT_ZOOM - ) - coroutineScope.launch { - cameraState.animateTo(finalPosition = position) - } - } + if (!state.isSheetDraggable) { + Spacer(Modifier.height(20.dp)) + } + if (state.locationShares.isEmpty()) { + Spacer(Modifier.height(16.dp)) + Text( + text = "Nobody is sharing their location", + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + textAlign = TextAlign.Center, ) + Spacer(Modifier.height(16.dp)) + } else { + Text( + text = stringResource(CommonStrings.screen_static_location_sheet_title), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + state.locationShares.forEach { locationShare -> + LocationShareRow( + item = locationShare, + onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, + modifier = Modifier.clickable { + state.eventSink(ShowLocationEvent.TrackMyLocation(false)) + val position = CameraPosition( + padding = sheetPaddings, + target = Position(locationShare.location.lon, locationShare.location.lat), + zoom = MapDefaults.DEFAULT_ZOOM + ) + coroutineScope.launch { + cameraState.animateTo(finalPosition = position) + } + } + ) + } } }, mapContent = { From 0e9af5f42a5bbfc5f57870f7976f1d8b4305e818 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 10 Apr 2026 20:45:18 +0200 Subject: [PATCH 07/18] Refactor live location shares to use callbackFlow --- .../impl/room/location/LiveLocationSharesFlow.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt index efe2d0cd68..4f4ddac667 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt @@ -10,10 +10,12 @@ package io.element.android.libraries.matrix.impl.room.location import io.element.android.libraries.matrix.api.core.UserId 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.impl.util.mxCallbackFlow +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow import org.matrix.rustcomponents.sdk.LiveLocationShareListener import org.matrix.rustcomponents.sdk.LiveLocationShareUpdate import org.matrix.rustcomponents.sdk.RoomInterface @@ -38,9 +40,10 @@ fun RoomInterface.liveLocationSharesFlow(): Flow> { is LiveLocationShareUpdate.Truncate -> subList(update.length.toInt(), size).clear() } } - return mxCallbackFlow { + return callbackFlow { + val liveLocationShares = liveLocationShares() val shares: MutableList = ArrayList() - subscribeToLiveLocationShares(object : LiveLocationShareListener { + val taskHandle = liveLocationShares.subscribe(object : LiveLocationShareListener { override fun onUpdate(updates: List) { for (update in updates) { shares.applyUpdate(update) @@ -48,6 +51,10 @@ fun RoomInterface.liveLocationSharesFlow(): Flow> { trySend(shares) } }) + awaitClose { + taskHandle.cancelAndDestroy() + liveLocationShares.destroy() + } }.buffer(Channel.UNLIMITED) } From 537063d899ce2577147798c7c7eb7bc2312d6de1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 10 Apr 2026 21:11:30 +0200 Subject: [PATCH 08/18] Add focused location tracking when opening the map --- .../features/location/api/ShowLocationMode.kt | 4 +++- .../impl/show/ShowLocationPresenter.kt | 13 ++++++++---- .../location/impl/show/ShowLocationState.kt | 2 +- .../impl/show/ShowLocationStateProvider.kt | 2 ++ .../location/impl/show/ShowLocationView.kt | 21 +++++++++++-------- .../messages/impl/MessagesFlowNode.kt | 2 +- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt index 1227ddec46..3feeeff57d 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt @@ -24,5 +24,7 @@ sealed interface ShowLocationMode : Parcelable { ) : ShowLocationMode @Parcelize - data object Live : ShowLocationMode + data class Live( + val senderId: UserId + ) : ShowLocationMode } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index c46805684e..2f9c3d0d81 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -131,8 +131,8 @@ class ShowLocationPresenter( ) } } - ShowLocationMode.Live -> { - val liveShares by produceState(persistentListOf()) { + is ShowLocationMode.Live -> { + produceState(persistentListOf()) { val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares() val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() } combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members -> @@ -158,14 +158,19 @@ class ShowLocationPresenter( ) }.toPersistentList() }.collect { value = it } - } - liveShares + }.value } } + val focusedLocation = when (mode) { + is ShowLocationMode.Static -> locationShares.firstOrNull() + is ShowLocationMode.Live -> locationShares.firstOrNull { it.userId == mode.senderId } + } + return ShowLocationState( dialogState = dialogState, locationShares = locationShares, + focusedLocation = focusedLocation, hasLocationPermission = permissionsState.isAnyGranted, isTrackMyLocation = isTrackMyLocation, isLive = mode is ShowLocationMode.Live, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index 24090a1504..3d4df465f9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -9,7 +9,6 @@ package io.element.android.features.location.impl.show import io.element.android.features.location.api.Location -import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.libraries.designsystem.components.PinVariant @@ -22,6 +21,7 @@ data class ShowLocationState( val isLive: Boolean, val dialogState: LocationConstraintsDialogState, val locationShares: ImmutableList, + val focusedLocation: LocationShareItem?, val hasLocationPermission: Boolean, val isTrackMyLocation: Boolean, val appName: String, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index 774a97d284..3b08e81890 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -50,6 +50,7 @@ fun aShowLocationState( isLive: Boolean = false, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, locationShares: List = listOf(aLocationShareItem(isLive = isLive)), + focusedLocation: LocationShareItem? = locationShares.firstOrNull(), hasLocationPermission: Boolean = false, isTrackMyLocation: Boolean = false, appName: String = APP_NAME, @@ -58,6 +59,7 @@ fun aShowLocationState( return ShowLocationState( dialogState = constraintsDialogState, locationShares = locationShares.toImmutableList(), + focusedLocation = focusedLocation, hasLocationPermission = hasLocationPermission, isTrackMyLocation = isTrackMyLocation, appName = appName, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 30bb027bb5..de35430b39 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -22,8 +22,11 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -67,25 +70,25 @@ fun ShowLocationView( onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) }, ) - val initialPosition = remember { - if (state.locationShares.isEmpty()) { - MapDefaults.defaultCameraPosition - } else { - val firstLocation = state.locationShares.first().location - CameraPosition( - target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon), + val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) + var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) } + LaunchedEffect(state.focusedLocation) { + if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) { + hasAnimatedToFocusedLocation = true + val position = CameraPosition( + target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon), zoom = MapDefaults.DEFAULT_ZOOM ) + cameraState.position = position } } - val cameraState = rememberCameraState(firstPosition = initialPosition) - val userLocationState = rememberUserLocationState(state.hasLocationPermission) LaunchedEffect(cameraState.isCameraMoving) { if (cameraState.moveReason == CameraMoveReason.GESTURE) { state.eventSink(ShowLocationEvent.TrackMyLocation(false)) } } + val userLocationState = rememberUserLocationState(state.hasLocationPermission) val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( initialValue = diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index dc0ade0dc2..5affdb4484 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -571,7 +571,7 @@ class MessagesFlowNode( } is TimelineItemLocationContent -> { val mode = when(event.content.mode){ - is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live + is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId) is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static( location = event.content.mode.location, senderName = event.safeSenderName, From 580e85d232de5287cf6acd9643ad1cfb9fdb0e62 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 13 Apr 2026 11:51:48 +0200 Subject: [PATCH 09/18] Fix live location share item description --- .../features/location/impl/common/ui/LocationShareRow.kt | 8 ++++---- .../features/location/impl/show/ShowLocationPresenter.kt | 9 +++++++-- .../features/location/impl/show/ShowLocationState.kt | 2 +- .../location/impl/show/ShowLocationStateProvider.kt | 7 +++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt index 866c7342ca..6fbcfc4814 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt @@ -91,9 +91,9 @@ fun LocationShareRow( ) } Text( - text = item.description, + text = if (item.isLive) "Sharing live location" else item.formattedTimestamp, style = ElementTheme.typography.fontBodySmRegular, - color = ElementTheme.colors.textSecondary, + color = if(item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -123,7 +123,7 @@ internal fun LocationShareRowPreview() = ElementPreview { url = null, size = AvatarSize.UserListItem, ), - description = "Shared 1 min ago", + formattedTimestamp = "Shared 1 min ago", isLive = true, assetType = AssetType.SENDER, location = Location(0.0, 0.0) @@ -142,7 +142,7 @@ internal fun LocationShareRowPreview() = ElementPreview { ), isLive = false, assetType = AssetType.PIN, - description = "Shared 5 hours ago", + formattedTimestamp = "Shared 5 hours ago", location = Location(0.0, 0.0) ), onShareClick = {}, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 2f9c3d0d81..e23baf1a43 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -123,7 +123,7 @@ class ShowLocationPresenter( url = mode.senderAvatarUrl, size = AvatarSize.UserListItem, ), - description = formattedTimestamp, + formattedTimestamp = formattedTimestamp, location = mode.location, isLive = false, assetType = mode.assetType, @@ -142,6 +142,11 @@ 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 formattedTimestamp = stringProvider.getString( + CommonStrings.screen_static_location_sheet_timestamp_description, + relativeTime + ) LocationShareItem( userId = share.userId, displayName = displayName, @@ -151,7 +156,7 @@ class ShowLocationPresenter( url = avatarUrl, size = AvatarSize.UserListItem, ), - description = "Sharing live location", + formattedTimestamp = formattedTimestamp, location = location, isLive = true, assetType = lastLocation.assetType, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index 3d4df465f9..b6a60f35db 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -34,7 +34,7 @@ data class LocationShareItem( val userId: UserId, val displayName: String, val avatarData: AvatarData, - val description: String, + val formattedTimestamp: String, val location: Location, val isLive: Boolean, val assetType: AssetType?, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index 3b08e81890..1ab2310365 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -10,7 +10,6 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.api.Location -import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -77,15 +76,15 @@ fun aLocationShareItem( url = null, size = AvatarSize.UserListItem, ), - formattedTimestamp: String = "Shared 1 min ago", - location: Location = Location(1.23, 2.34, 4f), isLive: Boolean = false, assetType: AssetType? = null, + formattedTimestamp: String = "Shared 1 min ago", + location: Location = Location(1.23, 2.34, 4f), ) = LocationShareItem( userId = userId, displayName = displayName, avatarData = avatarData, - description = formattedTimestamp, + formattedTimestamp = formattedTimestamp, location = location, isLive = isLive, assetType = assetType, From f5683f9c8b5e753e47fefc19563b142737be0e0e Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 15 Apr 2026 13:46:31 +0200 Subject: [PATCH 10/18] Improve live location bottomsheet interaction with map --- .../impl/common/ui/MapBottomSheetScaffold.kt | 10 +++- .../location/impl/show/ShowLocationView.kt | 55 ++++++++++--------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index fbaed9c854..13c30c28eb 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -10,12 +10,14 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing @@ -43,6 +45,7 @@ import androidx.compose.ui.unit.max import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.impl.common.MapDefaults import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.rememberCameraState @@ -112,8 +115,11 @@ fun MapBottomSheetScaffold( modifier = Modifier, sheetPeekHeight = sheetPeekHeight, sheetContent = { - sheetContent(sheetPadding) - Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp() + Column(modifier = Modifier.heightIn(max = maxContentHeight)) { + sheetContent(sheetPadding) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) + } }, scaffoldState = scaffoldState, sheetDragHandle = sheetDragHandle, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index de35430b39..b660614ca2 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue @@ -90,15 +92,13 @@ fun ShowLocationView( val userLocationState = rememberUserLocationState(state.hasLocationPermission) val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState( - initialValue = - if (state.isSheetDraggable) { - SheetValue.Expanded - } else { - SheetValue.Expanded - } - ) + bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded) ) + LaunchedEffect(state.isSheetDraggable) { + if (!state.isSheetDraggable) { + scaffoldState.bottomSheetState.expand() + } + } MapBottomSheetScaffold( sheetDragHandle = if (state.isSheetDraggable) { { BottomSheetDefaults.DragHandle() } @@ -122,18 +122,19 @@ fun ShowLocationView( sheetContent = { sheetPaddings -> val coroutineScope = rememberCoroutineScope() if (!state.isSheetDraggable) { + // If sheet is draggable the DragHandle has already some padding Spacer(Modifier.height(20.dp)) } if (state.locationShares.isEmpty()) { - Spacer(Modifier.height(16.dp)) Text( text = "Nobody is sharing their location", style = ElementTheme.typography.fontBodyLgMedium, color = ElementTheme.colors.textPrimary, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp), textAlign = TextAlign.Center, ) - Spacer(Modifier.height(16.dp)) } else { Text( text = stringResource(CommonStrings.screen_static_location_sheet_title), @@ -141,22 +142,24 @@ fun ShowLocationView( color = ElementTheme.colors.textPrimary, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) - state.locationShares.forEach { locationShare -> - LocationShareRow( - item = locationShare, - onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, - modifier = Modifier.clickable { - state.eventSink(ShowLocationEvent.TrackMyLocation(false)) - val position = CameraPosition( - padding = sheetPaddings, - target = Position(locationShare.location.lon, locationShare.location.lat), - zoom = MapDefaults.DEFAULT_ZOOM - ) - coroutineScope.launch { - cameraState.animateTo(finalPosition = position) + LazyColumn { + items(state.locationShares) { locationShare -> + LocationShareRow( + item = locationShare, + onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, + modifier = Modifier.clickable { + state.eventSink(ShowLocationEvent.TrackMyLocation(false)) + val position = CameraPosition( + padding = sheetPaddings, + target = Position(locationShare.location.lon, locationShare.location.lat), + zoom = MapDefaults.DEFAULT_ZOOM + ) + coroutineScope.launch { + cameraState.animateTo(finalPosition = position) + } } - } - ) + ) + } } } }, From 11866afb03dbe197a46dddb8d682546afadc54a6 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 15 Apr 2026 13:55:54 +0200 Subject: [PATCH 11/18] Remove hardcoded strings --- .../features/location/impl/common/ui/LocationShareRow.kt | 2 +- .../android/features/location/impl/show/ShowLocationView.kt | 2 +- libraries/ui-strings/src/main/res/values/localazy.xml | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt index 6fbcfc4814..d2c7ba5966 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt @@ -91,7 +91,7 @@ fun LocationShareRow( ) } Text( - text = if (item.isLive) "Sharing live location" else item.formattedTimestamp, + text = if (item.isLive) stringResource(CommonStrings.screen_room_live_location_banner) else item.formattedTimestamp, style = ElementTheme.typography.fontBodySmRegular, color = if(item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary, maxLines = 1, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index b660614ca2..7ac5946723 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -127,7 +127,7 @@ fun ShowLocationView( } if (state.locationShares.isEmpty()) { Text( - text = "Nobody is sharing their location", + text = stringResource(CommonStrings.screen_live_location_sheet_nobody_sharing), style = ElementTheme.typography.fontBodyLgMedium, color = ElementTheme.colors.textPrimary, modifier = Modifier diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index f91e3a85b0..d0ce3dce24 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -467,6 +467,7 @@ Are you sure you want to continue?" "Options" "Remove %1$s" "Settings" + "Nobody is sharing their location" "Failed selecting media, please try again." "Open Element Classic" "Open Element Classic on your device" From 704ddc9132e80d12050b9899f6963b65d862516b Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 15 Apr 2026 22:06:00 +0200 Subject: [PATCH 12/18] Update live location shares when reaching timeout (before actual stop event) --- .../impl/show/ShowLocationPresenter.kt | 2 +- .../impl/show/ShowLocationPresenterTest.kt | 76 ++++----- .../components/TimelineItemEventRow.kt | 9 +- .../event/TimelineItemEventContentView.kt | 13 +- .../event/TimelineItemLocationView.kt | 10 +- .../event/TimelineItemContentFactory.kt | 3 +- .../event/TimelineItemEventContentProvider.kt | 4 +- .../event/TimelineItemLocationContent.kt | 47 +++++- .../TimelineItemLocationContentProvider.kt | 4 + .../fixtures/TimelineItemsFactoryFixtures.kt | 3 + .../TimelineItemContentMessageFactoryTest.kt | 3 +- .../api/timeline/item/event/EventContent.kt | 4 +- .../matrix/impl/room/JoinedRustRoom.kt | 3 +- .../location/TimedLiveLocationSharesFlow.kt | 56 +++++++ .../item/event/TimelineEventContentMapper.kt | 2 +- .../TimedLiveLocationSharesFlowTest.kt | 148 ++++++++++++++++++ ...nticsNodeInteractionsProviderExtensions.kt | 8 + 17 files changed, 331 insertions(+), 64 deletions(-) create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index e23baf1a43..10501409fe 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -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 diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 81ec465686..c5120928dc 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -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()) - 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, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index b0b1e0c755..976fa3c17e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 1de73f3658..2044796889 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index c9e3152afb..1c35216c38 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -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, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 8b81ae0906..fcb346ecd7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -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, ), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 9683a2c149..44dd2df38d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -35,7 +35,9 @@ class TimelineItemEventContentProvider : PreviewParameterProvider 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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt index e2309c2210..a9cc9e59d4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -22,6 +22,7 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider, ) : EventContent { - val endsAt = timestamp + timeout + val endTimestamp = startTimestamp + timeout } data object LegacyCallInviteContent : EventContent diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 0c41824dde..0a754a1f3c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -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> { - return innerRoom.liveLocationSharesFlow() + return innerRoom.liveLocationSharesFlow().timedByExpiry(systemClock::epochMillis) } override suspend fun startLiveLocationShare(durationMillis: Long): Result = withContext(roomDispatcher) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt new file mode 100644 index 0000000000..5a570d04d5 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt @@ -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>.timedByExpiry( + currentTimeMillis: () -> Long = System::currentTimeMillis, +): Flow> = channelFlow { + var timerJob: Job? = null + + fun List.nextExpiryAfter(timestamp: Long): Long? { + return this + .asSequence() + .map { it.endTimestamp } + .filter { it > timestamp } + .minOrNull() + } + + fun List.filterLive(): List { + val currentTimeMillis = currentTimeMillis() + return filter { it.endTimestamp > currentTimeMillis } + } + + fun reschedule(shares: List) { + 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) + } + +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 0d940a0a11..85b53bc6e3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -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(), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt new file mode 100644 index 0000000000..886b927b9a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt @@ -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>(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>() + .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, + ) +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt index 6502882d7d..5de2cf76da 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt @@ -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 AndroidComposeTestRule.assertNoNodeWith val text = activity.getString(res) onNodeWithText(text).assertDoesNotExist() } + +fun AndroidComposeTestRule.assertNodeWithTextIsDisplayed(@StringRes res: Int) { + val text = activity.getString(res) + onNodeWithText(text).assertIsDisplayed() +} + + From 6b933b6506d8f931e6034c8add3ff933a7a23e3d Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 16 Apr 2026 15:52:11 +0200 Subject: [PATCH 13/18] Use "Shared live location" in formatter --- .../features/messages/impl/actionlist/ActionListView.kt | 6 +++++- .../utils/messagesummary/DefaultMessageSummaryFormatter.kt | 5 ++++- .../eventformatter/impl/DefaultRoomLatestEventFormatter.kt | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 53f15066b6..9f3b2afd2a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -289,7 +289,11 @@ private fun MessageSummary( is TimelineItemRedactedContent, is TimelineItemUnknownContent -> content = { ContentForBody(textContent) } is TimelineItemLocationContent -> { - content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) } + val body = when(event.content.mode) { + is TimelineItemLocationContent.Mode.Live -> stringResource(CommonStrings.common_shared_live_location) + is TimelineItemLocationContent.Mode.Static -> stringResource(CommonStrings.common_shared_location) + } + content = { ContentForBody(body) } } is TimelineItemImageContent -> { content = { ContentForBody(event.content.bestDescription) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index 0aeb3bb8fc..a92dedae0f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -41,7 +41,10 @@ class DefaultMessageSummaryFormatter( is TimelineItemTextBasedContent -> content.plainText is TimelineItemProfileChangeContent -> content.body is TimelineItemStateContent -> content.body - is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location) + is TimelineItemLocationContent -> when(content.mode) { + is TimelineItemLocationContent.Mode.Live -> context.getString(CommonStrings.common_shared_live_location) + is TimelineItemLocationContent.Mode.Static -> context.getString(CommonStrings.common_shared_location) + } is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt) is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed) is TimelineItemPollContent -> content.question diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index 68dd4cd332..3ecd7819e5 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -117,7 +117,7 @@ class DefaultRoomLatestEventFormatter( message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) } is LiveLocationContent -> { - val message = sp.getString(CommonStrings.common_shared_location) + val message = sp.getString(CommonStrings.common_shared_live_location) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) } is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) From fbfeeae08476d45fa2af289dd7eb151974d424cc Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 16 Apr 2026 16:18:53 +0200 Subject: [PATCH 14/18] Fix formatting --- .../api/internal/StaticMapPlaceholder.kt | 1 - .../impl/common/ui/LocationShareRow.kt | 2 +- .../impl/show/ShowLocationPresenter.kt | 4 +- .../impl/show/ShowLocationPresenterTest.kt | 4 +- .../messages/impl/MessagesFlowNode.kt | 2 +- .../impl/actionlist/ActionListView.kt | 2 +- .../event/TimelineItemLocationView.kt | 2 - .../event/TimelineItemLocationContent.kt | 3 +- .../DefaultMessageSummaryFormatter.kt | 2 +- .../TimelineItemContentMessageFactoryTest.kt | 53 ++++++++----------- .../room/location/LiveLocationSharesFlow.kt | 1 - .../location/TimedLiveLocationSharesFlow.kt | 1 - .../TimedLiveLocationSharesFlowTest.kt | 4 +- ...nticsNodeInteractionsProviderExtensions.kt | 2 - 14 files changed, 31 insertions(+), 52 deletions(-) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt index 735a25fef9..0292ec927e 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt @@ -28,7 +28,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.location.api.R 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.Text import io.element.android.libraries.ui.strings.CommonStrings diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt index d2c7ba5966..83db9a9c61 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt @@ -93,7 +93,7 @@ fun LocationShareRow( Text( text = if (item.isLive) stringResource(CommonStrings.screen_room_live_location_banner) else item.formattedTimestamp, style = ElementTheme.typography.fontBodySmRegular, - color = if(item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary, + color = if (item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 10501409fe..6b05e473c6 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -43,7 +43,7 @@ import io.element.android.libraries.matrix.api.room.joinedRoomMembers import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.combine @AssistedInject @@ -161,7 +161,7 @@ class ShowLocationPresenter( isLive = true, assetType = lastLocation.assetType, ) - }.toPersistentList() + }.toImmutableList() }.collect { value = it } }.value } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index c5120928dc..5369c441f8 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -434,7 +434,7 @@ class ShowLocationPresenterTest { senderName = senderName, senderId = senderId, senderAvatarUrl = avatarUrl, - timestamp = 1234567890L, + timestamp = 0L, assetType = AssetType.SENDER, ) @@ -468,7 +468,7 @@ class ShowLocationPresenterTest { private fun aLiveLocationShare( userId: UserId, geoUri: String = "geo:48.8584,2.2945", - timestamp: Long = 1234567890L, + timestamp: Long = 0L, endTimestamp: Long = Long.MAX_VALUE, assetType: AssetType = AssetType.SENDER, ): LiveLocationShare { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 5affdb4484..690f895e07 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -570,7 +570,7 @@ class MessagesFlowNode( ) } is TimelineItemLocationContent -> { - val mode = when(event.content.mode){ + val mode = when (event.content.mode) { is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId) is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static( location = event.content.mode.location, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 9f3b2afd2a..ef446e36cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -289,7 +289,7 @@ private fun MessageSummary( is TimelineItemRedactedContent, is TimelineItemUnknownContent -> content = { ContentForBody(textContent) } is TimelineItemLocationContent -> { - val body = when(event.content.mode) { + val body = when (event.content.mode) { is TimelineItemLocationContent.Mode.Live -> stringResource(CommonStrings.common_shared_live_location) is TimelineItemLocationContent.Mode.Static -> stringResource(CommonStrings.common_shared_location) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index 1c35216c38..f00b6b0b5b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -33,7 +33,6 @@ 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 @@ -129,7 +128,6 @@ private fun LiveLocationOverlay( } else { stringResource(CommonStrings.common_live_location_ended) }, - style = ElementTheme.typography.fontBodySmMedium, color = ElementTheme.colors.textPrimary, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt index 8fb4d50427..40af75c82c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt @@ -88,7 +88,7 @@ data class TimelineItemLocationContent( internal fun TimelineItemLocationContent.ensureActiveLiveLocation( currentTimeMillis: () -> Long = System::currentTimeMillis, ): TimelineItemLocationContent { - return when (val mode = mode) { + return when (mode) { is TimelineItemLocationContent.Mode.Live -> { val isActive = rememberIsLiveLocationActive(mode, currentTimeMillis) copy(mode = mode.copy(isActive = isActive)) @@ -102,7 +102,6 @@ private fun rememberIsLiveLocationActive( mode: TimelineItemLocationContent.Mode.Live, currentTimeMillis: () -> Long, ): Boolean { - fun TimelineItemLocationContent.Mode.Live.isActive(): Boolean { return isActive && endTimestamp > currentTimeMillis() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index a92dedae0f..c48f2dae40 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -41,7 +41,7 @@ class DefaultMessageSummaryFormatter( is TimelineItemTextBasedContent -> content.plainText is TimelineItemProfileChangeContent -> content.body is TimelineItemStateContent -> content.body - is TimelineItemLocationContent -> when(content.mode) { + is TimelineItemLocationContent -> when (content.mode) { is TimelineItemLocationContent.Mode.Live -> context.getString(CommonStrings.common_shared_live_location) is TimelineItemLocationContent.Mode.Static -> context.getString(CommonStrings.common_shared_location) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index e6ef18d00d..fa837f1492 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -78,9 +78,7 @@ import org.robolectric.RobolectricTestRunner import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes -@Suppress("LargeClass") -@RunWith(RobolectricTestRunner::class) -class TimelineItemContentMessageFactoryTest { +@Suppress("LargeClass") @RunWith(RobolectricTestRunner::class) class TimelineItemContentMessageFactoryTest { @Test fun `test create OtherMessageType`() = runTest { val sut = createTimelineItemContentMessageFactory() @@ -164,16 +162,11 @@ class TimelineItemContentMessageFactoryTest { senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) as TimelineItemTextContent - val expected = TimelineItemTextContent( - body = "https://www.example.org", - htmlDocument = null, - isEdited = false, - formattedBody = buildSpannedString { - inSpans(URLSpan("https://www.example.org")) { - append("https://www.example.org") - } + val expected = TimelineItemTextContent(body = "https://www.example.org", htmlDocument = null, isEdited = false, formattedBody = buildSpannedString { + inSpans(URLSpan("https://www.example.org")) { + append("https://www.example.org") } - ) + }) assertThat(result.body).isEqualTo(expected.body) assertThat(result.htmlDocument).isEqualTo(expected.htmlDocument) assertThat(result.plainText).isEqualTo(expected.plainText) @@ -198,9 +191,7 @@ class TimelineItemContentMessageFactoryTest { append("and manually added link") } }.toSpannable() - val sut = createTimelineItemContentMessageFactory( - domConverterTransform = { expected } - ) + val sut = createTimelineItemContentMessageFactory(domConverterTransform = { expected }) val result = sut.create( content = createMessageContent( type = TextMessageType( @@ -217,9 +208,7 @@ class TimelineItemContentMessageFactoryTest { @Test fun `test create TextMessageType with unknown formatted body does nothing`() = runTest { - val sut = createTimelineItemContentMessageFactory( - htmlConverterTransform = { it } - ) + val sut = createTimelineItemContentMessageFactory(htmlConverterTransform = { it }) val result = sut.create( content = createMessageContent( type = TextMessageType( @@ -354,10 +343,10 @@ class TimelineItemContentMessageFactoryTest { formattedCaption = null, source = MediaSource("url"), info = AudioInfo( - duration = 1.minutes, - size = 123L, - mimetype = MimeTypes.Mp3, - ) + duration = 1.minutes, + size = 123L, + mimetype = MimeTypes.Mp3, + ) ), isEdited = true, ), @@ -595,16 +584,16 @@ class TimelineItemContentMessageFactoryTest { formattedCaption = null, source = MediaSource("url"), info = FileInfo( - mimetype = MimeTypes.Pdf, - size = 123L, - thumbnailInfo = ThumbnailInfo( - height = 10L, - width = 5L, - mimetype = MimeTypes.Jpeg, - size = 111L, - ), - thumbnailSource = MediaSource("url_thumbnail"), - ) + mimetype = MimeTypes.Pdf, + size = 123L, + thumbnailInfo = ThumbnailInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 111L, + ), + thumbnailSource = MediaSource("url_thumbnail"), + ) ), isEdited = true, ), diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt index 4f4ddac667..8922cd6627 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt @@ -71,4 +71,3 @@ private fun RustLiveLocationShare.into(): LiveLocationShare { endTimestamp = (startTs + timeout).toLong() ) } - diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt index 5a570d04d5..9bfddf280f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlow.kt @@ -52,5 +52,4 @@ internal fun Flow>.timedByExpiry( send(liveShares) reschedule(liveShares) } - } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt index 886b927b9a..6c78fcc51b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt @@ -12,9 +12,8 @@ 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.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest @@ -22,7 +21,6 @@ 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( diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt index 5de2cf76da..d78f570a31 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt @@ -66,5 +66,3 @@ fun AndroidComposeTestRule.assertNodeWithTe val text = activity.getString(res) onNodeWithText(text).assertIsDisplayed() } - - From 8182a149d09201dd1ab3f9a89be8afccf13d1dc7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 17 Apr 2026 14:54:53 +0200 Subject: [PATCH 15/18] Live location : ensure it's not sorted randomly --- .../impl/show/LiveLocationShareComparator.kt | 20 ++++++ .../impl/show/ShowLocationPresenter.kt | 56 ++++++++------- .../show/LiveLocationShareComparatorTest.kt | 69 +++++++++++++++++++ .../impl/show/ShowLocationPresenterTest.kt | 2 + .../api/room/location/LiveLocationShare.kt | 2 + .../room/location/LiveLocationSharesFlow.kt | 1 + 6 files changed, 124 insertions(+), 26 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.kt create mode 100644 features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.kt new file mode 100644 index 0000000000..41b9bea4ca --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.kt @@ -0,0 +1,20 @@ +/* + * 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.features.location.impl.show + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare + +class LiveLocationShareComparator(private val currentUser: UserId) : Comparator { + override fun compare(p0: LiveLocationShare, p1: LiveLocationShare): Int { + val p0IsCurrentUser = p0.userId == currentUser + val p1IsCurrentUser = p1.userId == currentUser + if (p0IsCurrentUser != p1IsCurrentUser) return if (p0IsCurrentUser) -1 else 1 + return p1.startTimestamp.compareTo(p0.startTimestamp) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 6b05e473c6..43d3aa6d00 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -133,35 +133,39 @@ class ShowLocationPresenter( } is ShowLocationMode.Live -> { produceState(persistentListOf()) { + val comparator = LiveLocationShareComparator(currentUser = joinedRoom.sessionId) val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares() val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() } combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members -> - liveShares.mapNotNull { share -> - val lastLocation = share.lastLocation ?: return@mapNotNull null - val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null - val member = members.find { it.userId == share.userId } - val displayName = member?.getBestName() ?: share.userId.value - val avatarUrl = member?.avatarUrl - val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true) - val formattedTimestamp = stringProvider.getString( - CommonStrings.screen_static_location_sheet_timestamp_description, - relativeTime - ) - LocationShareItem( - userId = share.userId, - displayName = displayName, - avatarData = AvatarData( - id = share.userId.value, - name = displayName, - url = avatarUrl, - size = AvatarSize.UserListItem, - ), - formattedTimestamp = formattedTimestamp, - location = location, - isLive = true, - assetType = lastLocation.assetType, - ) - }.toImmutableList() + liveShares + .sortedWith(comparator) + .mapNotNull { share -> + val lastLocation = share.lastLocation ?: return@mapNotNull null + val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null + val member = members.find { it.userId == share.userId } + val displayName = member?.getBestName() ?: share.userId.value + val avatarUrl = member?.avatarUrl + val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true) + val formattedTimestamp = stringProvider.getString( + CommonStrings.screen_static_location_sheet_timestamp_description, + relativeTime + ) + LocationShareItem( + userId = share.userId, + displayName = displayName, + avatarData = AvatarData( + id = share.userId.value, + name = displayName, + url = avatarUrl, + size = AvatarSize.UserListItem, + ), + formattedTimestamp = formattedTimestamp, + location = location, + isLive = true, + assetType = lastLocation.assetType, + ) + } + .toImmutableList() }.collect { value = it } }.value } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt new file mode 100644 index 0000000000..4042cb4c0c --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt @@ -0,0 +1,69 @@ +/* + * 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.features.location.impl.show + +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 org.junit.Test + +class LiveLocationShareComparatorTest { + private val currentUser = UserId("@me:matrix.org") + private val comparator = LiveLocationShareComparator(currentUser) + + @Test + fun `compare returns zero when comparing the same current user share`() { + val share = aLiveLocationShare(userId = currentUser, startTimestamp = 123L) + + val result = comparator.compare(share, share) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `compare orders current user share before another user share`() { + val otherShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L) + val currentUserShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L) + + val sortedShares = listOf(otherShare, currentUserShare).sortedWith(comparator) + + assertThat(sortedShares).containsExactly(currentUserShare, otherShare).inOrder() + } + + @Test + fun `compare orders current user shares by newest start timestamp first`() { + val newerShare = aLiveLocationShare(userId = currentUser, startTimestamp = 200L) + val olderShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L) + + val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator) + + assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder() + } + + @Test + fun `compare orders non current user shares by newest start timestamp first`() { + val newerShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L) + val olderShare = aLiveLocationShare(userId = UserId("@bob:matrix.org"), startTimestamp = 100L) + + val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator) + + assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder() + } +} + +private fun aLiveLocationShare( + userId: UserId, + startTimestamp: Long, +): LiveLocationShare { + return LiveLocationShare( + userId = userId, + lastLocation = null, + startTimestamp = startTimestamp, + endTimestamp = startTimestamp + 1_000L, + ) +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 5369c441f8..f38e8dae60 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -469,6 +469,7 @@ private fun aLiveLocationShare( userId: UserId, geoUri: String = "geo:48.8584,2.2945", timestamp: Long = 0L, + startTimestamp: Long = 0L, endTimestamp: Long = Long.MAX_VALUE, assetType: AssetType = AssetType.SENDER, ): LiveLocationShare { @@ -479,6 +480,7 @@ private fun aLiveLocationShare( timestamp = timestamp, assetType = assetType, ), + startTimestamp = startTimestamp, endTimestamp = endTimestamp, ) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt index 59b2381dbf..3f9c108dc7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt @@ -17,6 +17,8 @@ data class LiveLocationShare( val userId: UserId, /** The last known location if any. */ val lastLocation: LastLocation?, + /** The timestamp when location sharing started, in milliseconds.*/ + val startTimestamp: Long, /** The timestamp when location sharing ends, in milliseconds. */ val endTimestamp: Long, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt index 8922cd6627..bae406a137 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationSharesFlow.kt @@ -68,6 +68,7 @@ private fun RustLiveLocationShare.into(): LiveLocationShare { assetType = it.location.asset.into(), ) }, + startTimestamp = startTs.toLong(), endTimestamp = (startTs + timeout).toLong() ) } From 5fb0b3a93d2b14a0b3dc99dbc3ad8a501ee4f066 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 17 Apr 2026 13:45:30 +0000 Subject: [PATCH 16/18] Update screenshots --- ...es.location.api.internal_StaticMapPlaceholder_Day_0_en.png | 4 ++-- ....location.api.internal_StaticMapPlaceholder_Night_0_en.png | 4 ++-- .../images/features.location.api_StaticMapView_Day_0_en.png | 4 ++-- .../images/features.location.api_StaticMapView_Night_0_en.png | 4 ++-- ...ures.location.impl.common.ui_LocationShareRow_Day_0_en.png | 4 ++-- ...es.location.impl.common.ui_LocationShareRow_Night_0_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_1_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_2_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_3_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_4_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_5_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_6_en.png | 3 +++ .../features.location.impl.show_ShowLocationView_Day_7_en.png | 3 +++ ...eatures.location.impl.show_ShowLocationView_Night_1_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_2_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_3_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_4_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_5_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_6_en.png | 3 +++ ...eatures.location.impl.show_ShowLocationView_Night_7_en.png | 3 +++ ...ine.components.event_TimelineItemLocationView_Day_0_en.png | 4 ++-- ...ine.components.event_TimelineItemLocationView_Day_1_en.png | 4 ++-- ...ine.components.event_TimelineItemLocationView_Day_2_en.png | 4 ++-- ...ine.components.event_TimelineItemLocationView_Day_3_en.png | 3 +++ ...ine.components.event_TimelineItemLocationView_Day_4_en.png | 3 +++ ...e.components.event_TimelineItemLocationView_Night_0_en.png | 4 ++-- ...e.components.event_TimelineItemLocationView_Night_1_en.png | 4 ++-- ...e.components.event_TimelineItemLocationView_Night_2_en.png | 4 ++-- ...e.components.event_TimelineItemLocationView_Night_3_en.png | 3 +++ ...e.components.event_TimelineItemLocationView_Night_4_en.png | 3 +++ ...features.messages.impl.timeline_TimelineView_Day_17_en.png | 4 ++-- .../features.messages.impl.timeline_TimelineView_Day_9_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_17_en.png | 4 ++-- ...eatures.messages.impl.timeline_TimelineView_Night_9_en.png | 4 ++-- 34 files changed, 76 insertions(+), 52 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Day_0_en.png index dc7182b71e..aee573a6fc 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0c2adbbce73e57c601821ea9b511913b3df0efbdd3d42643fabe0d39655e04e6 -size 438908 +oid sha256:855f37c9ca2dc6ddc9a699e42a1a3c784c26911552174735f46f31ad2588e977 +size 295172 diff --git a/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Night_0_en.png index 41f55afa15..b8ff9c5b98 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.api.internal_StaticMapPlaceholder_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3baf32fc12535ffe246264dfe1f20db1fddddb970f86953e9877251dc3f74d3d -size 173356 +oid sha256:e99b3d1c62e4907f55beefa2414ff6324c62133ab723c13b68c906331e8ee072 +size 118086 diff --git a/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Day_0_en.png index 1735586b26..cedf0b1b7c 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:359960ea4b7be5ff9d766565007291f8585223052483736e17d4532c5f8af0c6 -size 252728 +oid sha256:56a86695d2c25c94a8e79c27c8f525229cc348201073566909f83a681b37ac30 +size 251329 diff --git a/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Night_0_en.png index b18c069686..dea0d3f21c 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.api_StaticMapView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b775500b1fdc6294d94dcb1f07f74c515c3eae31bc55d0faacfd86ff7bd1da3 -size 105526 +oid sha256:0c2b2a7071bd1c21ff706525e2416169507fef364485c651c429baefa15d4c9f +size 104240 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png index e48b3cd8ab..cae8f75b66 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2550638ee12b4181cea31caff0b5838a9cdb3a180c01d1188bc7c2726051b863 -size 16578 +oid sha256:aaafea9efc1000495ee469797239b82193844caa3d6f98c0c3a4344a536a1798 +size 17155 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png index 0f17f6d6a1..29f70fc9b1 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c880e4d01495868b3f0689d20d3cbf2050d6261be936421343bc1ac210aabeec -size 15959 +oid sha256:1f113f8979679c0673e4cc1f691140bc570b6826bea23eecc403f2fbfd3f6d09 +size 16460 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png index ff0295d9ac..65720f93f6 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb -size 37572 +oid sha256:ba0285628cb8f18c5d666e6727b213d3c7674d1782a7364c7b72ff906ad57eff +size 19684 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png index 6f440d71d6..26571d8a30 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08 -size 35976 +oid sha256:ed64e57bea072d5bdf232133c365db043f117e35ef180aa12c9b05bff40a1a92 +size 16437 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png index 964ad077b5..ff0295d9ac 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7 -size 31530 +oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb +size 37572 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png index 46226555db..6f440d71d6 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73 -size 19104 +oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08 +size 35976 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png index ceb1513af6..964ad077b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22 -size 19228 +oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7 +size 31530 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png new file mode 100644 index 0000000000..46226555db --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73 +size 19104 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png new file mode 100644 index 0000000000..ceb1513af6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22 +size 19228 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png index 6c424a1ffe..4e96ed69ce 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474 -size 36084 +oid sha256:4356f7de986f803b890d3bc95afdefd01e9eb42d775836c647a6d9fafe3bcc4a +size 19189 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png index 72196c0b11..cfa358b892 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed -size 34500 +oid sha256:0ce9e4f5911a6dfd35655f362d122f95690a651d7c05e5ef6c4ea0621be2e628 +size 15783 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png index da90a76ab1..6c424a1ffe 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a -size 30345 +oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474 +size 36084 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png index eed60f472d..72196c0b11 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7 -size 18715 +oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed +size 34500 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png index d3ee3b9e22..da90a76ab1 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b -size 18842 +oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a +size 30345 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png new file mode 100644 index 0000000000..eed60f472d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7 +size 18715 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png new file mode 100644 index 0000000000..d3ee3b9e22 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b +size 18842 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_0_en.png index 57b0b89912..c11e6b978e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 -size 144841 +oid sha256:3daba890afd2533e1746f1ffd0c535674a4a238e4591da2a6bcbe31716d26858 +size 143676 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png index 57b0b89912..bdb2a689ac 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 -size 144841 +oid sha256:82ed52f907048490ffb63ea9b33274703c1d92d4131cca0320816cc6949bea5e +size 113727 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png index 57b0b89912..5d9f6ab2ba 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 -size 144841 +oid sha256:ae3a754c163f83e69ab062c8e638a6e620948081d70105ddb00c32dda7c2ba74 +size 119927 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png new file mode 100644 index 0000000000..01d01e6806 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:757d555f948a637952aed4b0ad2745212f6a9ab279d172b0a1649fdf547c5a0c +size 120027 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_4_en.png new file mode 100644 index 0000000000..40384d68cf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f0cac0a30818ae7a2e1439d403654052bad931c8ba136bdaf37f16272a0b858 +size 20160 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_0_en.png index 74add72a23..b9280ec225 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 -size 58482 +oid sha256:463a2e544b2806f4082c813ec3ba8c6ce0ad0ebcd8ee32666806757a90c248f5 +size 57131 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png index 74add72a23..6962ed5f4d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 -size 58482 +oid sha256:fb0d86877e5d049d618836846cd8ea97b40d1d66e7bd8d50639280fb3e829372 +size 38499 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png index 74add72a23..7b0ba78a2c 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 -size 58482 +oid sha256:b41879f5159a126b0641c66bed13470756a914cc038dd239d40d359661784b71 +size 40696 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png new file mode 100644 index 0000000000..8ee52bca11 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38fa52e6e755d86aa79a029427f04146a3939ac2038cc15717f5aec84ce3be20 +size 40870 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_4_en.png new file mode 100644 index 0000000000..01b3bc1f3f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09893d3f2bfa2c1f5b99368c21d070ea1aa460d576722410fc2ca85c4eab238c +size 15361 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png index b3255ab5b5..72f4fa03b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 -size 374820 +oid sha256:4a0827eb23b48bd03fde5078598cb7827459a213ff0a693890581628330f6939 +size 66845 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_9_en.png index b3255ab5b5..adf8d255e3 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 -size 374820 +oid sha256:a8fa43d296a984d242ed56f7879de4c38f3da94147c365c424248eba2c4ad61d +size 371263 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png index fc5e6d8e98..3ad7246822 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c -size 153022 +oid sha256:bea070091132156a809da1c185e2f94333e62f8ea9ae7d6d1b789adffb869522 +size 55538 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_9_en.png index fc5e6d8e98..7b5512dcc7 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c -size 153022 +oid sha256:bcd9e0934b5f6808d58a3d3506641f173c4b223d78c605de09832d3741e8834f +size 149300 From 41f86b492cff64f082349543b6b5dffb1f666b4d Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 17 Apr 2026 16:58:06 +0200 Subject: [PATCH 17/18] Fix test after new property in LiveLocationShare --- .../matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt index 6c78fcc51b..41627396ad 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/TimedLiveLocationSharesFlowTest.kt @@ -141,6 +141,7 @@ private fun aLiveLocationShare( return LiveLocationShare( userId = UserId(userId), lastLocation = null, + startTimestamp = 0L, endTimestamp = endTimestamp, ) } From c35b5811d09920edf0eede9fddf3430f6696de28 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 20 Apr 2026 15:14:10 +0200 Subject: [PATCH 18/18] Restore comment on StaticMapView --- .../io/element/android/features/location/api/StaticMapView.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index 3ee0af35af..a61cbe1c24 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -58,6 +58,9 @@ fun StaticMapView( modifier: Modifier = Modifier, darkMode: Boolean = !ElementTheme.isLightTheme, ) { + // Using BoxWithConstraints to: + // 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints. + // 2) Request the static map image of the exact required size in Px to fill the AsyncImage. BoxWithConstraints( modifier = modifier, contentAlignment = Alignment.Center