change: ensure UserLocationState is computed from the Presenters instead of Views

This commit is contained in:
ganfra
2026-06-03 21:52:54 +02:00
parent 1a73ab0094
commit 930aa4f1db
22 changed files with 265 additions and 228 deletions
@@ -1,42 +0,0 @@
/*
* 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 android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalInspectionMode
import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.spatialk.units.extensions.meters
import kotlin.time.Duration.Companion.seconds
class UserLocationState(locationState: State<Location?>) {
val location: Location? by locationState
}
@SuppressLint("MissingPermission")
@Composable
fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState {
val isPreview = LocalInspectionMode.current
val locationProvider = if (isPreview || !hasLocationPermission) {
rememberNullLocationProvider()
} else {
rememberPlatformLocationProvider(
updateInterval = 5.seconds,
desiredAccuracy = DesiredAccuracy.High,
minDistance = 5.meters,
)
}
val locationState = locationProvider.location.collectAsState()
return remember { UserLocationState(locationState) }
}
@@ -10,9 +10,8 @@ package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.UserLocationState
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes
@@ -20,40 +19,23 @@ import org.maplibre.compose.location.LocationPuckSizes
@Composable
fun UserLocationPuck(
cameraState: CameraState,
locationState: UserLocationState,
trackUserLocation: Boolean,
location: Location?,
) {
SimpleLocationTrackingEffect(
locationState = locationState,
enabled = trackUserLocation,
) { currentLocation ->
val target = currentLocation?.position?.value ?: cameraState.position.target
val newPosition = cameraState.position.copy(
target = target,
// Force pointing to NORTH
bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
LocationPuck(
idPrefix = "user-location",
location = location,
cameraState = cameraState,
accuracyThreshold = Float.POSITIVE_INFINITY,
showBearingAccuracy = false,
showBearing = false,
sizes = LocationPuckSizes(
dotRadius = 8.dp,
dotStrokeWidth = 2.dp,
),
colors = LocationPuckColors(
dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary,
dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary,
dotStrokeColor = ElementTheme.colors.bgCanvasDefault,
)
cameraState.animateTo(newPosition)
}
val location = locationState.location
if (location != null) {
LocationPuck(
idPrefix = "user-location",
location = location,
cameraState = cameraState,
accuracyThreshold = Float.POSITIVE_INFINITY,
showBearingAccuracy = false,
showBearing = false,
sizes = LocationPuckSizes(
dotRadius = 8.dp,
dotStrokeWidth = 2.dp,
),
colors = LocationPuckColors(
dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary,
dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary,
dotStrokeColor = ElementTheme.colors.bgCanvasDefault,
)
)
}
)
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common.userlocation
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.RoomScope
import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.spatialk.units.extensions.meters
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(RoomScope::class)
class DefaultUserLocationStateFactory : UserLocationState.Factory {
@Composable
override fun create(hasLocationPermission: Boolean): UserLocationState {
val locationProvider = if (!hasLocationPermission) {
rememberNullLocationProvider()
} else {
@SuppressLint("MissingPermission")
rememberPlatformLocationProvider(
updateInterval = 5.seconds,
desiredAccuracy = DesiredAccuracy.High,
minDistance = 5.meters,
)
}
val location by locationProvider.location.collectAsState()
return UserLocationState(location)
}
}
@@ -0,0 +1,26 @@
/*
* 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.userlocation
import io.element.android.features.location.api.Location
import org.maplibre.compose.location.PositionWithAccuracy
import org.maplibre.spatialk.geojson.Position
import org.maplibre.spatialk.units.extensions.meters
import kotlin.time.TimeSource
import org.maplibre.compose.location.Location as MapLibreLocation
fun Location.asMapLibreLocation(): MapLibreLocation {
return MapLibreLocation(
position = PositionWithAccuracy(
value = Position(latitude = lat, longitude = lon),
accuracy = accuracy?.toDouble()?.meters
),
// Not relevant as not used
timestamp = TimeSource.Monotonic.markNow(),
)
}
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common
package io.element.android.features.location.impl.common.userlocation
import android.Manifest
import android.annotation.SuppressLint
@@ -0,0 +1,18 @@
/*
* 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.userlocation
import androidx.compose.runtime.Composable
import org.maplibre.compose.location.Location
data class UserLocationState(val location: Location?) {
fun interface Factory {
@Composable
fun create(hasLocationPermission: Boolean): UserLocationState
}
}
@@ -5,15 +5,16 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common.ui
package io.element.android.features.location.impl.common.userlocation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import io.element.android.features.location.impl.common.UserLocationState
import io.element.android.features.location.impl.common.MapDefaults
import kotlinx.coroutines.flow.distinctUntilChanged
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.location.Location
import kotlin.math.abs
@@ -22,16 +23,17 @@ import kotlin.math.abs
* TODO remove once https://github.com/maplibre/maplibre-compose/issues/808 is fixed
*/
@Composable
internal fun SimpleLocationTrackingEffect(
internal fun UserLocationTrackingEffect(
locationState: UserLocationState,
enabled: Boolean = true,
precision: Double = 0.00001,
onLocationChange: suspend (Location?) -> Unit,
) {
val latestOnLocationChange by rememberUpdatedState(onLocationChange)
LaunchedEffect(locationState, enabled) {
val latestLocationState by rememberUpdatedState(locationState)
LaunchedEffect(enabled) {
if (!enabled) return@LaunchedEffect
val locationStateFlow = snapshotFlow { locationState.location }
val locationStateFlow = snapshotFlow { latestLocationState.location }
locationStateFlow
.distinctUntilChanged { oldLocation, newLocation ->
if (oldLocation != null && newLocation != null) {
@@ -49,3 +51,27 @@ internal fun SimpleLocationTrackingEffect(
}
}
}
@Composable
internal fun UserLocationTrackingEffect(
cameraState: CameraState,
locationState: UserLocationState,
enabled: Boolean = true,
precision: Double = 0.00001,
) {
UserLocationTrackingEffect(
locationState = locationState,
enabled = enabled,
precision = precision
) { location ->
val target = location?.position?.value ?: cameraState.position.target
cameraState.animateTo(
cameraState.position.copy(
target = target,
// Force pointing to NORTH
bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
)
)
}
}
@@ -14,7 +14,7 @@ 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.common.PlatformLocationProvider
import io.element.android.features.location.impl.common.userlocation.PlatformLocationProvider
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
@@ -33,6 +33,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsP
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.sendLiveLocationPermissions
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.AsyncAction
@@ -70,6 +71,7 @@ class ShareLocationPresenter(
private val durationFormatter: DurationFormatter,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore,
private val userLocationStateFactory: UserLocationState.Factory,
) : Presenter<ShareLocationState> {
@AssistedFactory
fun interface Factory {
@@ -123,6 +125,8 @@ class ShareLocationPresenter(
}
}
val userLocationState = userLocationStateFactory.create(permissionsState.isAnyGranted)
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
fun handleEvent(event: ShareLocationEvent) {
@@ -171,7 +175,7 @@ class ShareLocationPresenter(
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
hasLocationPermission = permissionsState.isAnyGranted,
userLocationState = userLocationState,
canShareLiveLocation = timelineMode.canShareLiveLocation(),
appName = appName,
startLiveLocationAction = startLiveLocationAction.value,
@@ -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.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -19,7 +20,7 @@ data class ShareLocationState(
val currentUser: MatrixUser,
val dialogState: Dialog,
val trackUserLocation: Boolean,
val hasLocationPermission: Boolean,
val userLocationState: UserLocationState,
val appName: String,
val canShareLiveLocation: Boolean,
val startLiveLocationAction: AsyncAction<Unit>,
@@ -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.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
@@ -26,43 +27,35 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false,
hasLocationPermission = false,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
trackUserPosition = false,
hasLocationPermission = false,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
trackUserPosition = false,
hasLocationPermission = false,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
trackUserPosition = false,
hasLocationPermission = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false,
hasLocationPermission = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
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(
@@ -74,7 +67,6 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
)
),
trackUserPosition = true,
hasLocationPermission = true,
canShareLiveLocation = true,
),
aShareLocationState(
@@ -88,7 +80,7 @@ fun aShareLocationState(
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false,
hasLocationPermission: Boolean = false,
userLocationState: UserLocationState = UserLocationState(null),
canShareLiveLocation: Boolean = false,
appName: String = APP_NAME,
startLiveLocationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
@@ -99,7 +91,7 @@ fun aShareLocationState(
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
hasLocationPermission = hasLocationPermission,
userLocationState = userLocationState,
canShareLiveLocation = canShareLiveLocation,
appName = appName,
startLiveLocationAction = startLiveLocationAction,
@@ -39,12 +39,11 @@ import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.impl.R
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.UserLocationState
import io.element.android.features.location.impl.common.rememberUserLocationState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
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.userlocation.UserLocationTrackingEffect
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
@@ -109,7 +108,6 @@ fun ShareLocationView(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded)
)
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
@@ -136,15 +134,18 @@ fun ShareLocationView(
BottomSheetContent(
cameraState = cameraState,
state = state,
userLocationState = userLocationState,
navigateUp = navigateUp
)
},
mapContent = {
UserLocationTrackingEffect(
cameraState = cameraState,
locationState = state.userLocationState,
enabled = state.trackUserLocation,
)
UserLocationPuck(
cameraState = cameraState,
locationState = userLocationState,
trackUserLocation = state.trackUserLocation
location = state.userLocationState.location,
)
},
overlayContent = { sheetPadding ->
@@ -215,11 +216,10 @@ private fun StartLiveLocationActionView(
private fun BottomSheetContent(
cameraState: CameraState,
state: ShareLocationState,
userLocationState: UserLocationState,
navigateUp: () -> Unit,
) {
Spacer(Modifier.height(20.dp))
val userLocation = userLocationState.location
val userLocation = state.userLocationState.location
if (state.trackUserLocation && userLocation != null) {
ShareCurrentLocationItem {
state.eventSink(
@@ -10,12 +10,14 @@ package io.element.android.features.location.impl.show
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
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.rememberUpdatedState
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
@@ -23,6 +25,7 @@ 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.api.live.isCurrentlySharing
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
@@ -33,6 +36,8 @@ import io.element.android.features.location.impl.common.permissions.PermissionsP
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.features.location.impl.common.userlocation.asMapLibreLocation
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
@@ -52,8 +57,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
@AssistedInject
class ShowLocationPresenter(
@AssistedInject class ShowLocationPresenter(
@Assisted private val mode: ShowLocationMode,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions,
@@ -63,9 +67,9 @@ class ShowLocationPresenter(
private val client: MatrixClient,
private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val userLocationStateFactory: UserLocationState.Factory,
) : Presenter<ShowLocationState> {
@AssistedFactory
fun interface Factory {
@AssistedFactory fun interface Factory {
fun create(mode: ShowLocationMode): ShowLocationPresenter
}
@@ -155,55 +159,58 @@ class ShowLocationPresenter(
val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares()
val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() }
combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members ->
liveShares
.sortedWith(comparator)
.mapNotNull { share ->
val lastLocation = share.lastLocation ?: return@mapNotNull null
val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null
val member = members.find { it.userId == share.userId }
val displayName = member?.getBestName() ?: share.userId.value
val avatarUrl = member?.avatarUrl
val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true)
val formattedTimestamp = stringProvider.getString(
CommonStrings.screen_static_location_sheet_timestamp_description,
relativeTime
)
LocationShareItem(
userId = share.userId,
displayName = displayName,
avatarData = AvatarData(
id = share.userId.value,
name = displayName,
url = avatarUrl,
size = AvatarSize.UserListItem,
),
formattedTimestamp = formattedTimestamp,
location = location,
isLive = true,
assetType = lastLocation.assetType,
isOwnUser = share.userId == joinedRoom.sessionId
)
}
.toImmutableList()
liveShares.sortedWith(comparator).mapNotNull { share ->
val lastLocation = share.lastLocation ?: return@mapNotNull null
val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null
val member = members.find { it.userId == share.userId }
val displayName = member?.getBestName() ?: share.userId.value
val avatarUrl = member?.avatarUrl
val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true)
val formattedTimestamp = stringProvider.getString(
CommonStrings.screen_static_location_sheet_timestamp_description,
relativeTime
)
LocationShareItem(
userId = share.userId,
displayName = displayName,
avatarData = AvatarData(
id = share.userId.value,
name = displayName,
url = avatarUrl,
size = AvatarSize.UserListItem,
),
formattedTimestamp = formattedTimestamp,
location = location,
isLive = true,
assetType = lastLocation.assetType,
isOwnUser = share.userId == joinedRoom.sessionId
)
}.toImmutableList()
}.collect { value = it }
}.value
}
}
val focusedLocation = when (mode) {
is ShowLocationMode.Static -> locationShares.firstOrNull()
is ShowLocationMode.Live -> locationShares.firstOrNull { it.userId == mode.senderId }
val updatedLocationShares by rememberUpdatedState(locationShares)
val focusedLocation by remember {
derivedStateOf {
when (mode) {
is ShowLocationMode.Static -> updatedLocationShares.firstOrNull()
is ShowLocationMode.Live -> updatedLocationShares.firstOrNull { it.userId == mode.senderId }
}
}
}
val userLocationState = userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
return ShowLocationState(
customMapStyleUrl = customMapStyleUrl,
dialogState = dialogState,
locationShares = locationShares,
focusedLocation = focusedLocation,
hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation,
userLocationState = userLocationState,
isLive = mode is ShowLocationMode.Live,
appName = appName,
hideUserLocationPuck = false,
eventSink = ::handleEvent,
)
}
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.show
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.ui.LocationMarkerData
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -24,9 +25,10 @@ data class ShowLocationState(
val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList<LocationShareItem>,
val focusedLocation: LocationShareItem?,
val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean,
val userLocationState: UserLocationState,
val appName: String,
val hideUserLocationPuck: Boolean,
val eventSink: (ShowLocationEvent) -> Unit,
) {
val isSheetDraggable = isLive && locationShares.isNotEmpty()
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.show
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -33,18 +34,9 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
),
aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
hasLocationPermission = true,
),
aShowLocationState(
hasLocationPermission = true,
),
aShowLocationState(
hasLocationPermission = true,
isTrackMyLocation = true,
),
aShowLocationState(
customMapStyleUrl = AsyncData.Loading(),
),
aShowLocationState(isTrackMyLocation = true),
aShowLocationState(customMapStyleUrl = AsyncData.Loading()),
)
}
@@ -56,9 +48,10 @@ fun aShowLocationState(
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
focusedLocation: LocationShareItem? = locationShares.firstOrNull(),
hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false,
userLocationState: UserLocationState = UserLocationState(null),
appName: String = APP_NAME,
hideUserLocationPuck: Boolean = false,
eventSink: (ShowLocationEvent) -> Unit = {},
): ShowLocationState {
return ShowLocationState(
@@ -66,8 +59,9 @@ fun aShowLocationState(
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation,
hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation,
userLocationState = userLocationState,
hideUserLocationPuck = hideUserLocationPuck,
appName = appName,
isLive = isLive,
eventSink = eventSink,
@@ -37,13 +37,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.rememberUserLocationState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.features.location.impl.common.ui.LocationPinMarkers
import io.element.android.features.location.impl.common.ui.LocationShareRow
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.userlocation.UserLocationTrackingEffect
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -91,7 +91,6 @@ fun ShowLocationView(
}
}
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
)
@@ -169,11 +168,17 @@ fun ShowLocationView(
}
},
mapContent = {
UserLocationPuck(
UserLocationTrackingEffect(
cameraState = cameraState,
locationState = userLocationState,
trackUserLocation = state.isTrackMyLocation
locationState = state.userLocationState,
enabled = state.isTrackMyLocation,
)
if (!state.hideUserLocationPuck) {
UserLocationPuck(
cameraState = cameraState,
location = state.userLocationState.location,
)
}
val markers = remember(state.locationShares) {
state.locationShares.map { it.toMarkerData() }.toImmutableList()
}
@@ -0,0 +1,18 @@
/*
* 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 androidx.compose.runtime.Composable
import io.element.android.features.location.impl.common.userlocation.UserLocationState
class FakeUserLocationStateFactory : UserLocationState.Factory {
@Composable
override fun create(hasLocationPermission: Boolean): UserLocationState {
return UserLocationState(null)
}
}
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.share
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
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
@@ -56,6 +57,7 @@ class DefaultShareLocationEntryPointTest {
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
sessionId = room.sessionId,
),
userLocationStateFactory = FakeUserLocationStateFactory(),
)
},
analyticsService = FakeAnalyticsService(),
@@ -21,6 +21,7 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
@@ -102,6 +103,7 @@ class ShareLocationPresenterTest {
durationFormatter = durationFormatter,
liveLocationShareManager = liveLocationShareManager,
liveLocationStore = liveLocationStore,
userLocationStateFactory = FakeUserLocationStateFactory(),
)
@Test
@@ -118,7 +120,6 @@ class ShareLocationPresenterTest {
val state = awaitFirstItem()
assertThat(state.customMapStyleUrl.isLoading()).isFalse()
assertThat(state.trackUserLocation).isTrue()
assertThat(state.hasLocationPermission).isTrue()
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
}
@@ -155,7 +156,6 @@ class ShareLocationPresenterTest {
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isTrue()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
}
@@ -175,7 +175,6 @@ class ShareLocationPresenterTest {
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied)
)
@@ -195,7 +194,6 @@ class ShareLocationPresenterTest {
shareLocationPresenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
)
@@ -216,7 +214,6 @@ class ShareLocationPresenterTest {
shareLocationPresenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
)
@@ -120,7 +120,6 @@ class ShareLocationViewTest {
setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
@@ -135,7 +134,6 @@ class ShareLocationViewTest {
setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
@@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
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
@@ -51,6 +52,7 @@ class DefaultShowLocationEntryPointTest {
joinedRoom = joinedRoom,
client = FakeMatrixClient(),
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
userLocationStateFactory = FakeUserLocationStateFactory(),
)
},
analyticsService = FakeAnalyticsService(),
@@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
@@ -41,6 +42,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.hours
@OptIn(ExperimentalCoroutinesApi::class)
class ShowLocationPresenterTest {
@@ -76,26 +78,9 @@ class ShowLocationPresenterTest {
joinedRoom = joinedRoom,
client = client,
liveLocationShareManager = liveLocationShareManager,
userLocationStateFactory = FakeUserLocationStateFactory(),
)
@Test
fun `emits initial state with no location permission`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
)
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.customMapStyleUrl.isLoading()).isTrue()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse()
}
}
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
val shareLocationPresenter = createShowLocationPresenter(
@@ -112,35 +97,6 @@ class ShowLocationPresenterTest {
}
}
@Test
fun `emits initial state location permission denied once`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
)
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse()
}
}
@Test
fun `emits initial state with location permission`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse()
}
}
@Test
fun `emits initial state with partial location permission`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
@@ -148,7 +104,6 @@ class ShowLocationPresenterTest {
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse()
}
}
@@ -176,7 +131,6 @@ class ShowLocationPresenterTest {
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse()
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
@@ -184,7 +138,6 @@ class ShowLocationPresenterTest {
delay(1)
assertThat(trackMyLocationState.hasLocationPermission).isTrue()
assertThat(trackMyLocationState.isTrackMyLocation).isTrue()
// Swipe the map to switch mode
@@ -192,7 +145,6 @@ class ShowLocationPresenterTest {
val trackLocationDisabledState = awaitItem()
assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse()
assertThat(trackLocationDisabledState.hasLocationPermission).isTrue()
}
}
@@ -215,14 +167,12 @@ class ShowLocationPresenterTest {
val trackLocationState = awaitItem()
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
initialState.eventSink(ShowLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
}
}
@@ -244,7 +194,6 @@ class ShowLocationPresenterTest {
val trackLocationState = awaitItem()
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse()
// Continue the dialog sends permission request to the permissions presenter
trackLocationState.eventSink(ShowLocationEvent.RequestPermissions)
@@ -271,14 +220,12 @@ class ShowLocationPresenterTest {
val trackLocationState = awaitItem()
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied)
assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
initialState.eventSink(ShowLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
}
}
@@ -326,7 +273,6 @@ class ShowLocationPresenterTest {
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
// Try to track location when location services are disabled
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
@@ -493,4 +439,25 @@ class ShowLocationPresenterTest {
assertThat(state.isSheetDraggable).isFalse()
}
}
@Test
fun `static mode never hides user location puck`() = runTest {
val presenter = createShowLocationPresenter()
presenter.test {
val state = awaitItem()
assertThat(state.hideUserLocationPuck).isFalse()
}
}
@Test
fun `live mode does not hide user location puck when not sharing`() = runTest {
val presenter = createShowLocationPresenter(
mode = ShowLocationMode.Live(senderId = A_USER_ID),
)
presenter.test {
val state = awaitItem()
assertThat(state.hideUserLocationPuck).isFalse()
}
}
}