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

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