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(