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 397274250a
commit 3b620bd8a8
145 changed files with 2913 additions and 278 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,100 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.api
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LiveLocationSharingBanner(
onClick: () -> Unit,
onStopClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgCanvasDefault)
.drawBannerBorder(ElementTheme.colors.separatorPrimary)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = CompoundIcons.LocationPinSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconAccentPrimary,
modifier = Modifier.size(24.dp),
)
Text(
text = stringResource(CommonStrings.screen_room_live_location_banner),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
Button(
text = stringResource(CommonStrings.action_stop),
onClick = onStopClick,
destructive = true,
size = ButtonSize.Small,
)
}
}
private fun Modifier.drawBannerBorder(borderColor: Color): Modifier = drawBehind {
val strokeWidth = 1.dp.toPx()
val bottomY = size.height - strokeWidth / 2
drawLine(
color = borderColor,
start = Offset(0f, strokeWidth / 2),
end = Offset(size.width, strokeWidth / 2),
strokeWidth = strokeWidth,
)
drawLine(
color = borderColor,
start = Offset(0f, bottomY),
end = Offset(size.width, bottomY),
strokeWidth = strokeWidth,
)
}
@PreviewsDayNight
@Composable
internal fun LiveLocationSharingBannerPreview() = ElementPreview {
LiveLocationSharingBanner(
onClick = {},
onStopClick = {},
)
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.api.live
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
interface ActiveLiveLocationShareManager {
/** All rooms currently sharing live location on this device. */
val sharingRoomIds: StateFlow<Set<RoomId>>
/**
* Initializes the manager.
* This will restart or stop current location sharing and set the listener on the SDK
* and the session manager.
*/
suspend fun setup()
/**
* Starts live location sharing in the given room.
* Calls room.startLiveLocationShare() on the SDK, registers the share,
* and starts the foreground GPS service if not already running.
*/
suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit>
/**
* Stops live location sharing in the given room.
* Calls room.stopLiveLocationShare() on the SDK, removes the share,
* and stops the foreground service if no shares remain.
*/
suspend fun stopShare(roomId: RoomId): Result<Unit>
}
fun ActiveLiveLocationShareManager.isCurrentlySharing(roomId: RoomId): StateFlow<Boolean> {
return sharingRoomIds.mapState { roomId in it }
}

View file

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

View file

@ -9,4 +9,14 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_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> </manifest>

View file

@ -16,13 +16,16 @@ sealed interface LocationConstraintsCheck {
data object PermissionRationale : LocationConstraintsCheck data object PermissionRationale : LocationConstraintsCheck
data object PermissionDenied : LocationConstraintsCheck data object PermissionDenied : LocationConstraintsCheck
data object LocationServiceDisabled : LocationConstraintsCheck data object LocationServiceDisabled : LocationConstraintsCheck
data object NotEnoughPowerLevel : LocationConstraintsCheck
} }
fun checkLocationConstraints( fun checkLocationConstraints(
permissionsState: PermissionsState, permissionsState: PermissionsState,
locationActions: LocationActions, locationActions: LocationActions,
sendLiveLocationPermissions: SendLiveLocationPermissions,
): LocationConstraintsCheck { ): LocationConstraintsCheck {
return when { return when {
!sendLiveLocationPermissions.hasAll -> LocationConstraintsCheck.NotEnoughPowerLevel
permissionsState.isAnyGranted -> { permissionsState.isAnyGranted -> {
if (locationActions.isLocationEnabled()) { if (locationActions.isLocationEnabled()) {
LocationConstraintsCheck.Success LocationConstraintsCheck.Success
@ -41,5 +44,6 @@ fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled 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.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource 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.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -42,6 +44,10 @@ fun LocationConstraintsDialog(
onDismiss = onDismiss, onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue), 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 PermissionRationale : LocationConstraintsDialogState
data object PermissionDenied : LocationConstraintsDialogState data object PermissionDenied : LocationConstraintsDialogState
data object LocationServiceDisabled : 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -44,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LocationShareRow( fun LocationShareRow(
item: LocationShareItem, item: LocationShareItem,
onShareClick: () -> Unit, onShareClick: () -> Unit,
onStopClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( 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) { IconButton(onClick = onShareClick) {
Icon( Icon(
imageVector = CompoundIcons.ShareAndroid(), imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share), contentDescription = stringResource(CommonStrings.action_share),
tint = ElementTheme.colors.iconPrimary,
) )
} }
} }
@ -128,8 +143,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
formattedTimestamp = "Shared 1 min ago", formattedTimestamp = "Shared 1 min ago",
isLive = true, isLive = true,
assetType = AssetType.SENDER, assetType = AssetType.SENDER,
location = Location(0.0, 0.0) location = Location(0.0, 0.0),
isOwnUser = true,
), ),
onStopClick = {},
onShareClick = {}, onShareClick = {},
) )
LocationShareRow( LocationShareRow(
@ -145,8 +162,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
isLive = false, isLive = false,
assetType = AssetType.PIN, assetType = AssetType.PIN,
formattedTimestamp = "Shared 5 hours ago", formattedTimestamp = "Shared 5 hours ago",
location = Location(0.0, 0.0) location = Location(0.0, 0.0),
isOwnUser = false
), ),
onStopClick = {},
onShareClick = {}, onShareClick = {},
) )
} }

View file

@ -23,7 +23,7 @@ import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.location.rememberAndroidLocationProvider import org.maplibre.compose.location.rememberAndroidLocationProvider
import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.compose.location.rememberUserLocationState
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun UserLocationPuck( fun UserLocationPuck(
@ -72,9 +72,9 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState
rememberNullLocationProvider() rememberNullLocationProvider()
} else { } else {
rememberAndroidLocationProvider( rememberAndroidLocationProvider(
updateInterval = 1.minutes, updateInterval = 5.seconds,
desiredAccuracy = DesiredAccuracy.Balanced, desiredAccuracy = DesiredAccuracy.High,
minDistanceMeters = 50f, minDistanceMeters = 5f,
) )
} }
return rememberUserLocationState(locationProvider) 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, val isPinned: Boolean,
) : ShareLocationEvent ) : ShareLocationEvent
data object ShowLiveLocationDurationPicker : ShareLocationEvent data object InitiateLiveLocationShare : ShareLocationEvent
data object AcceptLiveLocationDisclaimer : ShareLocationEvent
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
data object StartTrackingUserLocation : 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.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer 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.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults 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.actions.LocationActions
import io.element.android.features.location.impl.common.checkLocationConstraints 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.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter 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.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.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.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter 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.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DurationFormatter 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.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.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.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -63,6 +69,8 @@ class ShareLocationPresenter(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val client: MatrixClient, private val client: MatrixClient,
private val durationFormatter: DurationFormatter, private val durationFormatter: DurationFormatter,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore,
) : Presenter<ShareLocationState> { ) : Presenter<ShareLocationState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@ -82,15 +90,39 @@ class ShareLocationPresenter(
var dialogState: ShareLocationState.Dialog by remember { var dialogState: ShareLocationState.Dialog by remember {
mutableStateOf(ShareLocationState.Dialog.None) mutableStateOf(ShareLocationState.Dialog.None)
} }
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState() val currentUser by client.userProfile.collectAsState()
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
perms.sendLiveLocationPermissions()
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
fun checkLocationConstraints() { fun checkLocationConstraints() {
val locationConstraints = checkLocationConstraints(permissionsState, locationActions) // No need to check SendLiveLocationPermissions here
dialogState = Constraints(locationConstraints.toDialogState()) val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success 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() } LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
fun handleEvent(event: ShareLocationEvent) { fun handleEvent(event: ShareLocationEvent) {
@ -109,20 +141,23 @@ class ShareLocationPresenter(
locationActions.openLocationSettings() locationActions.openLocationSettings()
dialogState = ShareLocationState.Dialog.None dialogState = ShareLocationState.Dialog.None
} }
ShareLocationEvent.ShowLiveLocationDurationPicker -> { ShareLocationEvent.InitiateLiveLocationShare -> scope.launch {
val constraintsResult = checkLocationConstraints(permissionsState, locationActions) dialogState = computeLiveLocationDialogState()
dialogState = if (constraintsResult is LocationConstraintsCheck.Success) { }
val durations = LIVE_LOCATION_DURATIONS.map { ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it)) liveLocationStore.setAcceptedLiveLocationDisclaimer()
.onSuccess {
dialogState = computeLiveLocationDialogState()
} }
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
} else {
Constraints(constraintsResult.toDialogState())
}
} }
is ShareLocationEvent.StartLiveLocationShare -> scope.launch { is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
dialogState = ShareLocationState.Dialog.None dialogState = ShareLocationState.Dialog.None
// room.startLiveLocationShare(event.duration.inWholeMilliseconds) startLiveLocationAction.runUpdatingState {
liveLocationShareManager.startShare(
roomId = room.roomId,
duration = event.duration,
)
}
} }
ShareLocationEvent.RequestPermissions -> { ShareLocationEvent.RequestPermissions -> {
dialogState = ShareLocationState.Dialog.None dialogState = ShareLocationState.Dialog.None
@ -138,6 +173,7 @@ class ShareLocationPresenter(
hasLocationPermission = permissionsState.isAnyGranted, hasLocationPermission = permissionsState.isAnyGranted,
canShareLiveLocation = isLiveLocationSharingEnabled, canShareLiveLocation = isLiveLocationSharingEnabled,
appName = appName, appName = appName,
startLiveLocationAction = startLiveLocationAction.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -9,6 +9,7 @@
package io.element.android.features.location.impl.share package io.element.android.features.location.impl.share
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState 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 io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -19,11 +20,13 @@ data class ShareLocationState(
val hasLocationPermission: Boolean, val hasLocationPermission: Boolean,
val appName: String, val appName: String,
val canShareLiveLocation: Boolean, val canShareLiveLocation: Boolean,
val startLiveLocationAction: AsyncAction<Unit>,
val eventSink: (ShareLocationEvent) -> Unit, val eventSink: (ShareLocationEvent) -> Unit,
) { ) {
sealed interface Dialog { sealed interface Dialog {
data object None : Dialog data object None : Dialog
data class Constraints(val state: LocationConstraintsDialogState) : Dialog data class Constraints(val state: LocationConstraintsDialogState) : Dialog
data object LiveLocationDisclaimer : Dialog
data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState 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.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -51,6 +52,18 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
trackUserPosition = true, trackUserPosition = true,
hasLocationPermission = 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( aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDurations( dialogState = ShareLocationState.Dialog.LiveLocationDurations(
persistentListOf( persistentListOf(
@ -73,6 +86,7 @@ fun aShareLocationState(
hasLocationPermission: Boolean = false, hasLocationPermission: Boolean = false,
canShareLiveLocation: Boolean = false, canShareLiveLocation: Boolean = false,
appName: String = APP_NAME, appName: String = APP_NAME,
startLiveLocationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (ShareLocationEvent) -> Unit = {}, eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState { ): ShareLocationState {
return ShareLocationState( return ShareLocationState(
@ -82,6 +96,7 @@ fun aShareLocationState(
hasLocationPermission = hasLocationPermission, hasLocationPermission = hasLocationPermission,
canShareLiveLocation = canShareLiveLocation, canShareLiveLocation = canShareLiveLocation,
appName = appName, appName = appName,
startLiveLocationAction = startLiveLocationAction,
eventSink = eventSink eventSink = eventSink
) )
} }

View file

@ -29,7 +29,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.ui.rememberUserLocationState 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.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant 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.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton 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.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
@ -74,7 +78,6 @@ fun ShareLocationView(
navigateUp: () -> Unit, navigateUp: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
when (val dialogState = state.dialogState) { when (val dialogState = state.dialogState) {
ShareLocationState.Dialog.None -> Unit ShareLocationState.Dialog.None -> Unit
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog( is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
@ -85,12 +88,17 @@ fun ShareLocationView(
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) }, onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, 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( is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
durations = dialogState.durations, durations = dialogState.durations,
onSelectDuration = { duration -> onSelectDuration = { duration ->
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration)) state.eventSink(StartLiveLocationShare(duration))
context.toast("Not implemented yet!")
navigateUp()
}, },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
) )
@ -160,10 +168,46 @@ fun ShareLocationView(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(all = 16.dp), .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 @Composable
private fun BottomSheetContent( private fun BottomSheetContent(
cameraState: CameraState, cameraState: CameraState,
@ -202,7 +246,7 @@ private fun BottomSheetContent(
} }
if (state.canShareLiveLocation) { if (state.canShareLiveLocation) {
ShareLiveLocationItem { ShareLiveLocationItem {
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
} }
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,115 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.live
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.live.service.LiveLocationReceiver
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LiveLocationSharingCoordinatorTest {
@Test
fun `first registration starts the service and last unregister stops it`() = runTest {
var startCount = 0
var stopCount = 0
val coordinator = LiveLocationSharingCoordinator(
startService = { startCount++ },
stopService = { stopCount++ },
nowMillis = { 0L },
)
coordinator.register(A_SESSION_ID, LiveLocationReceiver { })
coordinator.unregister(A_SESSION_ID)
assertThat(startCount).isEqualTo(1)
assertThat(stopCount).isEqualTo(1)
}
@Test
fun `dispatch isolates receiver failures and still reaches later receivers`() = runTest {
val delivered = mutableListOf<Location>()
val coordinator = LiveLocationSharingCoordinator(
startService = { },
stopService = { },
nowMillis = { 4_000L },
)
coordinator.register(A_SESSION_ID) { error("boom") }
coordinator.register(A_SESSION_ID_2) { location -> delivered += location }
coordinator.dispatch(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
assertThat(delivered).containsExactly(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
}
@Test
fun `dispatch delivers first location immediately`() = runTest {
var nowMillis = 4_000L
val delivered = mutableListOf<Location>()
val coordinator = LiveLocationSharingCoordinator(
startService = { },
stopService = { },
nowMillis = { nowMillis },
)
coordinator.register(A_SESSION_ID) { location -> delivered += location }
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
coordinator.dispatch(firstLocation)
assertThat(delivered).containsExactly(firstLocation)
}
@Test
fun `dispatch drops updates inside the throttle window`() = runTest {
var nowMillis = 4_000L
val delivered = mutableListOf<Location>()
val coordinator = LiveLocationSharingCoordinator(
startService = { },
stopService = { },
nowMillis = { nowMillis },
)
coordinator.register(A_SESSION_ID) { location -> delivered += location }
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
coordinator.dispatch(firstLocation)
nowMillis += 500
coordinator.dispatch(secondLocation)
assertThat(delivered).containsExactly(firstLocation)
}
@Test
fun `dispatch delivers next update after the throttle window elapses`() = runTest {
var nowMillis = 4_000L
val delivered = mutableListOf<Location>()
val coordinator = LiveLocationSharingCoordinator(
startService = { },
stopService = { },
nowMillis = { nowMillis },
)
coordinator.register(A_SESSION_ID) { location -> delivered += location }
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
coordinator.dispatch(firstLocation)
nowMillis += 3_000
coordinator.dispatch(secondLocation)
assertThat(delivered).containsExactly(firstLocation, secondLocation).inOrder()
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@ package io.element.android.features.location.impl.show
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import org.junit.Test import org.junit.Test
class LiveLocationShareComparatorTest { class LiveLocationShareComparatorTest {
@ -55,15 +55,3 @@ class LiveLocationShareComparatorTest {
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder() assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
} }
} }
private fun aLiveLocationShare(
userId: UserId,
startTimestamp: Long,
): LiveLocationShare {
return LiveLocationShare(
userId = userId,
lastLocation = null,
startTimestamp = startTimestamp,
endTimestamp = startTimestamp + 1_000L,
)
}

View file

@ -20,14 +20,15 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.location.LastLocation
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
@ -60,6 +61,7 @@ class ShowLocationPresenterTest {
), ),
locationActions: FakeLocationActions = fakeLocationActions, locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(), joinedRoom: JoinedRoom = FakeJoinedRoom(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
) = ShowLocationPresenter( ) = ShowLocationPresenter(
mode = mode, mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter }, permissionsPresenterFactory = { fakePermissionsPresenter },
@ -68,6 +70,7 @@ class ShowLocationPresenterTest {
dateFormatter = fakeDateFormatter, dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider(), stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom, joinedRoom = joinedRoom,
liveLocationShareManager = liveLocationShareManager,
) )
@Test @Test
@ -205,7 +208,7 @@ class ShowLocationPresenterTest {
) )
) )
val presenter = createShowLocationPresenter() val presenter = createShowLocationPresenter()
presenter.test { presenter.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
@ -464,23 +467,3 @@ class ShowLocationPresenterTest {
} }
} }
} }
private fun aLiveLocationShare(
userId: UserId,
geoUri: String = "geo:48.8584,2.2945",
timestamp: Long = 0L,
startTimestamp: Long = 0L,
endTimestamp: Long = Long.MAX_VALUE,
assetType: AssetType = AssetType.SENDER,
): LiveLocationShare {
return LiveLocationShare(
userId = userId,
lastLocation = LastLocation(
geoUri = geoUri,
timestamp = timestamp,
assetType = assetType,
),
startTimestamp = startTimestamp,
endTimestamp = endTimestamp,
)
}

View file

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

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.test
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlin.time.Duration
class FakeActiveLiveLocationShareManager(
val setupLambda: () -> Unit = { lambdaError() },
val startShareLambda: (roomId: RoomId, duration: Duration) -> Result<Unit> = { _, _ -> lambdaError() },
val stopShareLambda: (roomId: RoomId) -> Result<Unit> = { _ -> lambdaError() },
) : ActiveLiveLocationShareManager {
private val _sharingRoomIds = MutableStateFlow(emptySet<RoomId>())
override val sharingRoomIds: StateFlow<Set<RoomId>> = _sharingRoomIds
override suspend fun setup() {
setupLambda()
}
override suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> = simulateLongTask {
startShareLambda(roomId, duration).onSuccess {
_sharingRoomIds.update {
it + roomId
}
}
}
override suspend fun stopShare(roomId: RoomId): Result<Unit> = simulateLongTask {
stopShareLambda(roomId).onSuccess {
_sharingRoomIds.update {
it - roomId
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room.location
import io.element.android.libraries.matrix.api.core.EventId
typealias BeaconId = EventId

View file

@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room.location
import io.element.android.libraries.matrix.api.core.RoomId
data class BeaconInfoUpdate(
val roomId: RoomId,
val beaconId: BeaconId,
val isLive: Boolean,
)

View file

@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room.location
sealed class LiveLocationException(message: String?) : Exception(message) {
class NotLive : LiveLocationException("The beacon event has expired.")
class Network : LiveLocationException("Network error")
class Other(val exception: Exception) : LiveLocationException(exception.message)
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.room.location
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import org.matrix.rustcomponents.sdk.BeaconInfoUpdate as RustBeaconInfoUpdate
fun RustBeaconInfoUpdate.map(): BeaconInfoUpdate {
return BeaconInfoUpdate(
roomId = RoomId(roomId),
beaconId = EventId(eventId),
isLive = live
)
}

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.room.location
import io.element.android.libraries.matrix.api.room.location.LiveLocationException
import org.matrix.rustcomponents.sdk.LiveLocationException as RustLiveLocationException
fun RustLiveLocationException.map(): LiveLocationException {
return when (this) {
is RustLiveLocationException.Network -> LiveLocationException.Network()
is RustLiveLocationException.NotLive -> LiveLocationException.NotLive()
else -> LiveLocationException.Other(this)
}
}

View file

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

View file

@ -11,6 +11,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
@ -24,9 +25,9 @@ class TimedLiveLocationSharesFlowTest {
@Test @Test
fun `it keeps emitting shares for subsequent expiries without upstream changes`() = runTest { fun `it keeps emitting shares for subsequent expiries without upstream changes`() = runTest {
val shares = listOf( val shares = listOf(
aLiveLocationShare(userId = "@alice:server", endTimestamp = 1_000), aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 1_000),
aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000), aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000),
aLiveLocationShare(userId = "@carol:server", endTimestamp = 3_000), aLiveLocationShare(userId = UserId("@carol:server"), endTimestamp = 3_000),
) )
flowOf(shares) flowOf(shares)
@ -56,8 +57,8 @@ class TimedLiveLocationSharesFlowTest {
@Test @Test
fun `it does not double-emit when a share is already expired on receipt`() = runTest { fun `it does not double-emit when a share is already expired on receipt`() = runTest {
val shares = listOf( val shares = listOf(
aLiveLocationShare(userId = "@alice:server", endTimestamp = 500), aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 500),
aLiveLocationShare(userId = "@bob:server", endTimestamp = 2_000), aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 2_000),
) )
flowOf(shares) flowOf(shares)
@ -81,8 +82,8 @@ class TimedLiveLocationSharesFlowTest {
val upstream = MutableSharedFlow<List<LiveLocationShare>>(extraBufferCapacity = 1) val upstream = MutableSharedFlow<List<LiveLocationShare>>(extraBufferCapacity = 1)
val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000)) val initialShares = listOf(aLiveLocationShare(endTimestamp = 10_000))
val updatedShares = listOf( val updatedShares = listOf(
aLiveLocationShare(userId = "@alice:server", endTimestamp = 10_000), aLiveLocationShare(userId = UserId("@alice:server"), endTimestamp = 10_000),
aLiveLocationShare(userId = "@bob:server", endTimestamp = 6_000), aLiveLocationShare(userId = UserId("@bob:server"), endTimestamp = 6_000),
) )
upstream upstream
@ -133,15 +134,3 @@ class TimedLiveLocationSharesFlowTest {
} }
} }
} }
private fun aLiveLocationShare(
userId: String = "@user:server",
endTimestamp: Long,
): LiveLocationShare {
return LiveLocationShare(
userId = UserId(userId),
lastLocation = null,
startTimestamp = 0L,
endTimestamp = endTimestamp,
)
}

View file

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

View file

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

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.test.room.location
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.location.LastLocation
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
fun aLiveLocationShare(
beaconId: EventId = AN_EVENT_ID,
userId: UserId = A_USER_ID,
geoUri: String = "geo:48.8584,2.2945",
timestamp: Long = 0L,
startTimestamp: Long = 0L,
endTimestamp: Long = Long.MAX_VALUE,
assetType: AssetType = AssetType.SENDER,
): LiveLocationShare {
return LiveLocationShare(
beaconId = beaconId,
userId = userId,
lastLocation = LastLocation(
geoUri = geoUri,
timestamp = timestamp,
assetType = assetType,
),
startTimestamp = startTimestamp,
endTimestamp = endTimestamp,
)
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.preferences.impl.store
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultAppPreferencesStoreTest {
private val buildMeta = BuildMeta(
buildType = BuildType.DEBUG,
isDebuggable = true,
applicationName = "Element X",
productionApplicationName = "Element",
desktopApplicationName = "Element Desktop",
applicationId = "io.element.android",
isEnterpriseBuild = false,
lowPrivacyLoggingEnabled = false,
versionName = "1.0.0",
versionCode = 1,
gitRevision = "test",
gitBranchName = "test",
flavorDescription = "test",
flavorShortDescription = "test",
)
@Test
fun `live location minimum distance defaults to 10`() = runTest {
val store = DefaultAppPreferencesStore(
buildMeta = buildMeta,
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
)
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(10)
}
@Test
fun `live location minimum distance persists updates`() = runTest {
val store = DefaultAppPreferencesStore(
buildMeta = buildMeta,
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
)
store.setLiveLocationMinimumDistanceInMetersUpdate(25)
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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