Feature : share live location (#6741)

* First live location sharing sending implementation

* Simplify logic around canStop sharing

* Add some debug logs around LiveLocationSharingService

* Add LiveLocationException

* Expose beaconId to identify the current share

* Throttle live location instead of debouncing

* Keep sync alive when sharing live location

* Improve LiveLocation sharing

* Show LiveLocationDisclaimer

* Read minDistanceUpdate in LiveLocationSharingService

* Set minDistanceUpdate in AdvancedSettings

* Display banner in room when sharing live location

* Fix tests around LiveLocationSharing

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

* Ensure LLS data is cleared when session is removed

* Update and fix LLS tests

* Handle Start LLS in ui

* Add check LLS permissions

* Remove hardcoded strings

* Fix quality and format

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

* Update screenshots

* Fix warning

* Do not try to stop if it was not sharing

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

This reverts commit ba12bd968e82941cc231bdbb449310b24c97c5b8.

* Tweak location provider config values

* Address PR review remarks

* Fix ktlint

* Update screenshots

* Fix some tests after merging develop

* Adjust TimelineItemLocationView ui to match figma

* Update screenshots

* Documentation and cleanup

* Remove temporary resource

---------

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

View file

@ -54,6 +54,7 @@ import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
@ -151,6 +152,7 @@ class LoggedInFlowNode(
private val analyticsService: AnalyticsService,
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@ -211,6 +213,7 @@ class LoggedInFlowNode(
super.onBuilt()
lifecycleScope.launch {
sessionEnterpriseService.init()
activeLiveLocationShareManager.setup()
}
lifecycle.subscribe(
onCreate = {
@ -219,7 +222,6 @@ class LoggedInFlowNode(
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration()
sessionCoroutineScope.launch {
// Wait for the network to be connected before pre-fetching the max file upload size
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }

View file

@ -89,16 +89,14 @@ class SyncOrchestrator(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun observeStates() = coroutineScope.launch {
Timber.tag(tag).d("start observing the app and network state")
val isAppActiveFlow = combine(
val isAppActiveFlows = listOf(
appForegroundStateService.isInForeground,
appForegroundStateService.isInCall,
appForegroundStateService.isSyncingNotificationEvent,
appForegroundStateService.hasRingingCall,
) { isInForeground, isInCall, isSyncingNotificationEvent, hasRingingCall ->
isInForeground || isInCall || isSyncingNotificationEvent || hasRingingCall
}
appForegroundStateService.isSharingLiveLocation
)
val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
combine(
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
syncService.syncState.debounce(100.milliseconds),

View file

@ -71,6 +71,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose)
implementation(libs.datetime)
testCommonDependencies(libs)
}

View file

@ -0,0 +1,100 @@
/*
* 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.api
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
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
@Composable
fun LiveLocationSharingBanner(
onClick: () -> Unit,
onStopClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgCanvasDefault)
.drawBannerBorder(ElementTheme.colors.separatorPrimary)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = CompoundIcons.LocationPinSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconAccentPrimary,
modifier = Modifier.size(24.dp),
)
Text(
text = stringResource(CommonStrings.screen_room_live_location_banner),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
Button(
text = stringResource(CommonStrings.action_stop),
onClick = onStopClick,
destructive = true,
size = ButtonSize.Small,
)
}
}
private fun Modifier.drawBannerBorder(borderColor: Color): Modifier = drawBehind {
val strokeWidth = 1.dp.toPx()
val bottomY = size.height - strokeWidth / 2
drawLine(
color = borderColor,
start = Offset(0f, strokeWidth / 2),
end = Offset(size.width, strokeWidth / 2),
strokeWidth = strokeWidth,
)
drawLine(
color = borderColor,
start = Offset(0f, bottomY),
end = Offset(size.width, bottomY),
strokeWidth = strokeWidth,
)
}
@PreviewsDayNight
@Composable
internal fun LiveLocationSharingBannerPreview() = ElementPreview {
LiveLocationSharingBanner(
onClick = {},
onStopClick = {},
)
}

View file

@ -0,0 +1,43 @@
/*
* 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.api.live
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
interface ActiveLiveLocationShareManager {
/** All rooms currently sharing live location on this device. */
val sharingRoomIds: StateFlow<Set<RoomId>>
/**
* Initializes the manager.
* This will restart or stop current location sharing and set the listener on the SDK
* and the session manager.
*/
suspend fun setup()
/**
* Starts live location sharing in the given room.
* Calls room.startLiveLocationShare() on the SDK, registers the share,
* and starts the foreground GPS service if not already running.
*/
suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit>
/**
* Stops live location sharing in the given room.
* Calls room.stopLiveLocationShare() on the SDK, removes the share,
* and stops the foreground service if no shares remain.
*/
suspend fun stopShare(roomId: RoomId): Result<Unit>
}
fun ActiveLiveLocationShareManager.isCurrentlySharing(roomId: RoomId): StateFlow<Boolean> {
return sharingRoomIds.mapState { roomId in it }
}

View file

@ -37,10 +37,16 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.matrixui)
implementation(projects.services.analytics.api)
implementation(projects.services.appnavstate.api)
implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
implementation(libs.androidx.datastore.preferences)
implementation(libs.datetime)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
@ -50,4 +56,7 @@ dependencies {
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.features.location.test)
}

View file

@ -9,4 +9,14 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application>
<service
android:name=".live.service.LiveLocationSharingService"
android:foregroundServiceType="location"
android:exported="false" />
</application>
</manifest>

View file

@ -16,13 +16,16 @@ sealed interface LocationConstraintsCheck {
data object PermissionRationale : LocationConstraintsCheck
data object PermissionDenied : LocationConstraintsCheck
data object LocationServiceDisabled : LocationConstraintsCheck
data object NotEnoughPowerLevel : LocationConstraintsCheck
}
fun checkLocationConstraints(
permissionsState: PermissionsState,
locationActions: LocationActions,
sendLiveLocationPermissions: SendLiveLocationPermissions,
): LocationConstraintsCheck {
return when {
!sendLiveLocationPermissions.hasAll -> LocationConstraintsCheck.NotEnoughPowerLevel
permissionsState.isAnyGranted -> {
if (locationActions.isLocationEnabled()) {
LocationConstraintsCheck.Success
@ -41,5 +44,6 @@ fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
LocationConstraintsCheck.NotEnoughPowerLevel -> LocationConstraintsDialogState.NotEnoughPowerLevel
}
}

View file

@ -0,0 +1,34 @@
/*
* 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.common
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
/**
* Permissions to send beacon and beacon_info events in the room.
*/
data class SendLiveLocationPermissions(
val canSendBeacon: Boolean,
val canSendBeaconInfo: Boolean,
) {
val hasAll = canSendBeaconInfo && canSendBeacon
companion object {
val DEFAULT = SendLiveLocationPermissions(canSendBeacon = false, canSendBeaconInfo = false)
val GRANTED = SendLiveLocationPermissions(canSendBeacon = true, canSendBeaconInfo = true)
}
}
fun RoomPermissions.sendLiveLocationPermissions(): SendLiveLocationPermissions {
return SendLiveLocationPermissions(
canSendBeaconInfo = canOwnUserSendState(StateEventType.BeaconInfo),
canSendBeacon = canOwnUserSendMessage(MessageEventType.Beacon),
)
}

View file

@ -10,6 +10,8 @@ package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import io.element.android.features.location.impl.R
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@ -42,6 +44,10 @@ fun LocationConstraintsDialog(
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
)
LocationConstraintsDialogState.NotEnoughPowerLevel -> AlertDialog(
content = stringResource(R.string.screen_share_location_live_location_missing_permissions),
onDismiss = onDismiss
)
}
}
@ -51,4 +57,5 @@ sealed interface LocationConstraintsDialogState {
data object PermissionRationale : LocationConstraintsDialogState
data object PermissionDenied : LocationConstraintsDialogState
data object LocationServiceDisabled : LocationConstraintsDialogState
data object NotEnoughPowerLevel : LocationConstraintsDialogState
}

View file

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -44,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LocationShareRow(
item: LocationShareItem,
onShareClick: () -> Unit,
onStopClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
@ -101,11 +103,24 @@ fun LocationShareRow(
)
}
}
if (item.canStopSharing) {
IconButton(
onClick = onStopClick,
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgCriticalPrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
) {
Icon(
imageVector = CompoundIcons.Stop(),
contentDescription = stringResource(CommonStrings.action_stop),
)
}
}
IconButton(onClick = onShareClick) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share),
tint = ElementTheme.colors.iconPrimary,
)
}
}
@ -128,8 +143,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
formattedTimestamp = "Shared 1 min ago",
isLive = true,
assetType = AssetType.SENDER,
location = Location(0.0, 0.0)
location = Location(0.0, 0.0),
isOwnUser = true,
),
onStopClick = {},
onShareClick = {},
)
LocationShareRow(
@ -145,8 +162,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
isLive = false,
assetType = AssetType.PIN,
formattedTimestamp = "Shared 5 hours ago",
location = Location(0.0, 0.0)
location = Location(0.0, 0.0),
isOwnUser = false
),
onStopClick = {},
onShareClick = {},
)
}

View file

@ -23,7 +23,7 @@ import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.location.rememberAndroidLocationProvider
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.compose.location.rememberUserLocationState
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@Composable
fun UserLocationPuck(
@ -72,9 +72,9 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState
rememberNullLocationProvider()
} else {
rememberAndroidLocationProvider(
updateInterval = 1.minutes,
desiredAccuracy = DesiredAccuracy.Balanced,
minDistanceMeters = 50f,
updateInterval = 5.seconds,
desiredAccuracy = DesiredAccuracy.High,
minDistanceMeters = 5f,
)
}
return rememberUserLocationState(locationProvider)

View file

@ -0,0 +1,17 @@
/*
* 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.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.location.impl.live.service.LiveLocationSharingService
@ContributesTo(AppScope::class)
interface LocationBindings {
fun inject(service: LiveLocationSharingService)
}

View file

@ -0,0 +1,227 @@
/*
* 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.live
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.live.service.LiveLocationReceiver
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.BeaconId
import io.element.android.libraries.matrix.api.room.location.LiveLocationException
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration
import kotlin.time.Instant
@OptIn(ExperimentalAtomicApi::class)
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, binding = binding<ActiveLiveLocationShareManager>())
class DefaultActiveLiveLocationShareManager(
private val matrixClient: MatrixClient,
private val coordinator: LiveLocationSharingCoordinator,
private val liveLocationStore: LiveLocationStore,
private val clock: SystemClock,
private val sessionObserver: SessionObserver,
) : ActiveLiveLocationShareManager, LiveLocationReceiver {
private val isSetup = AtomicBoolean(false)
private val cachedRooms = ConcurrentHashMap<RoomId, JoinedRoom>()
private val timeoutJobs = ConcurrentHashMap<RoomId, Job>()
private val syncedActiveShareIds = MutableStateFlow<Set<BeaconId>>(emptySet())
private val localSharingRoomIds = MutableStateFlow<Set<RoomId>>(emptySet())
override val sharingRoomIds: StateFlow<Set<RoomId>> = localSharingRoomIds
override suspend fun setup() = withContext(NonCancellable) {
if (isSetup.compareAndSet(expectedValue = false, newValue = true)) {
Timber.d("ActiveLiveLocationShareManager setup manager.")
recoverPersistedShares()
matrixClient.ownBeaconInfoUpdates
.onEach { update ->
Timber.d("Received beaconInfoUpdate:$update")
// First cancel the local share in this room if any.
if (update.roomId in localSharingRoomIds.value) {
stopLocalShare(roomId = update.roomId)
}
syncedActiveShareIds.update {
if (update.isLive) {
it + update.beaconId
} else {
it - update.beaconId
}
}
}
.launchIn(matrixClient.sessionCoroutineScope)
sessionObserver.addListener(sessionListener)
}
}
private val sessionListener: SessionListener = object : SessionListener {
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
if (matrixClient.sessionId.value == userId) {
clear()
}
}
}
override suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> = withContext(NonCancellable) {
Timber.d("ActiveLiveLocationShareManager starting share for room $roomId with duration ${duration.inWholeSeconds}s")
val room = cachedRooms.getOrPut(roomId) {
matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
}
// Before starting a new location share, stop the current one if any is active.
room.stopLiveLocationShare()
room.startLiveLocationShare(duration.inWholeMilliseconds)
.onSuccess { beaconId ->
Timber.d("ActiveLiveLocationShareManager wait remote echo of $beaconId")
syncedActiveShareIds.first { beaconIds -> beaconIds.contains(beaconId) }
val expiresAt = Instant.fromEpochMilliseconds(clock.epochMillis() + duration.inWholeMilliseconds)
startLocalShare(roomId, expiresAt)
}
.onFailure {
Timber.e(it, "ActiveLiveLocationShareManager failed to start share for room $roomId")
stopLocalShare(roomId)
}
.map { }
}
override suspend fun stopShare(roomId: RoomId): Result<Unit> = withContext(NonCancellable) {
Timber.d("ActiveLiveLocationShareManager stopping share for room $roomId")
val room = cachedRooms.getOrPut(roomId) {
matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
}
room.stopLiveLocationShare()
.onSuccess {
Timber.d("ActiveLiveLocationShareManager share stopped successfully for room $roomId")
}
.onFailure {
Timber.e(it, "ActiveLiveLocationShareManager failed to stop share for room $roomId")
}
.also {
stopLocalShare(roomId)
}
}
override suspend fun onLocationUpdate(location: Location) {
val activeSharesCount = localSharingRoomIds.value.size
Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)")
localSharingRoomIds.value.forEach { roomId ->
Timber.d("ActiveLiveLocationShareManager sending location to room $roomId")
sendLiveLocation(roomId, location)
.onFailure {
Timber.e(it, "ActiveLiveLocationShareManager failed to send location to room $roomId")
}
}
}
private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result<Unit> {
val room = cachedRooms.getOrPut(roomId) {
matrixClient.getJoinedRoom(roomId) ?: return Result.failure(IllegalStateException("No room found for $roomId"))
}
return room.sendLiveLocation(location.toGeoUri())
.recoverCatching { exception ->
when (exception) {
is LiveLocationException.NotLive -> {
stopLocalShare(roomId)
throw exception
}
else -> throw exception
}
}
}
private suspend fun startLocalShare(roomId: RoomId, expiresAt: Instant) {
val wasEmpty = localSharingRoomIds.value.isEmpty()
Timber.d("ActiveLiveLocationShareManager share started successfully for room $roomId (wasEmpty=$wasEmpty)")
localSharingRoomIds.update { it + roomId }
liveLocationStore.setLiveLocationExpiry(roomId, expiresAt)
scheduleTimeout(roomId, expiresAt)
if (wasEmpty) {
Timber.d("ActiveLiveLocationShareManager registering with coordinator for session ${matrixClient.sessionId}")
coordinator.register(matrixClient.sessionId, this@DefaultActiveLiveLocationShareManager)
}
}
private suspend fun recoverPersistedShares() {
val now = Instant.fromEpochMilliseconds(clock.epochMillis())
liveLocationStore.getLiveLocationExpiries().forEach { (roomId, expiresAt) ->
if (expiresAt > now) {
// Only starts locally as the share is already started remotely
startLocalShare(roomId, expiresAt)
} else {
// Explicitly stop the share on the server.
stopShare(roomId)
}
}
}
private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) {
timeoutJobs.remove(roomId)?.cancel()
val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis()
timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch {
delay(delayMillis)
stopShare(roomId)
.onFailure { error ->
Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId")
}
}
}
private suspend fun stopLocalShare(roomId: RoomId) {
Timber.d("ActiveLiveLocationShareManager stop local share in $roomId")
timeoutJobs.remove(roomId)?.cancel()
val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty()
cachedRooms.remove(roomId)?.close()
liveLocationStore.removeLiveLocationExpiry(roomId)
if (wasSharing && localSharingRoomIds.value.isEmpty()) {
Timber.d("ActiveLiveLocationShareManager unregistering from coordinator for session ${matrixClient.sessionId}")
coordinator.unregister(matrixClient.sessionId)
}
}
private suspend fun clear() {
Timber.d("ActiveLiveLocationShareManager clear state")
sessionObserver.removeListener(sessionListener)
coordinator.unregister(matrixClient.sessionId)
liveLocationStore.clear()
for (room in cachedRooms.values) {
room.close()
timeoutJobs[room.roomId]?.cancel()
}
timeoutJobs.clear()
cachedRooms.clear()
localSharingRoomIds.value = emptySet()
syncedActiveShareIds.value = emptySet()
}
}

View file

@ -0,0 +1,94 @@
/*
* 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.live
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.first
import timber.log.Timber
import kotlin.time.Instant
private const val LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR = "="
@Inject
@SingleIn(SessionScope::class)
class LiveLocationStore(
preferenceDataStoreFactory: PreferenceDataStoreFactory,
sessionId: SessionId,
) {
private val store = preferenceDataStoreFactory.create("location_${sessionId.value.hash().take(16)}")
private val acceptedLiveLocationDisclaimerKey = booleanPreferencesKey("live_location_disclaimer_accepted")
private val liveLocationExpiriesKey = stringSetPreferencesKey("live_location_expiries")
suspend fun hasAcceptedLiveLocationDisclaimer(): Boolean = runCatchingExceptions {
store.data.first()[acceptedLiveLocationDisclaimerKey] ?: false
}.getOrDefault(false)
suspend fun setAcceptedLiveLocationDisclaimer(): Result<Unit> = runCatchingExceptions {
store.edit { prefs ->
prefs[acceptedLiveLocationDisclaimerKey] = true
}
}
suspend fun getLiveLocationExpiries(): Map<RoomId, Instant> = runCatchingExceptions {
val serialized = store.data.first()[liveLocationExpiriesKey].orEmpty()
decodeLiveLocationExpiries(serialized)
}.onFailure { error ->
Timber.e(error, "Failed to decode live location expiry payload")
}.getOrDefault(emptyMap())
suspend fun setLiveLocationExpiry(roomId: RoomId, expiresAt: Instant): Result<Unit> = runCatchingExceptions {
store.edit { prefs ->
val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(current + (roomId to expiresAt))
}
}
suspend fun removeLiveLocationExpiry(roomId: RoomId): Result<Unit> = runCatchingExceptions {
store.edit { prefs ->
val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
val updated = current - roomId
if (updated.isEmpty()) {
prefs.remove(liveLocationExpiriesKey)
} else {
prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(updated)
}
}
}
private fun decodeLiveLocationExpiries(serialized: Set<String>): Map<RoomId, Instant> {
return runCatchingExceptions {
serialized
.map { it.split(LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR) }
.associate { values ->
val roomId = RoomId(values[0])
val expiresAtMillis = values[1].toLong()
roomId to Instant.fromEpochMilliseconds(expiresAtMillis)
}
}.getOrDefault(emptyMap())
}
private fun encodeLiveLocationExpiries(expiries: Map<RoomId, Instant>): Set<String> {
return expiries.entries.map { (roomId, expiresAt) ->
"${roomId.value}$LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR${expiresAt.toEpochMilliseconds()}"
}.toSet()
}
suspend fun clear() {
store.edit { prefs -> prefs.clear() }
}
}

View file

@ -0,0 +1,61 @@
/*
* 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.live.notification
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.ui.strings.CommonStrings
@Inject
class LiveLocationSharingNotificationCreator(
@ApplicationContext private val context: Context,
private val buildMeta: BuildMeta,
) {
companion object {
const val CHANNEL_ID = "LIVE_LOCATION_SHARING"
}
fun createNotification(): Notification {
if (supportNotificationChannels()) {
ensureChannelExists()
}
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentTitle(context.getString(CommonStrings.live_location_sharing_foreground_service_title_android, buildMeta.applicationName))
.setContentText(context.getString(CommonStrings.live_location_sharing_foreground_service_message_android))
.setOngoing(true)
.build()
}
@RequiresApi(Build.VERSION_CODES.O)
private fun ensureChannelExists() {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(CommonStrings.live_location_sharing_foreground_service_channel_title_android)
.ifEmpty { "Live Location Sharing" },
NotificationManager.IMPORTANCE_LOW,
)
)
}
}
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
}

View file

@ -0,0 +1,14 @@
/*
* 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.live.service
import io.element.android.features.location.api.Location
fun interface LiveLocationReceiver {
suspend fun onLocationUpdate(location: Location)
}

View file

@ -0,0 +1,98 @@
/*
* 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.live.service
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.location.api.Location
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.services.toolbox.api.systemclock.SystemClock
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration.Companion.seconds
private val THROTTLE_WINDOW = 3.seconds
@OptIn(ExperimentalAtomicApi::class)
@SingleIn(AppScope::class)
class LiveLocationSharingCoordinator internal constructor(
private val startService: () -> Unit,
private val stopService: () -> Unit,
private val nowMillis: () -> Long,
) {
@Inject
constructor(@ApplicationContext context: Context, clock: SystemClock) : this(
startService = {
ContextCompat.startForegroundService(context, Intent(context, LiveLocationSharingService::class.java))
},
stopService = {
context.stopService(Intent(context, LiveLocationSharingService::class.java))
},
nowMillis = clock::epochMillis
)
private val receivers = ConcurrentHashMap<SessionId, LiveLocationReceiver>()
private val lastDispatchMillis = AtomicLong(0L)
private val lastKnownLocation = AtomicReference<Location?>(null)
suspend fun register(sessionId: SessionId, receiver: LiveLocationReceiver) {
val wasEmpty = receivers.isEmpty()
Timber.d("LiveLocationSharingCoordinator registering receiver for session $sessionId (wasEmpty=$wasEmpty)")
receivers[sessionId] = receiver
if (wasEmpty) {
Timber.d("LiveLocationSharingCoordinator starting service")
runCatchingExceptions(startService).onFailure {
Timber.e(it, "Failed to start live location sharing service")
}
}
lastKnownLocation.load()?.let {
dispatch(it)
}
}
fun unregister(sessionId: SessionId) {
Timber.d("LiveLocationSharingCoordinator unregistering receiver for session $sessionId")
receivers.remove(sessionId)
if (receivers.isEmpty()) {
lastKnownLocation.store(null)
Timber.d("LiveLocationSharingCoordinator stopping service (no more receivers)")
runCatchingExceptions(stopService).onFailure {
Timber.e(it, "Failed to stop live location sharing service")
}
}
}
suspend fun dispatch(location: Location) {
val currentTimeMillis = nowMillis()
val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load()
if (millisSincePrevious < THROTTLE_WINDOW.inWholeMilliseconds) {
Timber.d("Received location before $THROTTLE_WINDOW, ignore.")
return
}
lastKnownLocation.store(location)
lastDispatchMillis.store(currentTimeMillis)
receivers.forEach { (sessionId, receiver) ->
Timber.d("Dispatch received location for session $sessionId ")
runCatchingExceptions {
receiver.onLocationUpdate(location)
}.onFailure {
Timber.e(it, "Failed to dispatch live location update for session $sessionId")
}
}
}
}

View file

@ -0,0 +1,125 @@
/*
* 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.live.service
import android.annotation.SuppressLint
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
import android.os.IBinder
import androidx.core.app.ServiceCompat
import dev.zacsweers.metro.Inject
import io.element.android.features.location.impl.di.LocationBindings
import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import org.maplibre.compose.location.AndroidLocationProvider
import org.maplibre.compose.location.DesiredAccuracy
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
import io.element.android.features.location.api.Location as ApiLocation
private const val UPDATE_INTERVAL_IN_SECOND = 10
class LiveLocationSharingService : Service() {
@Inject lateinit var coordinator: LiveLocationSharingCoordinator
@Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var appForegroundStateService: AppForegroundStateService
@AppCoroutineScope
@Inject lateinit var appCoroutineScope: CoroutineScope
private lateinit var coroutineScope: CoroutineScope
override fun onBind(p0: Intent?): IBinder? = null
@OptIn(FlowPreview::class)
@SuppressLint("InlinedApi")
override fun onCreate() {
super.onCreate()
Timber.d("LiveLocationSharingService onCreate")
runCatchingExceptions {
bindings<LocationBindings>().inject(this)
appForegroundStateService.updateIsSharingLiveLocation(true)
coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService")
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION)
Timber.d("LiveLocationSharingService starting foreground service with notificationId=$notificationId")
ServiceCompat.startForeground(
// service =
this,
// id =
notificationId,
// notification =
notificationCreator.createNotification(),
// foregroundServiceType =
FOREGROUND_SERVICE_TYPE_LOCATION,
)
startLocationUpdatesListener()
}.onFailure {
Timber.e(it, "Failed to start live location sharing service")
stopSelf()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun startLocationUpdatesListener() {
Timber.d("LiveLocationSharingService listening to location updates")
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
.flatMapLatest { minDistanceMeters ->
val locationProvider = AndroidLocationProvider(
context = applicationContext,
updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
minDistanceMeters = minDistanceMeters.toFloat(),
desiredAccuracy = DesiredAccuracy.Balanced,
coroutineScope = coroutineScope
)
locationProvider.location
}
.filterNotNull()
.map { location ->
ApiLocation(
lat = location.position.latitude,
lon = location.position.longitude,
accuracy = location.accuracy.toFloat(),
)
}
.onEach(coordinator::dispatch)
.launchIn(coroutineScope)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Timber.d("LiveLocationSharingService onStartCommand startId=$startId")
return START_STICKY
}
override fun onDestroy() {
Timber.d("LiveLocationSharingService onDestroy")
if (::coroutineScope.isInitialized) {
coroutineScope.cancel()
}
appForegroundStateService.updateIsSharingLiveLocation(false)
super.onDestroy()
}
}

View file

@ -17,7 +17,8 @@ sealed interface ShareLocationEvent {
val isPinned: Boolean,
) : ShareLocationEvent
data object ShowLiveLocationDurationPicker : ShareLocationEvent
data object InitiateLiveLocationShare : ShareLocationEvent
data object AcceptLiveLocationDisclaimer : ShareLocationEvent
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
data object StartTrackingUserLocation : ShareLocationEvent

View file

@ -21,17 +21,22 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.SendLiveLocationPermissions
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.checkLocationConstraints
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.sendLiveLocationPermissions
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.share.ShareLocationState.Dialog.Constraints
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DurationFormatter
@ -41,6 +46,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
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.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
@ -63,6 +69,8 @@ class ShareLocationPresenter(
private val featureFlagService: FeatureFlagService,
private val client: MatrixClient,
private val durationFormatter: DurationFormatter,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore,
) : Presenter<ShareLocationState> {
@AssistedFactory
fun interface Factory {
@ -82,15 +90,39 @@ class ShareLocationPresenter(
var dialogState: ShareLocationState.Dialog by remember {
mutableStateOf(ShareLocationState.Dialog.None)
}
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState()
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
perms.sendLiveLocationPermissions()
}
val scope = rememberCoroutineScope()
fun checkLocationConstraints() {
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
dialogState = Constraints(locationConstraints.toDialogState())
// No need to check SendLiveLocationPermissions here
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
}
suspend fun computeLiveLocationDialogState(): ShareLocationState.Dialog {
val hasAcceptedDisclaimer = liveLocationStore.hasAcceptedLiveLocationDisclaimer()
val constraintsResult = checkLocationConstraints(permissionsState, locationActions, sendLiveLocationPermissions)
return when {
!hasAcceptedDisclaimer -> {
ShareLocationState.Dialog.LiveLocationDisclaimer
}
constraintsResult is LocationConstraintsCheck.Success -> {
val durations = LIVE_LOCATION_DURATIONS.map {
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
}
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
}
else -> {
ShareLocationState.Dialog.Constraints(constraintsResult.toDialogState())
}
}
}
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
fun handleEvent(event: ShareLocationEvent) {
@ -109,20 +141,23 @@ class ShareLocationPresenter(
locationActions.openLocationSettings()
dialogState = ShareLocationState.Dialog.None
}
ShareLocationEvent.ShowLiveLocationDurationPicker -> {
val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
dialogState = if (constraintsResult is LocationConstraintsCheck.Success) {
val durations = LIVE_LOCATION_DURATIONS.map {
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
ShareLocationEvent.InitiateLiveLocationShare -> scope.launch {
dialogState = computeLiveLocationDialogState()
}
ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
liveLocationStore.setAcceptedLiveLocationDisclaimer()
.onSuccess {
dialogState = computeLiveLocationDialogState()
}
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
} else {
Constraints(constraintsResult.toDialogState())
}
}
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
dialogState = ShareLocationState.Dialog.None
// room.startLiveLocationShare(event.duration.inWholeMilliseconds)
startLiveLocationAction.runUpdatingState {
liveLocationShareManager.startShare(
roomId = room.roomId,
duration = event.duration,
)
}
}
ShareLocationEvent.RequestPermissions -> {
dialogState = ShareLocationState.Dialog.None
@ -138,6 +173,7 @@ class ShareLocationPresenter(
hasLocationPermission = permissionsState.isAnyGranted,
canShareLiveLocation = isLiveLocationSharingEnabled,
appName = appName,
startLiveLocationAction = startLiveLocationAction.value,
eventSink = ::handleEvent,
)
}

View file

@ -9,6 +9,7 @@
package io.element.android.features.location.impl.share
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@ -19,11 +20,13 @@ data class ShareLocationState(
val hasLocationPermission: Boolean,
val appName: String,
val canShareLiveLocation: Boolean,
val startLiveLocationAction: AsyncAction<Unit>,
val eventSink: (ShareLocationEvent) -> Unit,
) {
sealed interface Dialog {
data object None : Dialog
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
data object LiveLocationDisclaimer : Dialog
data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : Dialog
}
}

View file

@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.persistentListOf
@ -51,6 +52,18 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
trackUserPosition = true,
hasLocationPermission = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
trackUserPosition = true,
hasLocationPermission = true,
canShareLiveLocation = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
trackUserPosition = true,
hasLocationPermission = true,
canShareLiveLocation = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
persistentListOf(
@ -73,6 +86,7 @@ fun aShareLocationState(
hasLocationPermission: Boolean = false,
canShareLiveLocation: Boolean = false,
appName: String = APP_NAME,
startLiveLocationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState {
return ShareLocationState(
@ -82,6 +96,7 @@ fun aShareLocationState(
hasLocationPermission = hasLocationPermission,
canShareLiveLocation = canShareLiveLocation,
appName = appName,
startLiveLocationAction = startLiveLocationAction,
eventSink = eventSink
)
}

View file

@ -29,7 +29,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -44,11 +43,16 @@ import io.element.android.features.location.impl.common.ui.LocationFloatingActio
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
import io.element.android.libraries.androidutils.system.toast
import io.element.android.features.location.impl.share.ShareLocationEvent.StartLiveLocationShare
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
@ -74,7 +78,6 @@ fun ShareLocationView(
navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
when (val dialogState = state.dialogState) {
ShareLocationState.Dialog.None -> Unit
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
@ -85,12 +88,17 @@ fun ShareLocationView(
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
)
ShareLocationState.Dialog.LiveLocationDisclaimer -> ConfirmationDialog(
content = stringResource(R.string.screen_share_location_live_location_disclaimer_title),
submitText = stringResource(CommonStrings.action_accept),
cancelText = stringResource(CommonStrings.action_decline),
onSubmitClick = { state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer) },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
)
is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
durations = dialogState.durations,
onSelectDuration = { duration ->
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))
context.toast("Not implemented yet!")
navigateUp()
state.eventSink(StartLiveLocationShare(duration))
},
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
)
@ -160,10 +168,46 @@ fun ShareLocationView(
.align(Alignment.TopEnd)
.padding(all = 16.dp),
)
StartLiveLocationActionView(state.startLiveLocationAction, navigateUp)
}
)
}
@Composable
private fun StartLiveLocationActionView(
action: AsyncAction<Unit>,
onActionSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(state = asyncIndicatorState)
when (action) {
is AsyncAction.Loading -> {
LaunchedEffect(action) {
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(CommonStrings.common_waiting_live_location))
}
}
}
is AsyncAction.Failure -> {
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_something_went_wrong),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { onActionSuccess() }
}
else -> Unit
}
}
}
@Composable
private fun BottomSheetContent(
cameraState: CameraState,
@ -202,7 +246,7 @@ private fun BottomSheetContent(
}
if (state.canShareLiveLocation) {
ShareLiveLocationItem {
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
}
}
}

View file

@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
data object RequestPermissions : ShowLocationEvent
data object OpenAppSettings : ShowLocationEvent
data object OpenLocationSettings : ShowLocationEvent
data object StopLocationSharing : ShowLocationEvent
}

View file

@ -15,14 +15,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.SendLiveLocationPermissions
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.checkLocationConstraints
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
@ -45,6 +48,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
@AssistedInject
class ShowLocationPresenter(
@ -55,6 +59,7 @@ class ShowLocationPresenter(
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
) : Presenter<ShowLocationState> {
@AssistedFactory
fun interface Factory {
@ -65,6 +70,7 @@ class ShowLocationPresenter(
@Composable
override fun present(): ShowLocationState {
val coroutineScope = rememberCoroutineScope()
val permissionsState: PermissionsState = permissionsPresenter.present()
var isTrackMyLocation by remember { mutableStateOf(false) }
val appName by remember { derivedStateOf { buildMeta.applicationName } }
@ -85,7 +91,7 @@ class ShowLocationPresenter(
}
is ShowLocationEvent.TrackMyLocation -> {
if (event.enabled) {
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
dialogState = locationConstraints.toDialogState()
} else {
@ -102,6 +108,9 @@ class ShowLocationPresenter(
dialogState = LocationConstraintsDialogState.None
}
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
ShowLocationEvent.StopLocationSharing -> coroutineScope.launch {
liveLocationShareManager.stopShare(joinedRoom.roomId)
}
}
}
@ -127,6 +136,7 @@ class ShowLocationPresenter(
location = mode.location,
isLive = false,
assetType = mode.assetType,
isOwnUser = mode.senderId == joinedRoom.sessionId
)
)
}
@ -163,6 +173,7 @@ class ShowLocationPresenter(
location = location,
isLive = true,
assetType = lastLocation.assetType,
isOwnUser = share.userId == joinedRoom.sessionId
)
}
.toImmutableList()

View file

@ -38,7 +38,10 @@ data class LocationShareItem(
val location: Location,
val isLive: Boolean,
val assetType: AssetType?,
)
val isOwnUser: Boolean
) {
val canStopSharing = isLive && isOwnUser
}
fun LocationShareItem.toMarkerData(): LocationMarkerData {
val pinVariant = if (assetType == AssetType.PIN) {

View file

@ -81,6 +81,7 @@ fun aLocationShareItem(
assetType: AssetType? = null,
formattedTimestamp: String = "Shared 1 min ago",
location: Location = Location(1.23, 2.34, 4f),
isOwnUser: Boolean = false,
) = LocationShareItem(
userId = userId,
displayName = displayName,
@ -89,4 +90,5 @@ fun aLocationShareItem(
location = location,
isLive = isLive,
assetType = assetType,
isOwnUser = isOwnUser,
)

View file

@ -147,6 +147,7 @@ fun ShowLocationView(
LocationShareRow(
item = locationShare,
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) },
modifier = Modifier.clickable {
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
val position = CameraPosition(

View file

@ -21,7 +21,7 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
}
@ -33,7 +33,7 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
}
@ -45,7 +45,7 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = false)
val result = checkLocationConstraints(permissionsState, locationActions)
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
}
@ -58,7 +58,7 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
}
@ -71,8 +71,20 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
}
@Test
fun `checkLocationConstraints returns NotEnoughPowerLevel when send permissions are not granted`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.DEFAULT)
assertThat(result).isEqualTo(LocationConstraintsCheck.NotEnoughPowerLevel)
}
}

View file

@ -0,0 +1,488 @@
/*
* 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.live
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultActiveLiveLocationShareManagerTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `starting the first share starts the coordinator service after the beacon echo and adds an active share`() = runTest {
val startServiceRecorder = lambdaRecorder<Unit> { }
val stopServiceRecorder = lambdaRecorder<Unit> { }
val coordinator = createCoordinator(
startService = startServiceRecorder,
stopService = stopServiceRecorder
)
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val room = FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = { Result.success(Unit) },
)
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdates,
).apply { givenGetRoomResult(A_ROOM_ID, room) },
coordinator = coordinator,
clock = FakeSystemClock(epochMillisResult = 123L),
)
advanceUntilIdle()
val result = async { manager.startShare(A_ROOM_ID, 60.minutes) }
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(result.await().isSuccess).isTrue()
assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID)
assert(startServiceRecorder).isCalledOnce()
assert(stopServiceRecorder).isNeverCalled()
}
@Test
fun `stopping the last share stops the coordinator service`() = runTest {
val startServiceRecorder = lambdaRecorder<Unit> { }
val stopServiceRecorder = lambdaRecorder<Unit> { }
val coordinator = createCoordinator(
startService = startServiceRecorder,
stopService = stopServiceRecorder
)
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val room = FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = { Result.success(Unit) },
)
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdates,
).apply { givenGetRoomResult(A_ROOM_ID, room) },
coordinator = coordinator,
)
advanceUntilIdle()
val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) }
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(startResult.await().isSuccess).isTrue()
val result = manager.stopShare(A_ROOM_ID)
assertThat(result.isSuccess).isTrue()
assertThat(manager.sharingRoomIds.value).isEmpty()
assert(startServiceRecorder).isCalledOnce()
assert(stopServiceRecorder).isCalledOnce()
}
@Test
fun `two managers with the same room id keep isolated state per session`() = runTest {
val coordinator = createCoordinator()
val beaconInfoUpdatesOne = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val beaconInfoUpdatesTwo = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val managerOne = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdatesOne,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = { Result.success(Unit) },
),
)
},
coordinator = coordinator,
)
val managerTwo = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID_2,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdatesTwo,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = { Result.success(Unit) },
),
)
},
coordinator = coordinator,
)
advanceUntilIdle()
val startResult = async { managerOne.startShare(A_ROOM_ID, 15.minutes) }
beaconInfoUpdatesOne.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(startResult.await().isSuccess).isTrue()
assertThat(managerOne.sharingRoomIds.value).containsExactly(A_ROOM_ID)
assertThat(managerTwo.sharingRoomIds.value).isEmpty()
}
@Test
fun `start share persists room expiry after beacon echo`() = runTest {
val liveLocationStore = createLiveLocationStore()
val coordinator = createCoordinator()
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdates,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = { Result.success(Unit) },
),
)
},
coordinator = coordinator,
liveLocationStore = liveLocationStore,
clock = FakeSystemClock(epochMillisResult = 123L),
)
advanceUntilIdle()
val result = async { manager.startShare(A_ROOM_ID, 15.minutes) }
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(result.await().isSuccess).isTrue()
assertThat(liveLocationStore.getLiveLocationExpiries()).containsKey(A_ROOM_ID)
}
@Test
fun `stop share removes persisted expiry`() = runTest {
val liveLocationStore = createLiveLocationStore()
val coordinator = createCoordinator()
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdates,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = { Result.success(Unit) },
),
)
},
coordinator = coordinator,
liveLocationStore = liveLocationStore,
)
advanceUntilIdle()
val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) }
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(startResult.await().isSuccess).isTrue()
manager.stopShare(A_ROOM_ID)
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
}
@Test
fun `setup restores unexpired stored share and registers coordinator`() = runTest {
val startServiceRecorder = lambdaRecorder<Unit> { }
val stopServiceRecorder = lambdaRecorder<Unit> { }
val liveLocationStore = createLiveLocationStore().apply {
setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L))
}
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
).apply {
givenGetRoomResult(A_ROOM_ID, FakeJoinedRoom())
},
coordinator = createCoordinator(
startService = startServiceRecorder,
stopService = stopServiceRecorder,
),
liveLocationStore = liveLocationStore,
clock = FakeSystemClock(epochMillisResult = 1_000L),
)
assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID)
assert(startServiceRecorder).isCalledOnce()
assert(stopServiceRecorder).isNeverCalled()
}
@Test
fun `setup remotely stops expired stored share and removes it from store`() = runTest {
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val liveLocationStore = createLiveLocationStore().apply {
setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L))
}
createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult),
)
},
coordinator = createCoordinator(),
liveLocationStore = liveLocationStore,
clock = FakeSystemClock(epochMillisResult = 5_000L),
)
advanceUntilIdle()
assert(stopLiveLocationShareResult).isCalledOnce()
assertThat(liveLocationStore.getLiveLocationExpiries()).isEmpty()
}
@Test
fun `stop share closes loaded room and removes persisted expiry when room is not tracked`() = runTest {
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult)
val liveLocationStore = createInMemoryLiveLocationStore()
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
).apply {
givenGetRoomResult(A_ROOM_ID, room)
},
coordinator = createCoordinator(),
liveLocationStore = liveLocationStore,
)
liveLocationStore.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L))
val result = manager.stopShare(A_ROOM_ID)
assertThat(result.isSuccess).isTrue()
assert(stopLiveLocationShareResult).isCalledOnce()
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
room.baseRoom.assertDestroyed()
}
@Test
fun `share is automatically stopped when timeout elapses`() = runTest {
val liveLocationStore = createInMemoryLiveLocationStore()
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdates,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = stopLiveLocationShareResult
),
)
},
coordinator = createCoordinator(),
liveLocationStore = liveLocationStore,
clock = FakeSystemClock(epochMillisResult = 123L),
)
advanceUntilIdle()
val startResult = async { manager.startShare(A_ROOM_ID, 1.minutes) }
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(startResult.await().isSuccess).isTrue()
manager.sharingRoomIds.test {
assertThat(awaitItem()).containsExactly(A_ROOM_ID)
assertThat(awaitItem()).isEmpty()
advanceUntilIdle()
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
assert(stopLiveLocationShareResult).isCalledExactly(2)
}
}
@Test
fun `restored share is automatically stopped when remaining timeout elapses`() = runTest {
val liveLocationStore = createInMemoryLiveLocationStore().apply {
setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(6_000L))
}
val stopLiveLocationShareLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(
stopLiveLocationShareResult = stopLiveLocationShareLambda
),
)
},
coordinator = createCoordinator(),
liveLocationStore = liveLocationStore,
clock = FakeSystemClock(epochMillisResult = 1_000L),
)
manager.sharingRoomIds.test {
assertThat(awaitItem()).containsExactly(A_ROOM_ID)
assertThat(awaitItem()).isEmpty()
advanceUntilIdle()
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
assert(stopLiveLocationShareLambda).isCalledOnce()
}
}
@Test
fun `session deleted clears local state`() = runTest {
val startServiceRecorder = lambdaRecorder<Unit> { }
val stopServiceRecorder = lambdaRecorder<Unit> { }
val liveLocationStore = createInMemoryLiveLocationStore()
val sessionObserver = FakeSessionObserver()
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdates,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = { Result.success(Unit) },
),
)
},
coordinator = createCoordinator(
startService = startServiceRecorder,
stopService = stopServiceRecorder,
),
liveLocationStore = liveLocationStore,
sessionObserver = sessionObserver,
)
advanceUntilIdle()
val firstStart = async { manager.startShare(A_ROOM_ID, 15.minutes) }
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(firstStart.await().isSuccess).isTrue()
sessionObserver.onSessionDeleted(A_SESSION_ID.value)
advanceUntilIdle()
assertThat(manager.sharingRoomIds.value).isEmpty()
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
assert(startServiceRecorder).isCalledOnce()
assert(stopServiceRecorder).isCalledOnce()
val secondStart = async { manager.startShare(A_ROOM_ID, 15.minutes) }
advanceUntilIdle()
assertThat(secondStart.isCompleted).isFalse()
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(secondStart.await().isSuccess).isTrue()
}
private suspend fun createManager(
client: FakeMatrixClient = FakeMatrixClient(sessionId = A_SESSION_ID),
coordinator: LiveLocationSharingCoordinator = createCoordinator(),
liveLocationStore: LiveLocationStore = createLiveLocationStore(),
clock: SystemClock = FakeSystemClock(),
sessionObserver: SessionObserver = FakeSessionObserver(),
): DefaultActiveLiveLocationShareManager {
return DefaultActiveLiveLocationShareManager(
matrixClient = client,
coordinator = coordinator,
liveLocationStore = liveLocationStore,
clock = clock,
sessionObserver = sessionObserver,
).apply {
setup()
}
}
private fun createCoordinator(
startService: () -> Unit = {},
stopService: () -> Unit = {},
nowMillis: () -> Long = { 0L },
): LiveLocationSharingCoordinator {
return LiveLocationSharingCoordinator(
startService = startService,
stopService = stopService,
nowMillis = nowMillis,
)
}
private fun createLiveLocationStore(
sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID,
preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
): LiveLocationStore {
return LiveLocationStore(
preferenceDataStoreFactory = preferenceDataStoreFactory,
sessionId = sessionId,
)
}
private fun createInMemoryLiveLocationStore(
sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID,
): LiveLocationStore {
val preferenceDataStoreFactory = object : PreferenceDataStoreFactory {
override fun create(name: String): DataStore<Preferences> {
var preferences: Preferences = emptyPreferences()
return object : DataStore<Preferences> {
override val data: Flow<Preferences>
get() = flowOf(preferences)
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
preferences = transform(preferences)
return preferences
}
}
}
}
return createLiveLocationStore(
sessionId = sessionId,
preferenceDataStoreFactory = preferenceDataStoreFactory,
)
}
}

View file

@ -0,0 +1,115 @@
/*
* 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.live
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.live.service.LiveLocationReceiver
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LiveLocationSharingCoordinatorTest {
@Test
fun `first registration starts the service and last unregister stops it`() = runTest {
var startCount = 0
var stopCount = 0
val coordinator = LiveLocationSharingCoordinator(
startService = { startCount++ },
stopService = { stopCount++ },
nowMillis = { 0L },
)
coordinator.register(A_SESSION_ID, LiveLocationReceiver { })
coordinator.unregister(A_SESSION_ID)
assertThat(startCount).isEqualTo(1)
assertThat(stopCount).isEqualTo(1)
}
@Test
fun `dispatch isolates receiver failures and still reaches later receivers`() = runTest {
val delivered = mutableListOf<Location>()
val coordinator = LiveLocationSharingCoordinator(
startService = { },
stopService = { },
nowMillis = { 4_000L },
)
coordinator.register(A_SESSION_ID) { error("boom") }
coordinator.register(A_SESSION_ID_2) { location -> delivered += location }
coordinator.dispatch(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
assertThat(delivered).containsExactly(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
}
@Test
fun `dispatch delivers first location immediately`() = runTest {
var nowMillis = 4_000L
val delivered = mutableListOf<Location>()
val coordinator = LiveLocationSharingCoordinator(
startService = { },
stopService = { },
nowMillis = { nowMillis },
)
coordinator.register(A_SESSION_ID) { location -> delivered += location }
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
coordinator.dispatch(firstLocation)
assertThat(delivered).containsExactly(firstLocation)
}
@Test
fun `dispatch drops updates inside the throttle window`() = runTest {
var nowMillis = 4_000L
val delivered = mutableListOf<Location>()
val coordinator = LiveLocationSharingCoordinator(
startService = { },
stopService = { },
nowMillis = { nowMillis },
)
coordinator.register(A_SESSION_ID) { location -> delivered += location }
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
coordinator.dispatch(firstLocation)
nowMillis += 500
coordinator.dispatch(secondLocation)
assertThat(delivered).containsExactly(firstLocation)
}
@Test
fun `dispatch delivers next update after the throttle window elapses`() = runTest {
var nowMillis = 4_000L
val delivered = mutableListOf<Location>()
val coordinator = LiveLocationSharingCoordinator(
startService = { },
stopService = { },
nowMillis = { nowMillis },
)
coordinator.register(A_SESSION_ID) { location -> delivered += location }
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
coordinator.dispatch(firstLocation)
nowMillis += 3_000
coordinator.dispatch(secondLocation)
assertThat(delivered).containsExactly(firstLocation, secondLocation).inOrder()
}
}

View file

@ -13,6 +13,8 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -20,8 +22,10 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -30,16 +34,17 @@ class DefaultShareLocationEntryPointTest {
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
fun `test node builder`() = runTest {
val entryPoint = DefaultShareLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
val room = FakeJoinedRoom()
ShareLocationNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { timelineMode: Timeline.Mode ->
ShareLocationPresenter(
permissionsPresenterFactory = { FakePermissionsPresenter() },
room = FakeJoinedRoom(),
room = room,
timelineMode = timelineMode,
analyticsService = FakeAnalyticsService(),
messageComposerContext = FakeMessageComposerContext(),
@ -48,6 +53,11 @@ class DefaultShareLocationEntryPointTest {
featureFlagService = FakeFeatureFlagService(),
client = FakeMatrixClient(),
durationFormatter = FakeDurationFormatter(),
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
liveLocationStore = LiveLocationStore(
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
sessionId = room.sessionId,
),
)
},
analyticsService = FakeAnalyticsService(),

View file

@ -10,6 +10,9 @@
package io.element.android.features.location.impl.share
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
@ -22,28 +25,46 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
class ShareLocationPresenterTest {
@get:Rule
@ -59,9 +80,11 @@ class ShareLocationPresenterTest {
private val durationFormatter = FakeDurationFormatter()
private fun createShareLocationPresenter(
private fun TestScope.createShareLocationPresenter(
joinedRoom: JoinedRoom = FakeJoinedRoom(),
locationActions: FakeLocationActions = fakeLocationActions,
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
@ -73,6 +96,8 @@ class ShareLocationPresenterTest {
featureFlagService = fakeFeatureFlagService,
client = fakeMatrixClient,
durationFormatter = durationFormatter,
liveLocationShareManager = liveLocationShareManager,
liveLocationStore = liveLocationStore,
)
@Test
@ -296,7 +321,15 @@ class ShareLocationPresenterTest {
@Test
fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = grantedSendLiveLocationPermissions()
)
)
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
setAcceptedLiveLocationDisclaimer().getOrThrow()
}
val shareLocationPresenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
@ -307,7 +340,7 @@ class ShareLocationPresenterTest {
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
initialState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val durationDialogState = awaitItem()
assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
@ -315,9 +348,155 @@ class ShareLocationPresenterTest {
}
}
@Test
fun `ShowLiveLocationDurationPicker shows disclaimer when acceptance is missing`() = runTest {
val presenter = createShareLocationPresenter()
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
presenter.test {
skipItems(1)
val state = awaitItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val dialogState = awaitItem()
assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer)
}
}
@Test
fun `AcceptLiveLocationDisclaimer persists acceptance and shows durations`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = grantedSendLiveLocationPermissions()
)
)
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId)
val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
presenter.test {
skipItems(1)
val state = awaitItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
awaitItem()
state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer)
val durationState = awaitItem()
assertThat(locationStore.hasAcceptedLiveLocationDisclaimer()).isTrue()
assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
}
}
@Test
fun `AcceptLiveLocationDisclaimer keeps disclaimer gate active when persistence fails`() = runTest {
val joinedRoom = FakeJoinedRoom()
val presenter = createShareLocationPresenter(
joinedRoom = joinedRoom,
liveLocationStore = createFailingLiveLocationStore(sessionId = joinedRoom.sessionId),
)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
presenter.test {
skipItems(1)
val state = awaitItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val disclaimerState = awaitItem()
disclaimerState.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer)
advanceUntilIdle()
expectNoEvents()
}
}
@Test
fun `ShowLiveLocationDurationPicker bypasses disclaimer when already accepted`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = grantedSendLiveLocationPermissions()
)
)
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
setAcceptedLiveLocationDisclaimer().getOrThrow()
}
val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
presenter.test {
skipItems(1)
val state = awaitItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val durationState = awaitItem()
assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
}
}
@Test
fun `ShowLiveLocationDurationPicker uses the active session disclaimer state`() = runTest {
val joinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = SessionId("@alice:server")))
createLiveLocationStore(sessionId = SessionId("@bob:server"))
.setAcceptedLiveLocationDisclaimer()
.getOrThrow()
val presenter = createShareLocationPresenter(
joinedRoom = joinedRoom,
liveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
presenter.test {
skipItems(1)
val state = awaitItem()
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val dialogState = awaitItem()
assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer)
}
}
@Test
fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = grantedSendLiveLocationPermissions()
)
)
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
setAcceptedLiveLocationDisclaimer().getOrThrow()
}
val shareLocationPresenter = createShareLocationPresenter(
joinedRoom = joinedRoom,
liveLocationStore = locationStore,
)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@ -332,7 +511,7 @@ class ShareLocationPresenterTest {
initialState.eventSink(ShareLocationEvent.DismissDialog)
val dismissedState = awaitItem()
dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
dismissedState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val constraintDialogState = awaitItem()
assertThat(constraintDialogState.dialogState).isEqualTo(
@ -447,4 +626,62 @@ class ShareLocationPresenterTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `StartLiveLocationShare event calls manager startShare`() = runTest {
val startShareLambda = lambdaRecorder { _: RoomId, _: Duration -> Result.success(Unit) }
val manager = FakeActiveLiveLocationShareManager(
startShareLambda = startShareLambda,
)
val shareLocationPresenter = createShareLocationPresenter(liveLocationShareManager = manager)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration = 1.hours))
advanceUntilIdle()
assert(startShareLambda).isCalledOnce().with(
value(A_ROOM_ID),
value(1.hours)
)
cancelAndIgnoreRemainingEvents()
}
}
}
private fun createLiveLocationStore(
sessionId: SessionId = A_SESSION_ID,
preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
): LiveLocationStore {
return LiveLocationStore(
preferenceDataStoreFactory = preferenceDataStoreFactory,
sessionId = sessionId,
)
}
private fun createFailingLiveLocationStore(sessionId: SessionId = A_SESSION_ID): LiveLocationStore {
val failingPreferenceDataStoreFactory = object : PreferenceDataStoreFactory {
override fun create(name: String): DataStore<Preferences> = object : DataStore<Preferences> {
override val data: Flow<Preferences> = flowOf(emptyPreferences())
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
error("Failed to update preferences")
}
}
}
return createLiveLocationStore(
sessionId = sessionId,
preferenceDataStoreFactory = failingPreferenceDataStoreFactory,
)
}
private fun grantedSendLiveLocationPermissions(): FakeRoomPermissions = FakeRoomPermissions(
canSendState = { it is StateEventType.BeaconInfo },
canSendMessage = { it is MessageEventType.Beacon }
)

View file

@ -143,6 +143,38 @@ class ShareLocationViewTest {
clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
@Test
fun `when disclaimer is displayed user can accept`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
eventSink = eventsRecorder,
canShareLiveLocation = true,
),
navigateUp = EnsureNeverCalled(),
)
clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(ShareLocationEvent.AcceptLiveLocationDisclaimer)
}
@Test
fun `when disclaimer is displayed user can decline`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
eventSink = eventsRecorder,
canShareLiveLocation = true,
),
navigateUp = EnsureNeverCalled(),
)
clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setShareLocationView(

View file

@ -16,6 +16,7 @@ import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
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
@ -34,6 +35,7 @@ class DefaultShowLocationEntryPointTest {
fun `test node builder`() {
val entryPoint = DefaultShowLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
val joinedRoom = FakeJoinedRoom()
ShowLocationNode(
buildContext = buildContext,
plugins = plugins,
@ -45,7 +47,8 @@ class DefaultShowLocationEntryPointTest {
buildMeta = aBuildMeta(),
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
joinedRoom = FakeJoinedRoom(),
joinedRoom = joinedRoom,
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
)
},
analyticsService = FakeAnalyticsService(),

View file

@ -9,7 +9,7 @@ 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 io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import org.junit.Test
class LiveLocationShareComparatorTest {
@ -55,15 +55,3 @@ class LiveLocationShareComparatorTest {
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,
)
}

View file

@ -20,14 +20,15 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
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.location.aLiveLocationShare
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
@ -60,6 +61,7 @@ class ShowLocationPresenterTest {
),
locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
@ -68,6 +70,7 @@ class ShowLocationPresenterTest {
dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
liveLocationShareManager = liveLocationShareManager,
)
@Test
@ -205,7 +208,7 @@ class ShowLocationPresenterTest {
)
)
val presenter = createShowLocationPresenter()
presenter.test {
presenter.test {
// Skip initial state
val initialState = awaitItem()
@ -464,23 +467,3 @@ class ShowLocationPresenterTest {
}
}
}
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 {
return LiveLocationShare(
userId = userId,
lastLocation = LastLocation(
geoUri = geoUri,
timestamp = timestamp,
assetType = assetType,
),
startTimestamp = startTimestamp,
endTimestamp = endTimestamp,
)
}

View file

@ -0,0 +1,129 @@
/*
* 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.store
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Instant
class LiveLocationStoreTest {
private val preferenceDataStoreFactory = FakePreferenceDataStoreFactory()
@Test
fun `disclaimer defaults to false`() = runTest {
val store = LiveLocationStore(
preferenceDataStoreFactory = preferenceDataStoreFactory,
sessionId = A_SESSION_ID,
)
assertThat(store.hasAcceptedLiveLocationDisclaimer()).isFalse()
}
@Test
fun `disclaimer acceptance is isolated per session`() = runTest {
val firstStore = LiveLocationStore(
preferenceDataStoreFactory = preferenceDataStoreFactory,
sessionId = A_SESSION_ID,
)
val secondStore = LiveLocationStore(
preferenceDataStoreFactory = preferenceDataStoreFactory,
sessionId = SessionId("@other:server"),
)
firstStore.setAcceptedLiveLocationDisclaimer().getOrThrow()
assertThat(firstStore.hasAcceptedLiveLocationDisclaimer()).isTrue()
assertThat(secondStore.hasAcceptedLiveLocationDisclaimer()).isFalse()
}
@Test
fun `can persist and read expiry per room`() = runTest {
val store = LiveLocationStore(
preferenceDataStoreFactory = preferenceDataStoreFactory,
sessionId = A_SESSION_ID,
)
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
assertThat(store.getLiveLocationExpiries())
.containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L))
}
@Test
fun `removing one expiry leaves others untouched`() = runTest {
val otherRoomId = RoomId("!other:server")
val store = LiveLocationStore(
preferenceDataStoreFactory = preferenceDataStoreFactory,
sessionId = A_SESSION_ID,
)
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
store.setLiveLocationExpiry(otherRoomId, Instant.fromEpochMilliseconds(2_000L)).getOrThrow()
store.removeLiveLocationExpiry(A_ROOM_ID).getOrThrow()
assertThat(store.getLiveLocationExpiries())
.containsExactly(otherRoomId, Instant.fromEpochMilliseconds(2_000L))
}
@Test
fun `setting expiry twice replaces the existing room value`() = runTest {
val store = LiveLocationStore(
preferenceDataStoreFactory = preferenceDataStoreFactory,
sessionId = A_SESSION_ID,
)
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L)).getOrThrow()
assertThat(store.getLiveLocationExpiries())
.containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L))
}
@Test
fun `malformed expiry payload returns empty map`() = runTest {
val store = LiveLocationStore(
preferenceDataStoreFactory = createMalformedExpiryPreferenceDataStoreFactory(),
sessionId = A_SESSION_ID,
)
assertThat(store.getLiveLocationExpiries()).isEmpty()
}
private fun createMalformedExpiryPreferenceDataStoreFactory(): PreferenceDataStoreFactory {
return object : PreferenceDataStoreFactory {
override fun create(name: String): DataStore<Preferences> {
var preferences: Preferences = mutablePreferencesOf(
stringPreferencesKey("live_location_expiries") to "not valid"
)
return object : DataStore<Preferences> {
override val data: Flow<Preferences>
get() = flowOf(preferences)
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
preferences = transform(preferences)
return preferences
}
}
}
}
}
}

View file

@ -0,0 +1,46 @@
/*
* 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.test
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlin.time.Duration
class FakeActiveLiveLocationShareManager(
val setupLambda: () -> Unit = { lambdaError() },
val startShareLambda: (roomId: RoomId, duration: Duration) -> Result<Unit> = { _, _ -> lambdaError() },
val stopShareLambda: (roomId: RoomId) -> Result<Unit> = { _ -> lambdaError() },
) : ActiveLiveLocationShareManager {
private val _sharingRoomIds = MutableStateFlow(emptySet<RoomId>())
override val sharingRoomIds: StateFlow<Set<RoomId>> = _sharingRoomIds
override suspend fun setup() {
setupLambda()
}
override suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> = simulateLongTask {
startShareLambda(roomId, duration).onSuccess {
_sharingRoomIds.update {
it + roomId
}
}
}
override suspend fun stopShare(roomId: RoomId): Result<Unit> = simulateLongTask {
stopShareLambda(roomId).onSuccess {
_sharingRoomIds.update {
it - roomId
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,4 +20,5 @@ sealed interface AdvancedSettingsEvents {
data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents
data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents
data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents
data class SetLiveLocationMinimumDistanceUpdate(val value: Int) : AdvancedSettingsEvents
}

View file

@ -10,12 +10,14 @@ package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@ -28,10 +30,12 @@ class AdvancedSettingsNode(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
AdvancedSettingsView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp
onBackClick = ::navigateUp,
onOpenAppSettingsClick = context::openAppSettingsPage
)
}
}

View file

@ -25,8 +25,11 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
@Inject
@ -53,6 +56,19 @@ class AdvancedSettingsPresenter(
appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed)
}.collectAsState(initial = Theme.System)
@OptIn(ExperimentalCoroutinesApi::class)
val liveLocationMinimumDistanceUpdate by produceState<Int?>(null) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
.flatMapLatest { isEnabled ->
if (isEnabled) {
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
} else {
emptyFlow()
}
}
.collect { value = it }
}
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
val themeOption by remember {
@ -117,6 +133,9 @@ class AdvancedSettingsPresenter(
}
is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
is AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate -> sessionCoroutineScope.launch {
appPreferencesStore.setLiveLocationMinimumDistanceInMetersUpdate(event.value)
}
is AdvancedSettingsEvents.SetCompressImages -> sessionCoroutineScope.launch {
sessionPreferencesStore.setOptimizeImages(event.compress)
}
@ -133,6 +152,7 @@ class AdvancedSettingsPresenter(
theme = themeOption,
availableThemeOptions = availableThemeOptions,
mediaPreviewConfigState = mediaPreviewConfigState,
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
eventSink = ::handleEvent,
)
}

View file

@ -23,6 +23,7 @@ data class AdvancedSettingsState(
val theme: ThemeOption,
val availableThemeOptions: ImmutableList<ThemeOption>,
val mediaPreviewConfigState: MediaPreviewConfigState,
val liveLocationMinimumDistanceUpdate: Int?,
val eventSink: (AdvancedSettingsEvents) -> Unit
)

View file

@ -41,6 +41,7 @@ fun aAdvancedSettingsState(
availableThemeOptions: ImmutableList<ThemeOption> = ThemeOption.entries.toImmutableList(),
hideInviteAvatars: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
liveLocationMinimumDistanceUpdate: Int? = 50,
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (AdvancedSettingsEvents) -> Unit = {},
@ -56,5 +57,6 @@ fun aAdvancedSettingsState(
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
setHideInviteAvatarsAction = setHideInviteAvatarsAction
),
liveLocationMinimumDistanceUpdate = liveLocationMinimumDistanceUpdate,
eventSink = eventSink
)

View file

@ -8,15 +8,24 @@
package io.element.android.features.preferences.impl.advanced
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
@ -33,10 +42,12 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.stringWithLink
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults
import io.element.android.libraries.designsystem.theme.components.Slider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
@ -47,11 +58,13 @@ import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlin.math.roundToInt
@Composable
fun AdvancedSettingsView(
state: AdvancedSettingsState,
onBackClick: () -> Unit,
onOpenAppSettingsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val analyticsService = LocalAnalyticsService.current
@ -190,6 +203,15 @@ fun AdvancedSettingsView(
}
ModerationAndSafety(state)
if (state.liveLocationMinimumDistanceUpdate != null) {
LiveLocationUpdatesSection(
value = state.liveLocationMinimumDistanceUpdate,
onValueSaved = { value ->
state.eventSink(AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate(value))
},
onOpenAppPermissionsClick = onOpenAppSettingsClick,
)
}
}
}
@ -314,6 +336,78 @@ private fun ModerationAndSafety(
}
}
@Composable
private fun LiveLocationUpdatesSection(
value: Int,
onValueSaved: (Int) -> Unit,
onOpenAppPermissionsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
PreferenceCategory(
modifier = modifier,
showTopDivider = true,
) {
ListSectionHeader(
title = stringResource(R.string.screen_advanced_settings_live_location_section_title),
description = {
ListSupportingText(
text = stringResource(R.string.screen_advanced_settings_live_location_section_description),
contentPadding = ListSupportingTextDefaults.Padding.None,
)
}
)
var sliderValue by remember(value) { mutableIntStateOf(value) }
Column(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = pluralStringResource(
R.plurals.screen_advanced_settings_live_location_update_distance,
sliderValue,
sliderValue,
),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
val valueRange = 1f..100f
val start = valueRange.start.toInt()
val end = valueRange.endInclusive.toInt()
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${start}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular)
Slider(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.roundToInt() },
onValueChangeFinish = {
onValueSaved(sliderValue)
},
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = ElementTheme.colors.iconAccentPrimary,
activeTrackColor = ElementTheme.colors.iconAccentPrimary,
inactiveTrackColor = ElementTheme.colors.bgBadgeAccent,
inactiveTickColor = ElementTheme.colors.iconAccentPrimary,
)
)
Text("${end}m", color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodyMdRegular)
}
}
val footerText = stringWithLink(
textRes = R.string.screen_advanced_settings_live_location_section_footer,
url = "",
linkTextRes = R.string.screen_advanced_settings_live_location_section_footer_link,
onLinkClick = { onOpenAppPermissionsClick() },
)
ListSupportingText(
annotatedString = footerText,
contentPadding = ListSupportingTextDefaults.Padding.Default,
)
}
}
@PreviewWithLargeHeight
@Composable
internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
@ -334,7 +428,8 @@ internal fun AdvancedSettingsViewBlackPreview(@PreviewParameter(AdvancedSettings
private fun ContentToPreview(state: AdvancedSettingsState) {
AdvancedSettingsView(
state = state,
onBackClick = { }
onBackClick = { },
onOpenAppSettingsClick = {}
)
}

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
@ -209,6 +210,72 @@ class AdvancedSettingsPresenterTest {
}
}
@Test
fun `present - live location minimum distance is null when feature is disabled`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate = 50,
)
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.LiveLocationSharing, false)
}
val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isNull()
}
}
}
@Test
fun `present - exposes live location minimum distance from app preferences`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate = 50,
)
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.LiveLocationSharing, true)
}
val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(50)
}
}
}
@Test
fun `present - saving live location minimum distance updates app preferences`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate = 10,
)
val featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.LiveLocationSharing, true)
}
val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(10)
eventSink(AdvancedSettingsEvents.SetLiveLocationMinimumDistanceUpdate(42))
}
with(awaitItem()) {
assertThat(liveLocationMinimumDistanceUpdate).isEqualTo(42)
}
}
}
@Test
fun `present - black theme option shown when feature flag enabled`() = runTest {
val presenter = createAdvancedSettingsPresenter(
@ -338,7 +405,7 @@ class AdvancedSettingsPresenterTest {
}
private fun CoroutineScope.createAdvancedSettingsPresenter(
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),

View file

@ -250,6 +250,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
state: AdvancedSettingsState,
analyticsService: AnalyticsService = FakeAnalyticsService(),
onBackClick: () -> Unit = EnsureNeverCalled(),
onOpenAppSettings: () -> Unit = EnsureNeverCalled(),
) {
setContent {
CompositionLocalProvider(
@ -258,6 +259,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
AdvancedSettingsView(
state = state,
onBackClick = onBackClick,
onOpenAppSettingsClick = onOpenAppSettings
)
}
}

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.NotJoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.spaces.SpaceService
@ -67,6 +68,7 @@ interface MatrixClient {
val sessionCoroutineScope: CoroutineScope
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
val roomMembershipObserver: RoomMembershipObserver
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
suspend fun getRoom(roomId: RoomId): BaseRoom?
suspend fun findDM(userId: UserId): Result<RoomId?>

View file

@ -0,0 +1,12 @@
/*
* 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.api.room.location
import io.element.android.libraries.matrix.api.core.EventId
typealias BeaconId = EventId

View file

@ -0,0 +1,16 @@
/*
* 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.api.room.location
import io.element.android.libraries.matrix.api.core.RoomId
data class BeaconInfoUpdate(
val roomId: RoomId,
val beaconId: BeaconId,
val isLive: Boolean,
)

View file

@ -0,0 +1,14 @@
/*
* 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.api.room.location
sealed class LiveLocationException(message: String?) : Exception(message) {
class NotLive : LiveLocationException("The beacon event has expired.")
class Network : LiveLocationException("Network error")
class Other(val exception: Exception) : LiveLocationException(exception.message)
}

View file

@ -21,6 +21,8 @@ data class LiveLocationShare(
val startTimestamp: Long,
/** The timestamp when location sharing ends, in milliseconds. */
val endTimestamp: Long,
/** The event id from the beacon info. */
val beaconId: BeaconId
)
data class LastLocation(

View file

@ -70,6 +70,7 @@ import io.element.android.libraries.matrix.impl.room.RustRoomFactory
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
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.location.map
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
import io.element.android.libraries.matrix.impl.roomdirectory.map
@ -113,6 +114,8 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.AuthData
import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
import org.matrix.rustcomponents.sdk.BeaconInfoListener
import org.matrix.rustcomponents.sdk.BeaconInfoUpdate
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientException
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
@ -207,6 +210,15 @@ class RustMatrixClient(
analyticsService = analyticsService,
)
override val ownBeaconInfoUpdates = mxCallbackFlow {
val listener = object : BeaconInfoListener {
override fun onUpdate(update: BeaconInfoUpdate) {
trySend(update.map())
}
}
innerClient.subscribeToOwnBeaconInfoUpdates(listener)
}
override val sessionVerificationService = RustSessionVerificationService(
client = innerClient,
isSyncServiceReady = syncService.syncState.map { it == SyncState.Running },

View file

@ -45,6 +45,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.map
import io.element.android.libraries.matrix.impl.room.location.timedByExpiry
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
@ -72,6 +73,7 @@ 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.LiveLocationException
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
import org.matrix.rustcomponents.sdk.SendQueueListener
@ -525,12 +527,22 @@ class JoinedRustRoom(
override suspend fun stopLiveLocationShare(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.stopLiveLocationShare()
}.mapFailure { throwable ->
when (throwable) {
is LiveLocationException -> throwable.map()
else -> throwable
}
}
}
override suspend fun sendLiveLocation(geoUri: String): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.sendLiveLocation(geoUri)
}.mapFailure { throwable ->
when (throwable) {
is LiveLocationException -> throwable.map()
else -> throwable
}
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import org.matrix.rustcomponents.sdk.BeaconInfoUpdate as RustBeaconInfoUpdate
fun RustBeaconInfoUpdate.map(): BeaconInfoUpdate {
return BeaconInfoUpdate(
roomId = RoomId(roomId),
beaconId = EventId(eventId),
isLive = live
)
}

View file

@ -0,0 +1,19 @@
/*
* 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.LiveLocationException
import org.matrix.rustcomponents.sdk.LiveLocationException as RustLiveLocationException
fun RustLiveLocationException.map(): LiveLocationException {
return when (this) {
is RustLiveLocationException.Network -> LiveLocationException.Network()
is RustLiveLocationException.NotLive -> LiveLocationException.NotLive()
else -> LiveLocationException.Other(this)
}
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.room.location
import io.element.android.libraries.matrix.api.core.EventId
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
@ -41,9 +42,9 @@ fun RoomInterface.liveLocationSharesFlow(): Flow<List<LiveLocationShare>> {
}
}
return callbackFlow {
val liveLocationShares = liveLocationsObserver()
val observer = liveLocationsObserver()
val shares: MutableList<LiveLocationShare> = ArrayList()
val taskHandle = liveLocationShares.subscribe(object : LiveLocationsListener {
val taskHandle = observer.subscribe(object : LiveLocationsListener {
override fun onUpdate(updates: List<LiveLocationShareUpdate>) {
for (update in updates) {
shares.applyUpdate(update)
@ -53,13 +54,14 @@ fun RoomInterface.liveLocationSharesFlow(): Flow<List<LiveLocationShare>> {
})
awaitClose {
taskHandle.cancelAndDestroy()
liveLocationShares.destroy()
observer.destroy()
}
}.buffer(Channel.UNLIMITED)
}
private fun RustLiveLocationShare.into(): LiveLocationShare {
return LiveLocationShare(
beaconId = EventId(beaconId),
userId = UserId(userId),
lastLocation = lastLocation?.let {
LastLocation(
@ -69,6 +71,6 @@ private fun RustLiveLocationShare.into(): LiveLocationShare {
)
},
startTimestamp = startTs.toLong(),
endTimestamp = (startTs + timeout).toLong()
endTimestamp = (startTs + timeout).toLong(),
)
}

View file

@ -11,6 +11,7 @@ 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 io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
@ -24,9 +25,9 @@ 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),
aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 1_000),
aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000),
aLiveLocationShare(userId = UserId("@carol:server"), endTimestamp = 3_000),
)
flowOf(shares)
@ -56,8 +57,8 @@ class TimedLiveLocationSharesFlowTest {
@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),
aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 500),
aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000),
)
flowOf(shares)
@ -81,8 +82,8 @@ class TimedLiveLocationSharesFlowTest {
val upstream = MutableSharedFlow<List<LiveLocationShare>>(extraBufferCapacity = 1)
val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000))
val updatedShares = listOf(
aLiveLocationShare(userId = "@alice:server", endTimestamp = 10_000),
aLiveLocationShare(userId = "@bob:server", endTimestamp = 6_000),
aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 10_000),
aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 6_000),
)
upstream
@ -133,15 +134,3 @@ class TimedLiveLocationSharesFlowTest {
}
}
}
private fun aLiveLocationShare(
userId: String = "@user:server",
endTimestamp: Long,
): LiveLocationShare {
return LiveLocationShare(
userId = UserId(userId),
lastLocation = null,
startTimestamp = 0L,
endTimestamp = endTimestamp,
)
}

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.NotJoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.spaces.SpaceService
@ -107,6 +108,7 @@ class FakeMatrixClient(
private val canReportRoomLambda: () -> Boolean = { false },
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
override val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate> = emptyFlow(),
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },

View file

@ -89,7 +89,7 @@ class FakeJoinedRoom(
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
private val liveLocationSharesFlow: Flow<List<LiveLocationShare>> = MutableStateFlow(emptyList()),
private val startLiveLocationShareResult: (Long) -> Result<Unit> = { lambdaError() },
private val startLiveLocationShareResult: (Long) -> Result<EventId> = { lambdaError() },
private val stopLiveLocationShareResult: () -> Result<Unit> = { lambdaError() },
private val sendLiveLocationResult: (String) -> Result<Unit> = { lambdaError() },
) : JoinedRoom, BaseRoom by baseRoom {

View file

@ -0,0 +1,38 @@
/*
* 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.test.room.location
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
fun aLiveLocationShare(
beaconId: EventId = AN_EVENT_ID,
userId: UserId = A_USER_ID,
geoUri: String = "geo:48.8584,2.2945",
timestamp: Long = 0L,
startTimestamp: Long = 0L,
endTimestamp: Long = Long.MAX_VALUE,
assetType: AssetType = AssetType.SENDER,
): LiveLocationShare {
return LiveLocationShare(
beaconId = beaconId,
userId = userId,
lastLocation = LastLocation(
geoUri = geoUri,
timestamp = timestamp,
assetType = assetType,
),
startTimestamp = startTimestamp,
endTimestamp = endTimestamp,
)
}

View file

@ -23,6 +23,9 @@ interface AppPreferencesStore {
suspend fun setTheme(theme: String)
fun getThemeFlow(): Flow<String?>
suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int)
fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow<Int>
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
suspend fun setHideInviteAvatars(hide: Boolean?)
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
@ -26,4 +27,6 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.sessionStorage.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.preferences.test)
}

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.preferences.impl.store
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
@ -28,6 +29,7 @@ private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseU
private val themeKey = stringPreferencesKey("theme")
private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars")
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
@ -79,6 +81,18 @@ class DefaultAppPreferencesStore(
}
}
override suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) {
store.edit { prefs ->
prefs[liveLocationMinimumDistanceUpdateKey] = value
}
}
override fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow<Int> {
return store.data.map { prefs ->
prefs[liveLocationMinimumDistanceUpdateKey] ?: 10
}
}
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
return store.data.map { prefs ->

View file

@ -0,0 +1,57 @@
/*
* 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.preferences.impl.store
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultAppPreferencesStoreTest {
private val buildMeta = BuildMeta(
buildType = BuildType.DEBUG,
isDebuggable = true,
applicationName = "Element X",
productionApplicationName = "Element",
desktopApplicationName = "Element Desktop",
applicationId = "io.element.android",
isEnterpriseBuild = false,
lowPrivacyLoggingEnabled = false,
versionName = "1.0.0",
versionCode = 1,
gitRevision = "test",
gitBranchName = "test",
flavorDescription = "test",
flavorShortDescription = "test",
)
@Test
fun `live location minimum distance defaults to 10`() = runTest {
val store = DefaultAppPreferencesStore(
buildMeta = buildMeta,
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
)
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(10)
}
@Test
fun `live location minimum distance persists updates`() = runTest {
val store = DefaultAppPreferencesStore(
buildMeta = buildMeta,
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
)
store.setLiveLocationMinimumDistanceInMetersUpdate(25)
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25)
}
}

View file

@ -21,12 +21,14 @@ class InMemoryAppPreferencesStore(
hideInviteAvatars: Boolean? = null,
timelineMediaPreviewValue: MediaPreviewValue? = null,
theme: String? = null,
liveLocationMinimumDistanceUpdate: Int = 10,
logLevel: LogLevel = LogLevel.INFO,
traceLockPacks: Set<TraceLogPack> = emptySet(),
) : AppPreferencesStore {
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
private val theme = MutableStateFlow(theme)
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
private val logLevel = MutableStateFlow(logLevel)
private val tracingLogPacks = MutableStateFlow(traceLockPacks)
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
@ -56,6 +58,14 @@ class InMemoryAppPreferencesStore(
return theme
}
override suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) {
liveLocationMinimumDistanceUpdate.value = value
}
override fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow<Int> {
return liveLocationMinimumDistanceUpdate
}
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
return hideInviteAvatars

View file

@ -53,4 +53,5 @@ object NotificationIdProvider {
enum class ForegroundServiceType {
INCOMING_CALL,
ONGOING_CALL,
LIVE_LOCATION,
}

View file

@ -10,12 +10,13 @@ package io.element.android.libraries.sessionstorage.test.observer
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import java.util.concurrent.CopyOnWriteArraySet
class FakeSessionObserver : SessionObserver {
private val _listeners = mutableListOf<SessionListener>()
private val _listeners = CopyOnWriteArraySet<SessionListener>()
val listeners: List<SessionListener>
get() = _listeners
get() = _listeners.toList()
override fun addListener(listener: SessionListener) {
_listeners.add(listener)

View file

@ -34,6 +34,8 @@ interface AppForegroundStateService {
*/
val isSyncingNotificationEvent: StateFlow<Boolean>
val isSharingLiveLocation: StateFlow<Boolean>
/**
* Start observing the foreground state.
*/
@ -53,4 +55,6 @@ interface AppForegroundStateService {
* Update the active state for the syncing notification event flow.
*/
fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean)
fun updateIsSharingLiveLocation(isSharingLiveLocation: Boolean)
}

View file

@ -20,6 +20,8 @@ class DefaultAppForegroundStateService : AppForegroundStateService {
override val isSyncingNotificationEvent = MutableStateFlow(false)
override val hasRingingCall = MutableStateFlow(false)
override val isSharingLiveLocation = MutableStateFlow<Boolean>(false)
private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle }
override fun startObservingForeground() {
@ -38,6 +40,10 @@ class DefaultAppForegroundStateService : AppForegroundStateService {
this.isSyncingNotificationEvent.value = isSyncingNotificationEvent
}
override fun updateIsSharingLiveLocation(isSharingLiveLocation: Boolean) {
this.isSharingLiveLocation.value = isSharingLiveLocation
}
private val lifecycleObserver = LifecycleEventObserver { _, _ -> isInForeground.value = getCurrentState() }
private fun getCurrentState(): Boolean = appLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)

View file

@ -16,12 +16,15 @@ class FakeAppForegroundStateService(
initialIsInCallValue: Boolean = false,
initialIsSyncingNotificationEventValue: Boolean = false,
initialHasRingingCall: Boolean = false,
initialIsSharingLiveLocation: Boolean = false,
) : AppForegroundStateService {
override val isInForeground = MutableStateFlow(initialForegroundValue)
override val isInCall = MutableStateFlow(initialIsInCallValue)
override val isSyncingNotificationEvent = MutableStateFlow(initialIsSyncingNotificationEventValue)
override val hasRingingCall = MutableStateFlow(initialHasRingingCall)
override val isSharingLiveLocation = MutableStateFlow<Boolean>(initialIsSharingLiveLocation)
override fun startObservingForeground() {
// No-op
}
@ -41,4 +44,8 @@ class FakeAppForegroundStateService(
override fun updateHasRingingCall(hasRingingCall: Boolean) {
this.hasRingingCall.value = hasRingingCall
}
override fun updateIsSharingLiveLocation(isSharingLiveLocation: Boolean) {
this.isSharingLiveLocation.value = isSharingLiveLocation
}
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aaf113646fa3a8ffd57528d3e97eec05a8d80b99a3bbd7770266353dbe6abd64
size 10217

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:53d9deec2a6295fe0155ce2986463a3b598436c0515ef87e0905c62c25970420
size 9647

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aaafea9efc1000495ee469797239b82193844caa3d6f98c0c3a4344a536a1798
size 17155
oid sha256:c9ebf3725fa875994cf1a15b30e2d4d533c2a9281f0f7d5d9f83f8685ba384d1
size 18637

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f113f8979679c0673e4cc1f691140bc570b6826bea23eecc403f2fbfd3f6d09
size 16460
oid sha256:65c321199578618012d27afe478c0eaf6a67c44101e7d1d1c51a4c6c1fa9b93a
size 17897

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a97492422d54a6d6666c1ade693dc9b63bc9ca07c17d6c1f787c081984c09f68
size 42470
oid sha256:9f3fb75974ce37fc3ce5e303ab5573a0d9a769f3c079de5b0f6462b40e53c1cd
size 26713

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ddaf978cdf3e70b01fbee75e7e7290fa390e749b48ae192fe7da0dcaa9a1d4dc
size 38417

Some files were not shown because too many files have changed in this diff Show more