Feature : share live location (#6741)
* First live location sharing sending implementation * Simplify logic around canStop sharing * Add some debug logs around LiveLocationSharingService * Add LiveLocationException * Expose beaconId to identify the current share * Throttle live location instead of debouncing * Keep sync alive when sharing live location * Improve LiveLocation sharing * Show LiveLocationDisclaimer * Read minDistanceUpdate in LiveLocationSharingService * Set minDistanceUpdate in AdvancedSettings * Display banner in room when sharing live location * Fix tests around LiveLocationSharing * Ensure shares are properly restarted/stopped when app is re-launched * Ensure LLS data is cleared when session is removed * Update and fix LLS tests * Handle Start LLS in ui * Add check LLS permissions * Remove hardcoded strings * Fix quality and format * Create DeviceLocationProvider so we can share location data between sources (presenter/live location service) * Update screenshots * Fix warning * Do not try to stop if it was not sharing * Revert "Create DeviceLocationProvider so we can share location data between sources (presenter/live location service)" This reverts commit ba12bd968e82941cc231bdbb449310b24c97c5b8. * Tweak location provider config values * Address PR review remarks * Fix ktlint * Update screenshots * Fix some tests after merging develop * Adjust TimelineItemLocationView ui to match figma * Update screenshots * Documentation and cleanup * Remove temporary resource --------- Co-authored-by: ElementBot <android@element.io> Co-authored-by: Benoit Marty <benoit@matrix.org> Co-authored-by: Benoit Marty <benoitm@matrix.org>
This commit is contained in:
parent
0c657c258a
commit
e49e183178
145 changed files with 2913 additions and 278 deletions
|
|
@ -9,4 +9,14 @@
|
|||
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".live.service.LiveLocationSharingService"
|
||||
android:foregroundServiceType="location"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@ sealed interface LocationConstraintsCheck {
|
|||
data object PermissionRationale : LocationConstraintsCheck
|
||||
data object PermissionDenied : LocationConstraintsCheck
|
||||
data object LocationServiceDisabled : LocationConstraintsCheck
|
||||
data object NotEnoughPowerLevel : LocationConstraintsCheck
|
||||
}
|
||||
|
||||
fun checkLocationConstraints(
|
||||
permissionsState: PermissionsState,
|
||||
locationActions: LocationActions,
|
||||
sendLiveLocationPermissions: SendLiveLocationPermissions,
|
||||
): LocationConstraintsCheck {
|
||||
return when {
|
||||
!sendLiveLocationPermissions.hasAll -> LocationConstraintsCheck.NotEnoughPowerLevel
|
||||
permissionsState.isAnyGranted -> {
|
||||
if (locationActions.isLocationEnabled()) {
|
||||
LocationConstraintsCheck.Success
|
||||
|
|
@ -41,5 +44,6 @@ fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
|
|||
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
|
||||
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
|
||||
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
|
||||
LocationConstraintsCheck.NotEnoughPowerLevel -> LocationConstraintsDialogState.NotEnoughPowerLevel
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.impl.common
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
|
||||
|
||||
/**
|
||||
* Permissions to send beacon and beacon_info events in the room.
|
||||
*/
|
||||
data class SendLiveLocationPermissions(
|
||||
val canSendBeacon: Boolean,
|
||||
val canSendBeaconInfo: Boolean,
|
||||
) {
|
||||
val hasAll = canSendBeaconInfo && canSendBeacon
|
||||
|
||||
companion object {
|
||||
val DEFAULT = SendLiveLocationPermissions(canSendBeacon = false, canSendBeaconInfo = false)
|
||||
val GRANTED = SendLiveLocationPermissions(canSendBeacon = true, canSendBeaconInfo = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun RoomPermissions.sendLiveLocationPermissions(): SendLiveLocationPermissions {
|
||||
return SendLiveLocationPermissions(
|
||||
canSendBeaconInfo = canOwnUserSendState(StateEventType.BeaconInfo),
|
||||
canSendBeacon = canOwnUserSendMessage(MessageEventType.Beacon),
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ package io.element.android.features.location.impl.common.ui
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.location.impl.R
|
||||
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
|
|
@ -42,6 +44,10 @@ fun LocationConstraintsDialog(
|
|||
onDismiss = onDismiss,
|
||||
submitText = stringResource(CommonStrings.action_continue),
|
||||
)
|
||||
LocationConstraintsDialogState.NotEnoughPowerLevel -> AlertDialog(
|
||||
content = stringResource(R.string.screen_share_location_live_location_missing_permissions),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,4 +57,5 @@ sealed interface LocationConstraintsDialogState {
|
|||
data object PermissionRationale : LocationConstraintsDialogState
|
||||
data object PermissionDenied : LocationConstraintsDialogState
|
||||
data object LocationServiceDisabled : LocationConstraintsDialogState
|
||||
data object NotEnoughPowerLevel : LocationConstraintsDialogState
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -44,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
fun LocationShareRow(
|
||||
item: LocationShareItem,
|
||||
onShareClick: () -> Unit,
|
||||
onStopClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -101,11 +103,24 @@ fun LocationShareRow(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (item.canStopSharing) {
|
||||
IconButton(
|
||||
onClick = onStopClick,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = ElementTheme.colors.bgCriticalPrimary,
|
||||
contentColor = ElementTheme.colors.iconOnSolidPrimary,
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Stop(),
|
||||
contentDescription = stringResource(CommonStrings.action_stop),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onShareClick) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ShareAndroid(),
|
||||
contentDescription = stringResource(CommonStrings.action_share),
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -128,8 +143,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
|
|||
formattedTimestamp = "Shared 1 min ago",
|
||||
isLive = true,
|
||||
assetType = AssetType.SENDER,
|
||||
location = Location(0.0, 0.0)
|
||||
location = Location(0.0, 0.0),
|
||||
isOwnUser = true,
|
||||
),
|
||||
onStopClick = {},
|
||||
onShareClick = {},
|
||||
)
|
||||
LocationShareRow(
|
||||
|
|
@ -145,8 +162,10 @@ internal fun LocationShareRowPreview() = ElementPreview {
|
|||
isLive = false,
|
||||
assetType = AssetType.PIN,
|
||||
formattedTimestamp = "Shared 5 hours ago",
|
||||
location = Location(0.0, 0.0)
|
||||
location = Location(0.0, 0.0),
|
||||
isOwnUser = false
|
||||
),
|
||||
onStopClick = {},
|
||||
onShareClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import org.maplibre.compose.location.UserLocationState
|
|||
import org.maplibre.compose.location.rememberAndroidLocationProvider
|
||||
import org.maplibre.compose.location.rememberNullLocationProvider
|
||||
import org.maplibre.compose.location.rememberUserLocationState
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun UserLocationPuck(
|
||||
|
|
@ -72,9 +72,9 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState
|
|||
rememberNullLocationProvider()
|
||||
} else {
|
||||
rememberAndroidLocationProvider(
|
||||
updateInterval = 1.minutes,
|
||||
desiredAccuracy = DesiredAccuracy.Balanced,
|
||||
minDistanceMeters = 50f,
|
||||
updateInterval = 5.seconds,
|
||||
desiredAccuracy = DesiredAccuracy.High,
|
||||
minDistanceMeters = 5f,
|
||||
)
|
||||
}
|
||||
return rememberUserLocationState(locationProvider)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.impl.di
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import io.element.android.features.location.impl.live.service.LiveLocationSharingService
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
interface LocationBindings {
|
||||
fun inject(service: LiveLocationSharingService)
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.impl.live
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import dev.zacsweers.metro.binding
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||
import io.element.android.features.location.impl.live.service.LiveLocationReceiver
|
||||
import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.location.BeaconId
|
||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationException
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
@ContributesBinding(SessionScope::class, binding = binding<ActiveLiveLocationShareManager>())
|
||||
class DefaultActiveLiveLocationShareManager(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val coordinator: LiveLocationSharingCoordinator,
|
||||
private val liveLocationStore: LiveLocationStore,
|
||||
private val clock: SystemClock,
|
||||
private val sessionObserver: SessionObserver,
|
||||
) : ActiveLiveLocationShareManager, LiveLocationReceiver {
|
||||
private val isSetup = AtomicBoolean(false)
|
||||
private val cachedRooms = ConcurrentHashMap<RoomId, JoinedRoom>()
|
||||
private val timeoutJobs = ConcurrentHashMap<RoomId, Job>()
|
||||
private val syncedActiveShareIds = MutableStateFlow<Set<BeaconId>>(emptySet())
|
||||
private val localSharingRoomIds = MutableStateFlow<Set<RoomId>>(emptySet())
|
||||
override val sharingRoomIds: StateFlow<Set<RoomId>> = localSharingRoomIds
|
||||
|
||||
override suspend fun setup() = withContext(NonCancellable) {
|
||||
if (isSetup.compareAndSet(expectedValue = false, newValue = true)) {
|
||||
Timber.d("ActiveLiveLocationShareManager setup manager.")
|
||||
|
||||
recoverPersistedShares()
|
||||
|
||||
matrixClient.ownBeaconInfoUpdates
|
||||
.onEach { update ->
|
||||
Timber.d("Received beaconInfoUpdate:$update")
|
||||
// First cancel the local share in this room if any.
|
||||
if (update.roomId in localSharingRoomIds.value) {
|
||||
stopLocalShare(roomId = update.roomId)
|
||||
}
|
||||
syncedActiveShareIds.update {
|
||||
if (update.isLive) {
|
||||
it + update.beaconId
|
||||
} else {
|
||||
it - update.beaconId
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(matrixClient.sessionCoroutineScope)
|
||||
|
||||
sessionObserver.addListener(sessionListener)
|
||||
}
|
||||
}
|
||||
|
||||
private val sessionListener: SessionListener = object : SessionListener {
|
||||
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
|
||||
if (matrixClient.sessionId.value == userId) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit> = withContext(NonCancellable) {
|
||||
Timber.d("ActiveLiveLocationShareManager starting share for room $roomId with duration ${duration.inWholeSeconds}s")
|
||||
val room = cachedRooms.getOrPut(roomId) {
|
||||
matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
|
||||
}
|
||||
// Before starting a new location share, stop the current one if any is active.
|
||||
room.stopLiveLocationShare()
|
||||
|
||||
room.startLiveLocationShare(duration.inWholeMilliseconds)
|
||||
.onSuccess { beaconId ->
|
||||
Timber.d("ActiveLiveLocationShareManager wait remote echo of $beaconId")
|
||||
syncedActiveShareIds.first { beaconIds -> beaconIds.contains(beaconId) }
|
||||
val expiresAt = Instant.fromEpochMilliseconds(clock.epochMillis() + duration.inWholeMilliseconds)
|
||||
startLocalShare(roomId, expiresAt)
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "ActiveLiveLocationShareManager failed to start share for room $roomId")
|
||||
stopLocalShare(roomId)
|
||||
}
|
||||
.map { }
|
||||
}
|
||||
|
||||
override suspend fun stopShare(roomId: RoomId): Result<Unit> = withContext(NonCancellable) {
|
||||
Timber.d("ActiveLiveLocationShareManager stopping share for room $roomId")
|
||||
val room = cachedRooms.getOrPut(roomId) {
|
||||
matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
|
||||
}
|
||||
room.stopLiveLocationShare()
|
||||
.onSuccess {
|
||||
Timber.d("ActiveLiveLocationShareManager share stopped successfully for room $roomId")
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "ActiveLiveLocationShareManager failed to stop share for room $roomId")
|
||||
}
|
||||
.also {
|
||||
stopLocalShare(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onLocationUpdate(location: Location) {
|
||||
val activeSharesCount = localSharingRoomIds.value.size
|
||||
Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)")
|
||||
localSharingRoomIds.value.forEach { roomId ->
|
||||
Timber.d("ActiveLiveLocationShareManager sending location to room $roomId")
|
||||
sendLiveLocation(roomId, location)
|
||||
.onFailure {
|
||||
Timber.e(it, "ActiveLiveLocationShareManager failed to send location to room $roomId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result<Unit> {
|
||||
val room = cachedRooms.getOrPut(roomId) {
|
||||
matrixClient.getJoinedRoom(roomId) ?: return Result.failure(IllegalStateException("No room found for $roomId"))
|
||||
}
|
||||
return room.sendLiveLocation(location.toGeoUri())
|
||||
.recoverCatching { exception ->
|
||||
when (exception) {
|
||||
is LiveLocationException.NotLive -> {
|
||||
stopLocalShare(roomId)
|
||||
throw exception
|
||||
}
|
||||
else -> throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startLocalShare(roomId: RoomId, expiresAt: Instant) {
|
||||
val wasEmpty = localSharingRoomIds.value.isEmpty()
|
||||
Timber.d("ActiveLiveLocationShareManager share started successfully for room $roomId (wasEmpty=$wasEmpty)")
|
||||
localSharingRoomIds.update { it + roomId }
|
||||
liveLocationStore.setLiveLocationExpiry(roomId, expiresAt)
|
||||
scheduleTimeout(roomId, expiresAt)
|
||||
if (wasEmpty) {
|
||||
Timber.d("ActiveLiveLocationShareManager registering with coordinator for session ${matrixClient.sessionId}")
|
||||
coordinator.register(matrixClient.sessionId, this@DefaultActiveLiveLocationShareManager)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recoverPersistedShares() {
|
||||
val now = Instant.fromEpochMilliseconds(clock.epochMillis())
|
||||
liveLocationStore.getLiveLocationExpiries().forEach { (roomId, expiresAt) ->
|
||||
if (expiresAt > now) {
|
||||
// Only starts locally as the share is already started remotely
|
||||
startLocalShare(roomId, expiresAt)
|
||||
} else {
|
||||
// Explicitly stop the share on the server.
|
||||
stopShare(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) {
|
||||
timeoutJobs.remove(roomId)?.cancel()
|
||||
val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis()
|
||||
timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch {
|
||||
delay(delayMillis)
|
||||
stopShare(roomId)
|
||||
.onFailure { error ->
|
||||
Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stopLocalShare(roomId: RoomId) {
|
||||
Timber.d("ActiveLiveLocationShareManager stop local share in $roomId")
|
||||
timeoutJobs.remove(roomId)?.cancel()
|
||||
val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty()
|
||||
cachedRooms.remove(roomId)?.close()
|
||||
liveLocationStore.removeLiveLocationExpiry(roomId)
|
||||
if (wasSharing && localSharingRoomIds.value.isEmpty()) {
|
||||
Timber.d("ActiveLiveLocationShareManager unregistering from coordinator for session ${matrixClient.sessionId}")
|
||||
coordinator.unregister(matrixClient.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clear() {
|
||||
Timber.d("ActiveLiveLocationShareManager clear state")
|
||||
sessionObserver.removeListener(sessionListener)
|
||||
coordinator.unregister(matrixClient.sessionId)
|
||||
liveLocationStore.clear()
|
||||
for (room in cachedRooms.values) {
|
||||
room.close()
|
||||
timeoutJobs[room.roomId]?.cancel()
|
||||
}
|
||||
timeoutJobs.clear()
|
||||
cachedRooms.clear()
|
||||
localSharingRoomIds.value = emptySet()
|
||||
syncedActiveShareIds.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.impl.live
|
||||
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.androidutils.hash.hash
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||
import kotlinx.coroutines.flow.first
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Instant
|
||||
|
||||
private const val LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR = "="
|
||||
|
||||
@Inject
|
||||
@SingleIn(SessionScope::class)
|
||||
class LiveLocationStore(
|
||||
preferenceDataStoreFactory: PreferenceDataStoreFactory,
|
||||
sessionId: SessionId,
|
||||
) {
|
||||
private val store = preferenceDataStoreFactory.create("location_${sessionId.value.hash().take(16)}")
|
||||
private val acceptedLiveLocationDisclaimerKey = booleanPreferencesKey("live_location_disclaimer_accepted")
|
||||
private val liveLocationExpiriesKey = stringSetPreferencesKey("live_location_expiries")
|
||||
|
||||
suspend fun hasAcceptedLiveLocationDisclaimer(): Boolean = runCatchingExceptions {
|
||||
store.data.first()[acceptedLiveLocationDisclaimerKey] ?: false
|
||||
}.getOrDefault(false)
|
||||
|
||||
suspend fun setAcceptedLiveLocationDisclaimer(): Result<Unit> = runCatchingExceptions {
|
||||
store.edit { prefs ->
|
||||
prefs[acceptedLiveLocationDisclaimerKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLiveLocationExpiries(): Map<RoomId, Instant> = runCatchingExceptions {
|
||||
val serialized = store.data.first()[liveLocationExpiriesKey].orEmpty()
|
||||
decodeLiveLocationExpiries(serialized)
|
||||
}.onFailure { error ->
|
||||
Timber.e(error, "Failed to decode live location expiry payload")
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
suspend fun setLiveLocationExpiry(roomId: RoomId, expiresAt: Instant): Result<Unit> = runCatchingExceptions {
|
||||
store.edit { prefs ->
|
||||
val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
|
||||
prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(current + (roomId to expiresAt))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeLiveLocationExpiry(roomId: RoomId): Result<Unit> = runCatchingExceptions {
|
||||
store.edit { prefs ->
|
||||
val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
|
||||
val updated = current - roomId
|
||||
if (updated.isEmpty()) {
|
||||
prefs.remove(liveLocationExpiriesKey)
|
||||
} else {
|
||||
prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeLiveLocationExpiries(serialized: Set<String>): Map<RoomId, Instant> {
|
||||
return runCatchingExceptions {
|
||||
serialized
|
||||
.map { it.split(LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR) }
|
||||
.associate { values ->
|
||||
val roomId = RoomId(values[0])
|
||||
val expiresAtMillis = values[1].toLong()
|
||||
roomId to Instant.fromEpochMilliseconds(expiresAtMillis)
|
||||
}
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun encodeLiveLocationExpiries(expiries: Map<RoomId, Instant>): Set<String> {
|
||||
return expiries.entries.map { (roomId, expiresAt) ->
|
||||
"${roomId.value}$LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR${expiresAt.toEpochMilliseconds()}"
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
store.edit { prefs -> prefs.clear() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.impl.live.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.ChecksSdkIntAtLeast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Inject
|
||||
class LiveLocationSharingNotificationCreator(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val buildMeta: BuildMeta,
|
||||
) {
|
||||
companion object {
|
||||
const val CHANNEL_ID = "LIVE_LOCATION_SHARING"
|
||||
}
|
||||
|
||||
fun createNotification(): Notification {
|
||||
if (supportNotificationChannels()) {
|
||||
ensureChannelExists()
|
||||
}
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
||||
.setContentTitle(context.getString(CommonStrings.live_location_sharing_foreground_service_title_android, buildMeta.applicationName))
|
||||
.setContentText(context.getString(CommonStrings.live_location_sharing_foreground_service_message_android))
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun ensureChannelExists() {
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(CommonStrings.live_location_sharing_foreground_service_channel_title_android)
|
||||
.ifEmpty { "Live Location Sharing" },
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
|
||||
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.impl.live.service
|
||||
|
||||
import io.element.android.features.location.api.Location
|
||||
|
||||
fun interface LiveLocationReceiver {
|
||||
suspend fun onLocationUpdate(location: Location)
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.impl.live.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.concurrent.atomics.AtomicLong
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val THROTTLE_WINDOW = 3.seconds
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class LiveLocationSharingCoordinator internal constructor(
|
||||
private val startService: () -> Unit,
|
||||
private val stopService: () -> Unit,
|
||||
private val nowMillis: () -> Long,
|
||||
) {
|
||||
@Inject
|
||||
constructor(@ApplicationContext context: Context, clock: SystemClock) : this(
|
||||
startService = {
|
||||
ContextCompat.startForegroundService(context, Intent(context, LiveLocationSharingService::class.java))
|
||||
},
|
||||
stopService = {
|
||||
context.stopService(Intent(context, LiveLocationSharingService::class.java))
|
||||
},
|
||||
nowMillis = clock::epochMillis
|
||||
)
|
||||
|
||||
private val receivers = ConcurrentHashMap<SessionId, LiveLocationReceiver>()
|
||||
|
||||
private val lastDispatchMillis = AtomicLong(0L)
|
||||
private val lastKnownLocation = AtomicReference<Location?>(null)
|
||||
|
||||
suspend fun register(sessionId: SessionId, receiver: LiveLocationReceiver) {
|
||||
val wasEmpty = receivers.isEmpty()
|
||||
Timber.d("LiveLocationSharingCoordinator registering receiver for session $sessionId (wasEmpty=$wasEmpty)")
|
||||
receivers[sessionId] = receiver
|
||||
if (wasEmpty) {
|
||||
Timber.d("LiveLocationSharingCoordinator starting service")
|
||||
runCatchingExceptions(startService).onFailure {
|
||||
Timber.e(it, "Failed to start live location sharing service")
|
||||
}
|
||||
}
|
||||
lastKnownLocation.load()?.let {
|
||||
dispatch(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun unregister(sessionId: SessionId) {
|
||||
Timber.d("LiveLocationSharingCoordinator unregistering receiver for session $sessionId")
|
||||
receivers.remove(sessionId)
|
||||
if (receivers.isEmpty()) {
|
||||
lastKnownLocation.store(null)
|
||||
Timber.d("LiveLocationSharingCoordinator stopping service (no more receivers)")
|
||||
runCatchingExceptions(stopService).onFailure {
|
||||
Timber.e(it, "Failed to stop live location sharing service")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun dispatch(location: Location) {
|
||||
val currentTimeMillis = nowMillis()
|
||||
val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load()
|
||||
if (millisSincePrevious < THROTTLE_WINDOW.inWholeMilliseconds) {
|
||||
Timber.d("Received location before $THROTTLE_WINDOW, ignore.")
|
||||
return
|
||||
}
|
||||
lastKnownLocation.store(location)
|
||||
lastDispatchMillis.store(currentTimeMillis)
|
||||
receivers.forEach { (sessionId, receiver) ->
|
||||
Timber.d("Dispatch received location for session $sessionId ")
|
||||
runCatchingExceptions {
|
||||
receiver.onLocationUpdate(location)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to dispatch live location update for session $sessionId")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.impl.live.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.ServiceCompat
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.location.impl.di.LocationBindings
|
||||
import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.core.coroutine.childScope
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.maplibre.compose.location.AndroidLocationProvider
|
||||
import org.maplibre.compose.location.DesiredAccuracy
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import io.element.android.features.location.api.Location as ApiLocation
|
||||
|
||||
private const val UPDATE_INTERVAL_IN_SECOND = 10
|
||||
|
||||
class LiveLocationSharingService : Service() {
|
||||
@Inject lateinit var coordinator: LiveLocationSharingCoordinator
|
||||
@Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator
|
||||
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||
|
||||
@Inject lateinit var appForegroundStateService: AppForegroundStateService
|
||||
|
||||
@AppCoroutineScope
|
||||
@Inject lateinit var appCoroutineScope: CoroutineScope
|
||||
private lateinit var coroutineScope: CoroutineScope
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? = null
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@SuppressLint("InlinedApi")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Timber.d("LiveLocationSharingService onCreate")
|
||||
runCatchingExceptions {
|
||||
bindings<LocationBindings>().inject(this)
|
||||
appForegroundStateService.updateIsSharingLiveLocation(true)
|
||||
coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService")
|
||||
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION)
|
||||
Timber.d("LiveLocationSharingService starting foreground service with notificationId=$notificationId")
|
||||
ServiceCompat.startForeground(
|
||||
// service =
|
||||
this,
|
||||
// id =
|
||||
notificationId,
|
||||
// notification =
|
||||
notificationCreator.createNotification(),
|
||||
// foregroundServiceType =
|
||||
FOREGROUND_SERVICE_TYPE_LOCATION,
|
||||
)
|
||||
startLocationUpdatesListener()
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to start live location sharing service")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun startLocationUpdatesListener() {
|
||||
Timber.d("LiveLocationSharingService listening to location updates")
|
||||
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
|
||||
.flatMapLatest { minDistanceMeters ->
|
||||
val locationProvider = AndroidLocationProvider(
|
||||
context = applicationContext,
|
||||
updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
|
||||
minDistanceMeters = minDistanceMeters.toFloat(),
|
||||
desiredAccuracy = DesiredAccuracy.Balanced,
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
locationProvider.location
|
||||
}
|
||||
.filterNotNull()
|
||||
.map { location ->
|
||||
ApiLocation(
|
||||
lat = location.position.latitude,
|
||||
lon = location.position.longitude,
|
||||
accuracy = location.accuracy.toFloat(),
|
||||
)
|
||||
}
|
||||
.onEach(coordinator::dispatch)
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Timber.d("LiveLocationSharingService onStartCommand startId=$startId")
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Timber.d("LiveLocationSharingService onDestroy")
|
||||
if (::coroutineScope.isInitialized) {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
appForegroundStateService.updateIsSharingLiveLocation(false)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,8 @@ sealed interface ShareLocationEvent {
|
|||
val isPinned: Boolean,
|
||||
) : ShareLocationEvent
|
||||
|
||||
data object ShowLiveLocationDurationPicker : ShareLocationEvent
|
||||
data object InitiateLiveLocationShare : ShareLocationEvent
|
||||
data object AcceptLiveLocationDisclaimer : ShareLocationEvent
|
||||
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
|
||||
|
||||
data object StartTrackingUserLocation : ShareLocationEvent
|
||||
|
|
|
|||
|
|
@ -21,17 +21,22 @@ import dev.zacsweers.metro.Assisted
|
|||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
||||
import io.element.android.features.location.impl.common.MapDefaults
|
||||
import io.element.android.features.location.impl.common.SendLiveLocationPermissions
|
||||
import io.element.android.features.location.impl.common.actions.LocationActions
|
||||
import io.element.android.features.location.impl.common.checkLocationConstraints
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||
import io.element.android.features.location.impl.common.sendLiveLocationPermissions
|
||||
import io.element.android.features.location.impl.common.toDialogState
|
||||
import io.element.android.features.location.impl.share.ShareLocationState.Dialog.Constraints
|
||||
import io.element.android.features.location.impl.live.LiveLocationStore
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.dateformatter.api.DurationFormatter
|
||||
|
|
@ -41,6 +46,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
|||
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
|
@ -63,6 +69,8 @@ class ShareLocationPresenter(
|
|||
private val featureFlagService: FeatureFlagService,
|
||||
private val client: MatrixClient,
|
||||
private val durationFormatter: DurationFormatter,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val liveLocationStore: LiveLocationStore,
|
||||
) : Presenter<ShareLocationState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
|
|
@ -82,15 +90,39 @@ class ShareLocationPresenter(
|
|||
var dialogState: ShareLocationState.Dialog by remember {
|
||||
mutableStateOf(ShareLocationState.Dialog.None)
|
||||
}
|
||||
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
val currentUser by client.userProfile.collectAsState()
|
||||
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
|
||||
perms.sendLiveLocationPermissions()
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun checkLocationConstraints() {
|
||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
||||
dialogState = Constraints(locationConstraints.toDialogState())
|
||||
// No need to check SendLiveLocationPermissions here
|
||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
|
||||
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
|
||||
}
|
||||
|
||||
suspend fun computeLiveLocationDialogState(): ShareLocationState.Dialog {
|
||||
val hasAcceptedDisclaimer = liveLocationStore.hasAcceptedLiveLocationDisclaimer()
|
||||
val constraintsResult = checkLocationConstraints(permissionsState, locationActions, sendLiveLocationPermissions)
|
||||
return when {
|
||||
!hasAcceptedDisclaimer -> {
|
||||
ShareLocationState.Dialog.LiveLocationDisclaimer
|
||||
}
|
||||
constraintsResult is LocationConstraintsCheck.Success -> {
|
||||
val durations = LIVE_LOCATION_DURATIONS.map {
|
||||
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
|
||||
}
|
||||
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
|
||||
}
|
||||
else -> {
|
||||
ShareLocationState.Dialog.Constraints(constraintsResult.toDialogState())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
|
||||
|
||||
fun handleEvent(event: ShareLocationEvent) {
|
||||
|
|
@ -109,20 +141,23 @@ class ShareLocationPresenter(
|
|||
locationActions.openLocationSettings()
|
||||
dialogState = ShareLocationState.Dialog.None
|
||||
}
|
||||
ShareLocationEvent.ShowLiveLocationDurationPicker -> {
|
||||
val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
|
||||
dialogState = if (constraintsResult is LocationConstraintsCheck.Success) {
|
||||
val durations = LIVE_LOCATION_DURATIONS.map {
|
||||
LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
|
||||
ShareLocationEvent.InitiateLiveLocationShare -> scope.launch {
|
||||
dialogState = computeLiveLocationDialogState()
|
||||
}
|
||||
ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
|
||||
liveLocationStore.setAcceptedLiveLocationDisclaimer()
|
||||
.onSuccess {
|
||||
dialogState = computeLiveLocationDialogState()
|
||||
}
|
||||
ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
|
||||
} else {
|
||||
Constraints(constraintsResult.toDialogState())
|
||||
}
|
||||
}
|
||||
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
|
||||
dialogState = ShareLocationState.Dialog.None
|
||||
// room.startLiveLocationShare(event.duration.inWholeMilliseconds)
|
||||
startLiveLocationAction.runUpdatingState {
|
||||
liveLocationShareManager.startShare(
|
||||
roomId = room.roomId,
|
||||
duration = event.duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
ShareLocationEvent.RequestPermissions -> {
|
||||
dialogState = ShareLocationState.Dialog.None
|
||||
|
|
@ -138,6 +173,7 @@ class ShareLocationPresenter(
|
|||
hasLocationPermission = permissionsState.isAnyGranted,
|
||||
canShareLiveLocation = isLiveLocationSharingEnabled,
|
||||
appName = appName,
|
||||
startLiveLocationAction = startLiveLocationAction.value,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.features.location.impl.share
|
||||
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
|
|
@ -19,11 +20,13 @@ data class ShareLocationState(
|
|||
val hasLocationPermission: Boolean,
|
||||
val appName: String,
|
||||
val canShareLiveLocation: Boolean,
|
||||
val startLiveLocationAction: AsyncAction<Unit>,
|
||||
val eventSink: (ShareLocationEvent) -> Unit,
|
||||
) {
|
||||
sealed interface Dialog {
|
||||
data object None : Dialog
|
||||
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
|
||||
data object LiveLocationDisclaimer : Dialog
|
||||
data class LiveLocationDurations(val durations: ImmutableList<LiveLocationDuration>) : Dialog
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -51,6 +52,18 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
|
|||
trackUserPosition = true,
|
||||
hasLocationPermission = true,
|
||||
),
|
||||
aShareLocationState(
|
||||
dialogState = ShareLocationState.Dialog.None,
|
||||
trackUserPosition = true,
|
||||
hasLocationPermission = true,
|
||||
canShareLiveLocation = true,
|
||||
),
|
||||
aShareLocationState(
|
||||
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
|
||||
trackUserPosition = true,
|
||||
hasLocationPermission = true,
|
||||
canShareLiveLocation = true,
|
||||
),
|
||||
aShareLocationState(
|
||||
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
|
||||
persistentListOf(
|
||||
|
|
@ -73,6 +86,7 @@ fun aShareLocationState(
|
|||
hasLocationPermission: Boolean = false,
|
||||
canShareLiveLocation: Boolean = false,
|
||||
appName: String = APP_NAME,
|
||||
startLiveLocationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (ShareLocationEvent) -> Unit = {},
|
||||
): ShareLocationState {
|
||||
return ShareLocationState(
|
||||
|
|
@ -82,6 +96,7 @@ fun aShareLocationState(
|
|||
hasLocationPermission = hasLocationPermission,
|
||||
canShareLiveLocation = canShareLiveLocation,
|
||||
appName = appName,
|
||||
startLiveLocationAction = startLiveLocationAction,
|
||||
eventSink = eventSink
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -44,11 +43,16 @@ import io.element.android.features.location.impl.common.ui.LocationFloatingActio
|
|||
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
||||
import io.element.android.features.location.impl.common.ui.UserLocationPuck
|
||||
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
|
||||
import io.element.android.libraries.androidutils.system.toast
|
||||
import io.element.android.features.location.impl.share.ShareLocationEvent.StartLiveLocationShare
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.LocationPin
|
||||
import io.element.android.libraries.designsystem.components.PinVariant
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
|
||||
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
|
||||
|
|
@ -74,7 +78,6 @@ fun ShareLocationView(
|
|||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
when (val dialogState = state.dialogState) {
|
||||
ShareLocationState.Dialog.None -> Unit
|
||||
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
|
||||
|
|
@ -85,12 +88,17 @@ fun ShareLocationView(
|
|||
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
|
||||
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||
)
|
||||
ShareLocationState.Dialog.LiveLocationDisclaimer -> ConfirmationDialog(
|
||||
content = stringResource(R.string.screen_share_location_live_location_disclaimer_title),
|
||||
submitText = stringResource(CommonStrings.action_accept),
|
||||
cancelText = stringResource(CommonStrings.action_decline),
|
||||
onSubmitClick = { state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer) },
|
||||
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||
)
|
||||
is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
|
||||
durations = dialogState.durations,
|
||||
onSelectDuration = { duration ->
|
||||
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))
|
||||
context.toast("Not implemented yet!")
|
||||
navigateUp()
|
||||
state.eventSink(StartLiveLocationShare(duration))
|
||||
},
|
||||
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
|
||||
)
|
||||
|
|
@ -160,10 +168,46 @@ fun ShareLocationView(
|
|||
.align(Alignment.TopEnd)
|
||||
.padding(all = 16.dp),
|
||||
)
|
||||
StartLiveLocationActionView(state.startLiveLocationAction, navigateUp)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartLiveLocationActionView(
|
||||
action: AsyncAction<Unit>,
|
||||
onActionSuccess: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
val asyncIndicatorState = rememberAsyncIndicatorState()
|
||||
AsyncIndicatorHost(state = asyncIndicatorState)
|
||||
|
||||
when (action) {
|
||||
is AsyncAction.Loading -> {
|
||||
LaunchedEffect(action) {
|
||||
asyncIndicatorState.enqueue {
|
||||
AsyncIndicator.Loading(text = stringResource(CommonStrings.common_waiting_live_location))
|
||||
}
|
||||
}
|
||||
}
|
||||
is AsyncAction.Failure -> {
|
||||
LaunchedEffect(action) {
|
||||
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
|
||||
AsyncIndicator.Failure(
|
||||
text = stringResource(CommonStrings.common_something_went_wrong),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is AsyncAction.Success -> {
|
||||
LaunchedEffect(action) { onActionSuccess() }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomSheetContent(
|
||||
cameraState: CameraState,
|
||||
|
|
@ -202,7 +246,7 @@ private fun BottomSheetContent(
|
|||
}
|
||||
if (state.canShareLiveLocation) {
|
||||
ShareLiveLocationItem {
|
||||
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
|
|||
data object RequestPermissions : ShowLocationEvent
|
||||
data object OpenAppSettings : ShowLocationEvent
|
||||
data object OpenLocationSettings : ShowLocationEvent
|
||||
data object StopLocationSharing : ShowLocationEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,17 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.ShowLocationMode
|
||||
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
||||
import io.element.android.features.location.impl.common.MapDefaults
|
||||
import io.element.android.features.location.impl.common.SendLiveLocationPermissions
|
||||
import io.element.android.features.location.impl.common.actions.LocationActions
|
||||
import io.element.android.features.location.impl.common.checkLocationConstraints
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||
|
|
@ -45,6 +48,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AssistedInject
|
||||
class ShowLocationPresenter(
|
||||
|
|
@ -55,6 +59,7 @@ class ShowLocationPresenter(
|
|||
private val dateFormatter: DateFormatter,
|
||||
private val stringProvider: StringProvider,
|
||||
private val joinedRoom: JoinedRoom,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
) : Presenter<ShowLocationState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
|
|
@ -65,6 +70,7 @@ class ShowLocationPresenter(
|
|||
|
||||
@Composable
|
||||
override fun present(): ShowLocationState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val permissionsState: PermissionsState = permissionsPresenter.present()
|
||||
var isTrackMyLocation by remember { mutableStateOf(false) }
|
||||
val appName by remember { derivedStateOf { buildMeta.applicationName } }
|
||||
|
|
@ -85,7 +91,7 @@ class ShowLocationPresenter(
|
|||
}
|
||||
is ShowLocationEvent.TrackMyLocation -> {
|
||||
if (event.enabled) {
|
||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
|
||||
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
|
||||
dialogState = locationConstraints.toDialogState()
|
||||
} else {
|
||||
|
|
@ -102,6 +108,9 @@ class ShowLocationPresenter(
|
|||
dialogState = LocationConstraintsDialogState.None
|
||||
}
|
||||
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
ShowLocationEvent.StopLocationSharing -> coroutineScope.launch {
|
||||
liveLocationShareManager.stopShare(joinedRoom.roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +136,7 @@ class ShowLocationPresenter(
|
|||
location = mode.location,
|
||||
isLive = false,
|
||||
assetType = mode.assetType,
|
||||
isOwnUser = mode.senderId == joinedRoom.sessionId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -163,6 +173,7 @@ class ShowLocationPresenter(
|
|||
location = location,
|
||||
isLive = true,
|
||||
assetType = lastLocation.assetType,
|
||||
isOwnUser = share.userId == joinedRoom.sessionId
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ data class LocationShareItem(
|
|||
val location: Location,
|
||||
val isLive: Boolean,
|
||||
val assetType: AssetType?,
|
||||
)
|
||||
val isOwnUser: Boolean
|
||||
) {
|
||||
val canStopSharing = isLive && isOwnUser
|
||||
}
|
||||
|
||||
fun LocationShareItem.toMarkerData(): LocationMarkerData {
|
||||
val pinVariant = if (assetType == AssetType.PIN) {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ fun aLocationShareItem(
|
|||
assetType: AssetType? = null,
|
||||
formattedTimestamp: String = "Shared 1 min ago",
|
||||
location: Location = Location(1.23, 2.34, 4f),
|
||||
isOwnUser: Boolean = false,
|
||||
) = LocationShareItem(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
|
|
@ -89,4 +90,5 @@ fun aLocationShareItem(
|
|||
location = location,
|
||||
isLive = isLive,
|
||||
assetType = assetType,
|
||||
isOwnUser = isOwnUser,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ fun ShowLocationView(
|
|||
LocationShareRow(
|
||||
item = locationShare,
|
||||
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
|
||||
onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) },
|
||||
modifier = Modifier.clickable {
|
||||
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||
val position = CameraPosition(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = false)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
|
||||
}
|
||||
|
|
@ -71,8 +71,20 @@ class LocationConstraintsCheckTest {
|
|||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||
|
||||
val result = checkLocationConstraints(permissionsState, locationActions)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkLocationConstraints returns NotEnoughPowerLevel when send permissions are not granted`() {
|
||||
val permissionsState = aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.NoneGranted,
|
||||
shouldShowRationale = false,
|
||||
)
|
||||
val locationActions = FakeLocationActions(isLocationEnabled = true)
|
||||
val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.DEFAULT)
|
||||
|
||||
assertThat(result).isEqualTo(LocationConstraintsCheck.NotEnoughPowerLevel)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ import com.bumble.appyx.core.modality.BuildContext
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||
import io.element.android.features.location.impl.live.LiveLocationStore
|
||||
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
|
|
@ -20,8 +22,10 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
|||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -30,16 +34,17 @@ class DefaultShareLocationEntryPointTest {
|
|||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@Test
|
||||
fun `test node builder`() {
|
||||
fun `test node builder`() = runTest {
|
||||
val entryPoint = DefaultShareLocationEntryPoint()
|
||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||
val room = FakeJoinedRoom()
|
||||
ShareLocationNode(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
presenterFactory = { timelineMode: Timeline.Mode ->
|
||||
ShareLocationPresenter(
|
||||
permissionsPresenterFactory = { FakePermissionsPresenter() },
|
||||
room = FakeJoinedRoom(),
|
||||
room = room,
|
||||
timelineMode = timelineMode,
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
messageComposerContext = FakeMessageComposerContext(),
|
||||
|
|
@ -48,6 +53,11 @@ class DefaultShareLocationEntryPointTest {
|
|||
featureFlagService = FakeFeatureFlagService(),
|
||||
client = FakeMatrixClient(),
|
||||
durationFormatter = FakeDurationFormatter(),
|
||||
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
liveLocationStore = LiveLocationStore(
|
||||
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||
sessionId = room.sessionId,
|
||||
),
|
||||
)
|
||||
},
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
|
||||
package io.element.android.features.location.impl.share
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
|
|
@ -22,28 +25,46 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
|
|||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.features.location.impl.live.LiveLocationStore
|
||||
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
class ShareLocationPresenterTest {
|
||||
@get:Rule
|
||||
|
|
@ -59,9 +80,11 @@ class ShareLocationPresenterTest {
|
|||
|
||||
private val durationFormatter = FakeDurationFormatter()
|
||||
|
||||
private fun createShareLocationPresenter(
|
||||
private fun TestScope.createShareLocationPresenter(
|
||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||
locationActions: FakeLocationActions = fakeLocationActions,
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
|
||||
): ShareLocationPresenter = ShareLocationPresenter(
|
||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||
room = joinedRoom,
|
||||
|
|
@ -73,6 +96,8 @@ class ShareLocationPresenterTest {
|
|||
featureFlagService = fakeFeatureFlagService,
|
||||
client = fakeMatrixClient,
|
||||
durationFormatter = durationFormatter,
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
liveLocationStore = liveLocationStore,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -296,7 +321,15 @@ class ShareLocationPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest {
|
||||
val shareLocationPresenter = createShareLocationPresenter()
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = grantedSendLiveLocationPermissions()
|
||||
)
|
||||
)
|
||||
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
|
||||
setAcceptedLiveLocationDisclaimer().getOrThrow()
|
||||
}
|
||||
val shareLocationPresenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
|
||||
fakePermissionsPresenter.givenState(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.AllGranted,
|
||||
|
|
@ -307,7 +340,7 @@ class ShareLocationPresenterTest {
|
|||
shareLocationPresenter.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||
initialState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
val durationDialogState = awaitItem()
|
||||
|
||||
assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
|
||||
|
|
@ -315,9 +348,155 @@ class ShareLocationPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ShowLiveLocationDurationPicker shows disclaimer when acceptance is missing`() = runTest {
|
||||
val presenter = createShareLocationPresenter()
|
||||
fakePermissionsPresenter.givenState(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.AllGranted,
|
||||
shouldShowRationale = false,
|
||||
)
|
||||
)
|
||||
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
|
||||
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
val dialogState = awaitItem()
|
||||
|
||||
assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AcceptLiveLocationDisclaimer persists acceptance and shows durations`() = runTest {
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = grantedSendLiveLocationPermissions()
|
||||
)
|
||||
)
|
||||
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId)
|
||||
val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
|
||||
fakePermissionsPresenter.givenState(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.AllGranted,
|
||||
shouldShowRationale = false,
|
||||
)
|
||||
)
|
||||
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
awaitItem()
|
||||
|
||||
state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer)
|
||||
val durationState = awaitItem()
|
||||
|
||||
assertThat(locationStore.hasAcceptedLiveLocationDisclaimer()).isTrue()
|
||||
assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AcceptLiveLocationDisclaimer keeps disclaimer gate active when persistence fails`() = runTest {
|
||||
val joinedRoom = FakeJoinedRoom()
|
||||
val presenter = createShareLocationPresenter(
|
||||
joinedRoom = joinedRoom,
|
||||
liveLocationStore = createFailingLiveLocationStore(sessionId = joinedRoom.sessionId),
|
||||
)
|
||||
fakePermissionsPresenter.givenState(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.AllGranted,
|
||||
shouldShowRationale = false,
|
||||
)
|
||||
)
|
||||
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
val disclaimerState = awaitItem()
|
||||
|
||||
disclaimerState.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer)
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ShowLiveLocationDurationPicker bypasses disclaimer when already accepted`() = runTest {
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = grantedSendLiveLocationPermissions()
|
||||
)
|
||||
)
|
||||
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
|
||||
setAcceptedLiveLocationDisclaimer().getOrThrow()
|
||||
}
|
||||
val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
|
||||
fakePermissionsPresenter.givenState(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.AllGranted,
|
||||
shouldShowRationale = false,
|
||||
)
|
||||
)
|
||||
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
|
||||
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
val durationState = awaitItem()
|
||||
|
||||
assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ShowLiveLocationDurationPicker uses the active session disclaimer state`() = runTest {
|
||||
val joinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = SessionId("@alice:server")))
|
||||
createLiveLocationStore(sessionId = SessionId("@bob:server"))
|
||||
.setAcceptedLiveLocationDisclaimer()
|
||||
.getOrThrow()
|
||||
val presenter = createShareLocationPresenter(
|
||||
joinedRoom = joinedRoom,
|
||||
liveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
|
||||
)
|
||||
fakePermissionsPresenter.givenState(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.AllGranted,
|
||||
shouldShowRationale = false,
|
||||
)
|
||||
)
|
||||
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
|
||||
state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
val dialogState = awaitItem()
|
||||
|
||||
assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
|
||||
val shareLocationPresenter = createShareLocationPresenter()
|
||||
val joinedRoom = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = grantedSendLiveLocationPermissions()
|
||||
)
|
||||
)
|
||||
val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
|
||||
setAcceptedLiveLocationDisclaimer().getOrThrow()
|
||||
}
|
||||
val shareLocationPresenter = createShareLocationPresenter(
|
||||
joinedRoom = joinedRoom,
|
||||
liveLocationStore = locationStore,
|
||||
)
|
||||
fakePermissionsPresenter.givenState(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.NoneGranted,
|
||||
|
|
@ -332,7 +511,7 @@ class ShareLocationPresenterTest {
|
|||
initialState.eventSink(ShareLocationEvent.DismissDialog)
|
||||
val dismissedState = awaitItem()
|
||||
|
||||
dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||
dismissedState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
|
||||
val constraintDialogState = awaitItem()
|
||||
|
||||
assertThat(constraintDialogState.dialogState).isEqualTo(
|
||||
|
|
@ -447,4 +626,62 @@ class ShareLocationPresenterTest {
|
|||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StartLiveLocationShare event calls manager startShare`() = runTest {
|
||||
val startShareLambda = lambdaRecorder { _: RoomId, _: Duration -> Result.success(Unit) }
|
||||
val manager = FakeActiveLiveLocationShareManager(
|
||||
startShareLambda = startShareLambda,
|
||||
)
|
||||
val shareLocationPresenter = createShareLocationPresenter(liveLocationShareManager = manager)
|
||||
fakePermissionsPresenter.givenState(
|
||||
aPermissionsState(
|
||||
permissions = PermissionsState.Permissions.AllGranted,
|
||||
shouldShowRationale = false,
|
||||
)
|
||||
)
|
||||
|
||||
shareLocationPresenter.test {
|
||||
skipItems(1)
|
||||
val state = awaitItem()
|
||||
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration = 1.hours))
|
||||
advanceUntilIdle()
|
||||
assert(startShareLambda).isCalledOnce().with(
|
||||
value(A_ROOM_ID),
|
||||
value(1.hours)
|
||||
)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLiveLocationStore(
|
||||
sessionId: SessionId = A_SESSION_ID,
|
||||
preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
|
||||
): LiveLocationStore {
|
||||
return LiveLocationStore(
|
||||
preferenceDataStoreFactory = preferenceDataStoreFactory,
|
||||
sessionId = sessionId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createFailingLiveLocationStore(sessionId: SessionId = A_SESSION_ID): LiveLocationStore {
|
||||
val failingPreferenceDataStoreFactory = object : PreferenceDataStoreFactory {
|
||||
override fun create(name: String): DataStore<Preferences> = object : DataStore<Preferences> {
|
||||
override val data: Flow<Preferences> = flowOf(emptyPreferences())
|
||||
|
||||
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||
error("Failed to update preferences")
|
||||
}
|
||||
}
|
||||
}
|
||||
return createLiveLocationStore(
|
||||
sessionId = sessionId,
|
||||
preferenceDataStoreFactory = failingPreferenceDataStoreFactory,
|
||||
)
|
||||
}
|
||||
|
||||
private fun grantedSendLiveLocationPermissions(): FakeRoomPermissions = FakeRoomPermissions(
|
||||
canSendState = { it is StateEventType.BeaconInfo },
|
||||
canSendMessage = { it is MessageEventType.Beacon }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -143,6 +143,38 @@ class ShareLocationViewTest {
|
|||
clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when disclaimer is displayed user can accept`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||
setShareLocationView(
|
||||
aShareLocationState(
|
||||
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
|
||||
eventSink = eventsRecorder,
|
||||
canShareLiveLocation = true,
|
||||
),
|
||||
navigateUp = EnsureNeverCalled(),
|
||||
)
|
||||
|
||||
clickOn(CommonStrings.action_accept)
|
||||
eventsRecorder.assertSingle(ShareLocationEvent.AcceptLiveLocationDisclaimer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when disclaimer is displayed user can decline`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<ShareLocationEvent>()
|
||||
setShareLocationView(
|
||||
aShareLocationState(
|
||||
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
|
||||
eventSink = eventsRecorder,
|
||||
canShareLiveLocation = true,
|
||||
),
|
||||
navigateUp = EnsureNeverCalled(),
|
||||
)
|
||||
|
||||
clickOn(CommonStrings.action_decline)
|
||||
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setShareLocationView(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import io.element.android.features.location.api.ShowLocationEntryPoint
|
|||
import io.element.android.features.location.api.ShowLocationMode
|
||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
|
||||
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
|
|
@ -34,6 +35,7 @@ class DefaultShowLocationEntryPointTest {
|
|||
fun `test node builder`() {
|
||||
val entryPoint = DefaultShowLocationEntryPoint()
|
||||
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||
val joinedRoom = FakeJoinedRoom()
|
||||
ShowLocationNode(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
|
|
@ -45,7 +47,8 @@ class DefaultShowLocationEntryPointTest {
|
|||
buildMeta = aBuildMeta(),
|
||||
dateFormatter = FakeDateFormatter(),
|
||||
stringProvider = FakeStringProvider(),
|
||||
joinedRoom = FakeJoinedRoom(),
|
||||
joinedRoom = joinedRoom,
|
||||
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
)
|
||||
},
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ package io.element.android.features.location.impl.show
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
|
||||
import org.junit.Test
|
||||
|
||||
class LiveLocationShareComparatorTest {
|
||||
|
|
@ -55,15 +55,3 @@ class LiveLocationShareComparatorTest {
|
|||
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
private fun aLiveLocationShare(
|
||||
userId: UserId,
|
||||
startTimestamp: Long,
|
||||
): LiveLocationShare {
|
||||
return LiveLocationShare(
|
||||
userId = userId,
|
||||
lastLocation = null,
|
||||
startTimestamp = startTimestamp,
|
||||
endTimestamp = startTimestamp + 1_000L,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,14 +20,15 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
|
|||
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
|
||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.location.LastLocation
|
||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.test
|
||||
|
|
@ -60,6 +61,7 @@ class ShowLocationPresenterTest {
|
|||
),
|
||||
locationActions: FakeLocationActions = fakeLocationActions,
|
||||
joinedRoom: JoinedRoom = FakeJoinedRoom(),
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
) = ShowLocationPresenter(
|
||||
mode = mode,
|
||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||
|
|
@ -68,6 +70,7 @@ class ShowLocationPresenterTest {
|
|||
dateFormatter = fakeDateFormatter,
|
||||
stringProvider = FakeStringProvider(),
|
||||
joinedRoom = joinedRoom,
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -205,7 +208,7 @@ class ShowLocationPresenterTest {
|
|||
)
|
||||
)
|
||||
val presenter = createShowLocationPresenter()
|
||||
presenter.test {
|
||||
presenter.test {
|
||||
// Skip initial state
|
||||
val initialState = awaitItem()
|
||||
|
||||
|
|
@ -464,23 +467,3 @@ class ShowLocationPresenterTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun aLiveLocationShare(
|
||||
userId: UserId,
|
||||
geoUri: String = "geo:48.8584,2.2945",
|
||||
timestamp: Long = 0L,
|
||||
startTimestamp: Long = 0L,
|
||||
endTimestamp: Long = Long.MAX_VALUE,
|
||||
assetType: AssetType = AssetType.SENDER,
|
||||
): LiveLocationShare {
|
||||
return LiveLocationShare(
|
||||
userId = userId,
|
||||
lastLocation = LastLocation(
|
||||
geoUri = geoUri,
|
||||
timestamp = timestamp,
|
||||
assetType = assetType,
|
||||
),
|
||||
startTimestamp = startTimestamp,
|
||||
endTimestamp = endTimestamp,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue