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.runtime.Composable
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.UserLocationState
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes import org.maplibre.compose.location.LocationPuckSizes
@@ -20,40 +19,23 @@ import org.maplibre.compose.location.LocationPuckSizes
@Composable @Composable
fun UserLocationPuck( fun UserLocationPuck(
cameraState: CameraState, cameraState: CameraState,
locationState: UserLocationState, location: Location?,
trackUserLocation: Boolean,
) { ) {
SimpleLocationTrackingEffect( LocationPuck(
locationState = locationState, idPrefix = "user-location",
enabled = trackUserLocation, location = location,
) { currentLocation -> cameraState = cameraState,
val target = currentLocation?.position?.value ?: cameraState.position.target accuracyThreshold = Float.POSITIVE_INFINITY,
val newPosition = cameraState.position.copy( showBearingAccuracy = false,
target = target, showBearing = false,
// Force pointing to NORTH sizes = LocationPuckSizes(
bearing = 0.0, dotRadius = 8.dp,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) 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. * 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.Manifest
import android.annotation.SuppressLint 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. * 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow 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 kotlinx.coroutines.flow.distinctUntilChanged
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.location.Location import org.maplibre.compose.location.Location
import kotlin.math.abs 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 * TODO remove once https://github.com/maplibre/maplibre-compose/issues/808 is fixed
*/ */
@Composable @Composable
internal fun SimpleLocationTrackingEffect( internal fun UserLocationTrackingEffect(
locationState: UserLocationState, locationState: UserLocationState,
enabled: Boolean = true, enabled: Boolean = true,
precision: Double = 0.00001, precision: Double = 0.00001,
onLocationChange: suspend (Location?) -> Unit, onLocationChange: suspend (Location?) -> Unit,
) { ) {
val latestOnLocationChange by rememberUpdatedState(onLocationChange) val latestOnLocationChange by rememberUpdatedState(onLocationChange)
LaunchedEffect(locationState, enabled) { val latestLocationState by rememberUpdatedState(locationState)
LaunchedEffect(enabled) {
if (!enabled) return@LaunchedEffect if (!enabled) return@LaunchedEffect
val locationStateFlow = snapshotFlow { locationState.location } val locationStateFlow = snapshotFlow { latestLocationState.location }
locationStateFlow locationStateFlow
.distinctUntilChanged { oldLocation, newLocation -> .distinctUntilChanged { oldLocation, newLocation ->
if (oldLocation != null && newLocation != null) { 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 android.os.IBinder
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import dev.zacsweers.metro.Inject 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.di.LocationBindings
import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator
import io.element.android.libraries.architecture.bindings 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.permissions.PermissionsState
import io.element.android.features.location.impl.common.sendLiveLocationPermissions import io.element.android.features.location.impl.common.sendLiveLocationPermissions
import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@@ -70,6 +71,7 @@ class ShareLocationPresenter(
private val durationFormatter: DurationFormatter, private val durationFormatter: DurationFormatter,
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore, private val liveLocationStore: LiveLocationStore,
private val userLocationStateFactory: UserLocationState.Factory,
) : Presenter<ShareLocationState> { ) : Presenter<ShareLocationState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@@ -123,6 +125,8 @@ class ShareLocationPresenter(
} }
} }
val userLocationState = userLocationStateFactory.create(permissionsState.isAnyGranted)
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() } LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
fun handleEvent(event: ShareLocationEvent) { fun handleEvent(event: ShareLocationEvent) {
@@ -171,7 +175,7 @@ class ShareLocationPresenter(
currentUser = currentUser, currentUser = currentUser,
dialogState = dialogState, dialogState = dialogState,
trackUserLocation = trackUserPosition, trackUserLocation = trackUserPosition,
hasLocationPermission = permissionsState.isAnyGranted, userLocationState = userLocationState,
canShareLiveLocation = timelineMode.canShareLiveLocation(), canShareLiveLocation = timelineMode.canShareLiveLocation(),
appName = appName, appName = appName,
startLiveLocationAction = startLiveLocationAction.value, startLiveLocationAction = startLiveLocationAction.value,
@@ -9,6 +9,7 @@
package io.element.android.features.location.impl.share package io.element.android.features.location.impl.share
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -19,7 +20,7 @@ data class ShareLocationState(
val currentUser: MatrixUser, val currentUser: MatrixUser,
val dialogState: Dialog, val dialogState: Dialog,
val trackUserLocation: Boolean, val trackUserLocation: Boolean,
val hasLocationPermission: Boolean, val userLocationState: UserLocationState,
val appName: String, val appName: String,
val canShareLiveLocation: Boolean, val canShareLiveLocation: Boolean,
val startLiveLocationAction: AsyncAction<Unit>, val startLiveLocationAction: AsyncAction<Unit>,
@@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@@ -26,43 +27,35 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.None, dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = false,
), ),
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = false,
), ),
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = false,
), ),
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = true,
), ),
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.None, dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = true,
), ),
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.None, dialogState = ShareLocationState.Dialog.None,
trackUserPosition = true, trackUserPosition = true,
hasLocationPermission = true,
), ),
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.None, dialogState = ShareLocationState.Dialog.None,
trackUserPosition = true, trackUserPosition = true,
hasLocationPermission = true,
canShareLiveLocation = true, canShareLiveLocation = true,
), ),
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer, dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
trackUserPosition = true, trackUserPosition = true,
hasLocationPermission = true,
canShareLiveLocation = true, canShareLiveLocation = true,
), ),
aShareLocationState( aShareLocationState(
@@ -74,7 +67,6 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
) )
), ),
trackUserPosition = true, trackUserPosition = true,
hasLocationPermission = true,
canShareLiveLocation = true, canShareLiveLocation = true,
), ),
aShareLocationState( aShareLocationState(
@@ -88,7 +80,7 @@ fun aShareLocationState(
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None, dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false, trackUserPosition: Boolean = false,
hasLocationPermission: Boolean = false, userLocationState: UserLocationState = UserLocationState(null),
canShareLiveLocation: Boolean = false, canShareLiveLocation: Boolean = false,
appName: String = APP_NAME, appName: String = APP_NAME,
startLiveLocationAction: AsyncAction<Unit> = AsyncAction.Uninitialized, startLiveLocationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
@@ -99,7 +91,7 @@ fun aShareLocationState(
currentUser = currentUser, currentUser = currentUser,
dialogState = dialogState, dialogState = dialogState,
trackUserLocation = trackUserPosition, trackUserLocation = trackUserPosition,
hasLocationPermission = hasLocationPermission, userLocationState = userLocationState,
canShareLiveLocation = canShareLiveLocation, canShareLiveLocation = canShareLiveLocation,
appName = appName, appName = appName,
startLiveLocationAction = startLiveLocationAction, 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.api.internal.centerBottomEdge
import io.element.android.features.location.impl.R 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.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.LocationConstraintsDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton 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.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.userlocation.UserLocationTrackingEffect
import io.element.android.features.location.impl.share.ShareLocationEvent.StartLiveLocationShare import io.element.android.features.location.impl.share.ShareLocationEvent.StartLiveLocationShare
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.LocationPin
@@ -109,7 +108,6 @@ fun ShareLocationView(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded) bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded)
) )
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
LaunchedEffect(cameraState.isCameraMoving) { LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) { if (cameraState.moveReason == CameraMoveReason.GESTURE) {
@@ -136,15 +134,18 @@ fun ShareLocationView(
BottomSheetContent( BottomSheetContent(
cameraState = cameraState, cameraState = cameraState,
state = state, state = state,
userLocationState = userLocationState,
navigateUp = navigateUp navigateUp = navigateUp
) )
}, },
mapContent = { mapContent = {
UserLocationTrackingEffect(
cameraState = cameraState,
locationState = state.userLocationState,
enabled = state.trackUserLocation,
)
UserLocationPuck( UserLocationPuck(
cameraState = cameraState, cameraState = cameraState,
locationState = userLocationState, location = state.userLocationState.location,
trackUserLocation = state.trackUserLocation
) )
}, },
overlayContent = { sheetPadding -> overlayContent = { sheetPadding ->
@@ -215,11 +216,10 @@ private fun StartLiveLocationActionView(
private fun BottomSheetContent( private fun BottomSheetContent(
cameraState: CameraState, cameraState: CameraState,
state: ShareLocationState, state: ShareLocationState,
userLocationState: UserLocationState,
navigateUp: () -> Unit, navigateUp: () -> Unit,
) { ) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
val userLocation = userLocationState.location val userLocation = state.userLocationState.location
if (state.trackUserLocation && userLocation != null) { if (state.trackUserLocation && userLocation != null) {
ShareCurrentLocationItem { ShareCurrentLocationItem {
state.eventSink( state.eventSink(
@@ -10,12 +10,14 @@ package io.element.android.features.location.impl.show
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
@@ -23,6 +25,7 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.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.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.SendLiveLocationPermissions import io.element.android.features.location.impl.common.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.permissions.PermissionsState
import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState 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.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState 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.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@AssistedInject @AssistedInject class ShowLocationPresenter(
class ShowLocationPresenter(
@Assisted private val mode: ShowLocationMode, @Assisted private val mode: ShowLocationMode,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions, private val locationActions: LocationActions,
@@ -63,9 +67,9 @@ class ShowLocationPresenter(
private val client: MatrixClient, private val client: MatrixClient,
private val joinedRoom: JoinedRoom, private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val userLocationStateFactory: UserLocationState.Factory,
) : Presenter<ShowLocationState> { ) : Presenter<ShowLocationState> {
@AssistedFactory @AssistedFactory fun interface Factory {
fun interface Factory {
fun create(mode: ShowLocationMode): ShowLocationPresenter fun create(mode: ShowLocationMode): ShowLocationPresenter
} }
@@ -155,55 +159,58 @@ class ShowLocationPresenter(
val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares() val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares()
val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() } val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() }
combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members -> combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members ->
liveShares liveShares.sortedWith(comparator).mapNotNull { share ->
.sortedWith(comparator) val lastLocation = share.lastLocation ?: return@mapNotNull null
.mapNotNull { share -> val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null
val lastLocation = share.lastLocation ?: return@mapNotNull null val member = members.find { it.userId == share.userId }
val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null val displayName = member?.getBestName() ?: share.userId.value
val member = members.find { it.userId == share.userId } val avatarUrl = member?.avatarUrl
val displayName = member?.getBestName() ?: share.userId.value val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true)
val avatarUrl = member?.avatarUrl val formattedTimestamp = stringProvider.getString(
val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true) CommonStrings.screen_static_location_sheet_timestamp_description,
val formattedTimestamp = stringProvider.getString( relativeTime
CommonStrings.screen_static_location_sheet_timestamp_description, )
relativeTime LocationShareItem(
) userId = share.userId,
LocationShareItem( displayName = displayName,
userId = share.userId, avatarData = AvatarData(
displayName = displayName, id = share.userId.value,
avatarData = AvatarData( name = displayName,
id = share.userId.value, url = avatarUrl,
name = displayName, size = AvatarSize.UserListItem,
url = avatarUrl, ),
size = AvatarSize.UserListItem, formattedTimestamp = formattedTimestamp,
), location = location,
formattedTimestamp = formattedTimestamp, isLive = true,
location = location, assetType = lastLocation.assetType,
isLive = true, isOwnUser = share.userId == joinedRoom.sessionId
assetType = lastLocation.assetType, )
isOwnUser = share.userId == joinedRoom.sessionId }.toImmutableList()
)
}
.toImmutableList()
}.collect { value = it } }.collect { value = it }
}.value }.value
} }
} }
val focusedLocation = when (mode) { val updatedLocationShares by rememberUpdatedState(locationShares)
is ShowLocationMode.Static -> locationShares.firstOrNull() val focusedLocation by remember {
is ShowLocationMode.Live -> locationShares.firstOrNull { it.userId == mode.senderId } 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( return ShowLocationState(
customMapStyleUrl = customMapStyleUrl, customMapStyleUrl = customMapStyleUrl,
dialogState = dialogState, dialogState = dialogState,
locationShares = locationShares, locationShares = locationShares,
focusedLocation = focusedLocation, focusedLocation = focusedLocation,
hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation, isTrackMyLocation = isTrackMyLocation,
userLocationState = userLocationState,
isLive = mode is ShowLocationMode.Live, isLive = mode is ShowLocationMode.Live,
appName = appName, appName = appName,
hideUserLocationPuck = false,
eventSink = ::handleEvent, 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.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.ui.LocationMarkerData 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.architecture.AsyncData
import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -24,9 +25,10 @@ data class ShowLocationState(
val dialogState: LocationConstraintsDialogState, val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList<LocationShareItem>, val locationShares: ImmutableList<LocationShareItem>,
val focusedLocation: LocationShareItem?, val focusedLocation: LocationShareItem?,
val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean, val isTrackMyLocation: Boolean,
val userLocationState: UserLocationState,
val appName: String, val appName: String,
val hideUserLocationPuck: Boolean,
val eventSink: (ShowLocationEvent) -> Unit, val eventSink: (ShowLocationEvent) -> Unit,
) { ) {
val isSheetDraggable = isLive && locationShares.isNotEmpty() 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location 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.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -33,18 +34,9 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
), ),
aShowLocationState( aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled, 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, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)), locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
focusedLocation: LocationShareItem? = locationShares.firstOrNull(), focusedLocation: LocationShareItem? = locationShares.firstOrNull(),
hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false, isTrackMyLocation: Boolean = false,
userLocationState: UserLocationState = UserLocationState(null),
appName: String = APP_NAME, appName: String = APP_NAME,
hideUserLocationPuck: Boolean = false,
eventSink: (ShowLocationEvent) -> Unit = {}, eventSink: (ShowLocationEvent) -> Unit = {},
): ShowLocationState { ): ShowLocationState {
return ShowLocationState( return ShowLocationState(
@@ -66,8 +59,9 @@ fun aShowLocationState(
dialogState = constraintsDialogState, dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(), locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation, focusedLocation = focusedLocation,
hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation, isTrackMyLocation = isTrackMyLocation,
userLocationState = userLocationState,
hideUserLocationPuck = hideUserLocationPuck,
appName = appName, appName = appName,
isLive = isLive, isLive = isLive,
eventSink = eventSink, eventSink = eventSink,
@@ -37,13 +37,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.impl.common.MapDefaults 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.LocationConstraintsDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton 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.LocationPinMarkers
import io.element.android.features.location.impl.common.ui.LocationShareRow 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.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.userlocation.UserLocationTrackingEffect
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -91,7 +91,6 @@ fun ShowLocationView(
} }
} }
val userLocationState = rememberUserLocationState(state.hasLocationPermission)
val scaffoldState = rememberBottomSheetScaffoldState( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded) bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
) )
@@ -169,11 +168,17 @@ fun ShowLocationView(
} }
}, },
mapContent = { mapContent = {
UserLocationPuck( UserLocationTrackingEffect(
cameraState = cameraState, cameraState = cameraState,
locationState = userLocationState, locationState = state.userLocationState,
trackUserLocation = state.isTrackMyLocation enabled = state.isTrackMyLocation,
) )
if (!state.hideUserLocationPuck) {
UserLocationPuck(
cameraState = cameraState,
location = state.userLocationState.location,
)
}
val markers = remember(state.locationShares) { val markers = remember(state.locationShares) {
state.locationShares.map { it.toMarkerData() }.toImmutableList() 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 androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.impl.live.LiveLocationStore
@@ -56,6 +57,7 @@ class DefaultShareLocationEntryPointTest {
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(), preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
sessionId = room.sessionId, sessionId = room.sessionId,
), ),
userLocationStateFactory = FakeUserLocationStateFactory(),
) )
}, },
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
@@ -21,6 +21,7 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState 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.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
@@ -102,6 +103,7 @@ class ShareLocationPresenterTest {
durationFormatter = durationFormatter, durationFormatter = durationFormatter,
liveLocationShareManager = liveLocationShareManager, liveLocationShareManager = liveLocationShareManager,
liveLocationStore = liveLocationStore, liveLocationStore = liveLocationStore,
userLocationStateFactory = FakeUserLocationStateFactory(),
) )
@Test @Test
@@ -118,7 +120,6 @@ class ShareLocationPresenterTest {
val state = awaitFirstItem() val state = awaitFirstItem()
assertThat(state.customMapStyleUrl.isLoading()).isFalse() assertThat(state.customMapStyleUrl.isLoading()).isFalse()
assertThat(state.trackUserLocation).isTrue() assertThat(state.trackUserLocation).isTrue()
assertThat(state.hasLocationPermission).isTrue()
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
} }
} }
@@ -155,7 +156,6 @@ class ShareLocationPresenterTest {
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isTrue() assertThat(initialState.trackUserLocation).isTrue()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
} }
} }
@@ -175,7 +175,6 @@ class ShareLocationPresenterTest {
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse() assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo( assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied) ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied)
) )
@@ -195,7 +194,6 @@ class ShareLocationPresenterTest {
shareLocationPresenter.test { shareLocationPresenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse() assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo( assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale) ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
) )
@@ -216,7 +214,6 @@ class ShareLocationPresenterTest {
shareLocationPresenter.test { shareLocationPresenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse() assertThat(initialState.trackUserLocation).isFalse()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo( assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled) ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
) )
@@ -120,7 +120,6 @@ class ShareLocationViewTest {
setShareLocationView( setShareLocationView(
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
navigateUp = EnsureNeverCalled(), navigateUp = EnsureNeverCalled(),
@@ -135,7 +134,6 @@ class ShareLocationViewTest {
setShareLocationView( setShareLocationView(
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
navigateUp = EnsureNeverCalled(), 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.Location
import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
@@ -51,6 +52,7 @@ class DefaultShowLocationEntryPointTest {
joinedRoom = joinedRoom, joinedRoom = joinedRoom,
client = FakeMatrixClient(), client = FakeMatrixClient(),
liveLocationShareManager = FakeActiveLiveLocationShareManager(), liveLocationShareManager = FakeActiveLiveLocationShareManager(),
userLocationStateFactory = FakeUserLocationStateFactory(),
) )
}, },
analyticsService = FakeAnalyticsService(), 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.Location
import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.aPermissionsState 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.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsEvents 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 kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.hours
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class ShowLocationPresenterTest { class ShowLocationPresenterTest {
@@ -76,26 +78,9 @@ class ShowLocationPresenterTest {
joinedRoom = joinedRoom, joinedRoom = joinedRoom,
client = client, client = client,
liveLocationShareManager = liveLocationShareManager, 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 @Test
fun `present - non-null customMapStyleUrl`() = runTest { fun `present - non-null customMapStyleUrl`() = runTest {
val shareLocationPresenter = createShowLocationPresenter( 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 @Test
fun `emits initial state with partial location permission`() = runTest { fun `emits initial state with partial location permission`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
@@ -148,7 +104,6 @@ class ShowLocationPresenterTest {
val presenter = createShowLocationPresenter() val presenter = createShowLocationPresenter()
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
} }
} }
@@ -176,7 +131,6 @@ class ShowLocationPresenterTest {
presenter.test { presenter.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
@@ -184,7 +138,6 @@ class ShowLocationPresenterTest {
delay(1) delay(1)
assertThat(trackMyLocationState.hasLocationPermission).isTrue()
assertThat(trackMyLocationState.isTrackMyLocation).isTrue() assertThat(trackMyLocationState.isTrackMyLocation).isTrue()
// Swipe the map to switch mode // Swipe the map to switch mode
@@ -192,7 +145,6 @@ class ShowLocationPresenterTest {
val trackLocationDisabledState = awaitItem() val trackLocationDisabledState = awaitItem()
assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse() assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse()
assertThat(trackLocationDisabledState.hasLocationPermission).isTrue()
} }
} }
@@ -215,14 +167,12 @@ class ShowLocationPresenterTest {
val trackLocationState = awaitItem() val trackLocationState = awaitItem()
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale) assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog // Dismiss the dialog
initialState.eventSink(ShowLocationEvent.DismissDialog) initialState.eventSink(ShowLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem() val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
} }
} }
@@ -244,7 +194,6 @@ class ShowLocationPresenterTest {
val trackLocationState = awaitItem() val trackLocationState = awaitItem()
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale) assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse()
// Continue the dialog sends permission request to the permissions presenter // Continue the dialog sends permission request to the permissions presenter
trackLocationState.eventSink(ShowLocationEvent.RequestPermissions) trackLocationState.eventSink(ShowLocationEvent.RequestPermissions)
@@ -271,14 +220,12 @@ class ShowLocationPresenterTest {
val trackLocationState = awaitItem() val trackLocationState = awaitItem()
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied) assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied)
assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog // Dismiss the dialog
initialState.eventSink(ShowLocationEvent.DismissDialog) initialState.eventSink(ShowLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem() val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse()
} }
} }
@@ -326,7 +273,6 @@ class ShowLocationPresenterTest {
val presenter = createShowLocationPresenter() val presenter = createShowLocationPresenter()
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
// Try to track location when location services are disabled // Try to track location when location services are disabled
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
@@ -493,4 +439,25 @@ class ShowLocationPresenterTest {
assertThat(state.isSheetDraggable).isFalse() 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()
}
}
} }