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:
parent
0c657c258a
commit
e49e183178
145 changed files with 2913 additions and 278 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
|
|||
data object RequestPermissions : ShowLocationEvent
|
||||
data object OpenAppSettings : ShowLocationEvent
|
||||
data object OpenLocationSettings : ShowLocationEvent
|
||||
data object StopLocationSharing : ShowLocationEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue