Add Constraints check for permissions and GPS check

This commit is contained in:
ganfra
2026-03-12 11:53:36 +01:00
parent 7b305e34ef
commit 392fa9de9b
23 changed files with 382 additions and 326 deletions

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
sealed interface LocationConstraintsCheckResult {
data object Success : LocationConstraintsCheckResult
data object PermissionRationale : LocationConstraintsCheckResult
data object PermissionDenied : LocationConstraintsCheckResult
data object LocationServiceDisabled : LocationConstraintsCheckResult
}
fun checkLocationConstraints(
permissionsState: PermissionsState,
locationActions: LocationActions,
): LocationConstraintsCheckResult {
return when {
permissionsState.isAnyGranted -> {
if (locationActions.isLocationEnabled()) {
LocationConstraintsCheckResult.Success
} else {
LocationConstraintsCheckResult.LocationServiceDisabled
}
}
permissionsState.shouldShowRationale -> LocationConstraintsCheckResult.PermissionRationale
else -> LocationConstraintsCheckResult.PermissionDenied
}
}
fun LocationConstraintsCheckResult.toDialogState(): LocationConstraintsDialogState {
return when (this) {
LocationConstraintsCheckResult.Success -> LocationConstraintsDialogState.None
LocationConstraintsCheckResult.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
LocationConstraintsCheckResult.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
LocationConstraintsCheckResult.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
}
}

View File

@@ -34,43 +34,9 @@ object MapDefaults {
) )
) )
/* val defaultCameraPosition = CameraPosition(
val uiSettings: MapUiSettings target = Position(0.0, 0.0),
@Composable zoom = 0.0,
@ReadOnlyComposable
get() = MapUiSettings(
compassEnabled = false,
rotationGesturesEnabled = false,
scrollGesturesEnabled = true,
tiltGesturesEnabled = false,
zoomGesturesEnabled = true,
logoGravity = Gravity.TOP,
attributionGravity = Gravity.TOP,
attributionTintColor = ElementTheme.colors.iconPrimary
)
val symbolManagerSettings: MapSymbolManagerSettings
get() = MapSymbolManagerSettings(
iconAllowOverlap = true
)
val locationSettings: MapLocationSettings
get() = MapLocationSettings(
locationEnabled = false,
backgroundTintColor = Color.White,
foregroundTintColor = Color.Black,
backgroundStaleTintColor = Color.White,
foregroundStaleTintColor = Color.Black,
accuracyColor = Color.Black,
pulseEnabled = true,
pulseColor = Color.Black,
)
*/
val centerCameraPosition = CameraPosition(
target = Position(49.843, 9.902056),
zoom = 2.7,
) )
const val DEFAULT_ZOOM = 15.0 const val DEFAULT_ZOOM = 15.0

View File

@@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun PermissionDeniedDialog(
onContinue: () -> Unit,
onDismiss: () -> Unit,
appName: String,
) {
ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_auth_android, appName),
onSubmitClick = onContinue,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
}

View File

@@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun PermissionRationaleDialog(
onContinue: () -> Unit,
onDismiss: () -> Unit,
appName: String,
) {
ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_rationale_android, appName),
onSubmitClick = onContinue,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
}

View File

@@ -43,7 +43,7 @@ class AndroidLocationActions(
} }
} }
override fun openSettings() { override fun openAppSettings() {
context.openAppSettingsPage() context.openAppSettingsPage()
} }

View File

@@ -12,7 +12,7 @@ import io.element.android.features.location.api.Location
interface LocationActions { interface LocationActions {
fun share(location: Location, label: String?) fun share(location: Location, label: String?)
fun openSettings() fun openAppSettings()
fun isLocationEnabled(): Boolean fun isLocationEnabled(): Boolean
fun openLocationSettings() fun openLocationSettings()
} }

View File

@@ -0,0 +1,55 @@
/*
* 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.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
sealed interface LocationConstraintsDialogState {
data object None : LocationConstraintsDialogState
data object PermissionRationale : LocationConstraintsDialogState
data object PermissionDenied : LocationConstraintsDialogState
data object LocationServiceDisabled : LocationConstraintsDialogState
}
@Composable
fun LocationConstraintsDialog(
state: LocationConstraintsDialogState,
appName: String,
onRequestPermissions: () -> Unit,
onOpenAppSettings: () -> Unit,
onOpenLocationSettings: () -> Unit,
onDismiss: () -> Unit,
) {
when (state) {
LocationConstraintsDialogState.None -> Unit
LocationConstraintsDialogState.PermissionRationale -> ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_rationale_android, appName),
onSubmitClick = onRequestPermissions,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
LocationConstraintsDialogState.PermissionDenied -> ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_location_auth_android, appName),
onSubmitClick = onOpenAppSettings,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog(
content = "Please enable your GPS to access location-based features.",
onSubmitClick = onOpenLocationSettings,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
}
}

View File

@@ -1,27 +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.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun LocationServiceDisabledDialog(
onContinue: () -> Unit,
onDismiss: () -> Unit,
) {
ConfirmationDialog(
content = "Location services are disabled. Please enable them in your device settings to use this feature.",
onSubmitClick = onContinue,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
}

View File

@@ -20,10 +20,11 @@ sealed interface ShareLocationEvent {
data object ShowLiveLocationDurationPicker : ShareLocationEvent data object ShowLiveLocationDurationPicker : ShareLocationEvent
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
data object StartTrackingUserPosition : ShareLocationEvent data object StartTrackingUserLocation : ShareLocationEvent
data object StopTrackingUserPosition : ShareLocationEvent data object StopTrackingUserLocation : ShareLocationEvent
data object DismissDialog : ShareLocationEvent data object DismissDialog : ShareLocationEvent
data object RequestPermissions : ShareLocationEvent
data object RequestPermissions: ShareLocationEvent
data object OpenAppSettings : ShareLocationEvent data object OpenAppSettings : ShareLocationEvent
data object OpenLocationSettings : ShareLocationEvent data object OpenLocationSettings : ShareLocationEvent
} }

View File

@@ -21,11 +21,15 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.common.LocationConstraintsCheckResult
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.actions.LocationActions import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.checkLocationConstraints
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.share.ShareLocationState.Dialog.Constraints
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMap
@@ -63,7 +67,7 @@ class ShareLocationPresenter(
@Composable @Composable
override fun present(): ShareLocationState { override fun present(): ShareLocationState {
val permissionsState: PermissionsState = permissionsPresenter.present() val permissionsState: PermissionsState = permissionsPresenter.present()
var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted) } var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted && locationActions.isLocationEnabled()) }
val isLiveLocationSharingEnabled by remember { val isLiveLocationSharingEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
}.collectAsState(false) }.collectAsState(false)
@@ -74,55 +78,46 @@ class ShareLocationPresenter(
val currentUser by client.userProfile.collectAsState() val currentUser by client.userProfile.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(permissionsState.permissions) { fun checkLocationConstraints() {
if (permissionsState.isAnyGranted) { val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
trackUserPosition = true dialogState = Constraints(locationConstraints.toDialogState())
dialogState = ShareLocationState.Dialog.None trackUserPosition = locationConstraints is LocationConstraintsCheckResult.Success
}
} }
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
fun handleEvent(event: ShareLocationEvent) { fun handleEvent(event: ShareLocationEvent) {
when (event) { when (event) {
is ShareLocationEvent.ShareStaticLocation -> scope.launch { is ShareLocationEvent.ShareStaticLocation -> scope.launch {
shareStaticLocation(event) shareStaticLocation(event)
} }
ShareLocationEvent.StartTrackingUserPosition -> when { ShareLocationEvent.StartTrackingUserLocation -> checkLocationConstraints()
permissionsState.isAnyGranted -> { ShareLocationEvent.StopTrackingUserLocation -> trackUserPosition = false
if (!locationActions.isLocationEnabled()) {
dialogState = ShareLocationState.Dialog.LocationServiceDisabled
} else {
trackUserPosition = true
}
}
permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale
else -> dialogState = ShareLocationState.Dialog.PermissionDenied
}
ShareLocationEvent.StopTrackingUserPosition -> trackUserPosition = false
ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None
ShareLocationEvent.OpenAppSettings -> { ShareLocationEvent.OpenAppSettings -> {
locationActions.openSettings() locationActions.openAppSettings()
dialogState = ShareLocationState.Dialog.None dialogState = ShareLocationState.Dialog.None
} }
ShareLocationEvent.OpenLocationSettings -> { ShareLocationEvent.OpenLocationSettings -> {
locationActions.openLocationSettings() locationActions.openLocationSettings()
dialogState = ShareLocationState.Dialog.None dialogState = ShareLocationState.Dialog.None
} }
ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) ShareLocationEvent.ShowLiveLocationDurationPicker -> {
ShareLocationEvent.ShowLiveLocationDurationPicker -> dialogState = when { val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
permissionsState.isAnyGranted -> { dialogState = if (constraintsResult is LocationConstraintsCheckResult.Success) {
if (!locationActions.isLocationEnabled()) { ShareLocationState.Dialog.LiveLocationDuration
ShareLocationState.Dialog.LocationServiceDisabled } else {
} else { Constraints(constraintsResult.toDialogState())
ShareLocationState.Dialog.LiveLocationDuration
}
} }
permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale
else -> ShareLocationState.Dialog.PermissionDenied
} }
is ShareLocationEvent.StartLiveLocationShare -> scope.launch { is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
dialogState = ShareLocationState.Dialog.None dialogState = ShareLocationState.Dialog.None
//room.startLiveLocationShare(event.duration.inWholeMilliseconds) //room.startLiveLocationShare(event.duration.inWholeMilliseconds)
} }
ShareLocationEvent.RequestPermissions -> {
dialogState = ShareLocationState.Dialog.None
permissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
} }
} }

View File

@@ -8,6 +8,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.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
data class ShareLocationState( data class ShareLocationState(
@@ -21,9 +22,7 @@ data class ShareLocationState(
) { ) {
sealed interface Dialog { sealed interface Dialog {
data object None : Dialog data object None : Dialog
data object PermissionRationale : Dialog data class Constraints(val state: LocationConstraintsDialogState) : Dialog
data object PermissionDenied : Dialog
data object LocationServiceDisabled : Dialog
data object LiveLocationDuration : Dialog data object LiveLocationDuration : Dialog
} }
} }

View File

@@ -9,6 +9,7 @@
package io.element.android.features.location.impl.share 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.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -18,37 +19,37 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
override val values: Sequence<ShareLocationState> override val values: Sequence<ShareLocationState>
get() = sequenceOf( get() = sequenceOf(
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.None, dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = false, hasLocationPermission = false,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.PermissionDenied, dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = false, hasLocationPermission = false,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.PermissionRationale, dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = false, hasLocationPermission = false,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.LocationServiceDisabled, dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = true, hasLocationPermission = true,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.None, dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = true, hasLocationPermission = true,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.None, dialogState = ShareLocationState.Dialog.None,
trackUserPosition = true, trackUserPosition = true,
hasLocationPermission = true, hasLocationPermission = true,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.LiveLocationDuration, dialogState = ShareLocationState.Dialog.LiveLocationDuration,
trackUserPosition = true, trackUserPosition = true,
hasLocationPermission = true, hasLocationPermission = true,
canShareLiveLocation = true, canShareLiveLocation = true,
@@ -58,14 +59,14 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
private fun aShareLocationState( private fun aShareLocationState(
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
permissionDialog: ShareLocationState.Dialog, dialogState: ShareLocationState.Dialog,
trackUserPosition: Boolean, trackUserPosition: Boolean,
hasLocationPermission: Boolean, hasLocationPermission: Boolean,
canShareLiveLocation: Boolean = false, canShareLiveLocation: Boolean = false,
): ShareLocationState { ): ShareLocationState {
return ShareLocationState( return ShareLocationState(
currentUser = currentUser, currentUser = currentUser,
dialogState = permissionDialog, dialogState = dialogState,
trackUserLocation = trackUserPosition, trackUserLocation = trackUserPosition,
hasLocationPermission = hasLocationPermission, hasLocationPermission = hasLocationPermission,
canShareLiveLocation = canShareLiveLocation, canShareLiveLocation = canShareLiveLocation,

View File

@@ -35,10 +35,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.Location 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.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
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.LocationServiceDisabledDialog
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.ui.rememberUserLocationState import io.element.android.features.location.impl.common.ui.rememberUserLocationState
@@ -58,7 +56,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.location.UserLocationState import org.maplibre.compose.location.UserLocationState
@@ -71,24 +68,14 @@ fun ShareLocationView(
navigateUp: () -> Unit, navigateUp: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LaunchedEffect(Unit) { when (val dialogState = state.dialogState) {
state.eventSink(ShareLocationEvent.RequestPermissions)
}
when (state.dialogState) {
ShareLocationState.Dialog.None -> Unit ShareLocationState.Dialog.None -> Unit
ShareLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
onContinue = { state.eventSink(ShareLocationEvent.OpenAppSettings) }, state = dialogState.state,
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
appName = state.appName, appName = state.appName,
) onRequestPermissions = { state.eventSink(ShareLocationEvent.RequestPermissions) },
ShareLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( onOpenAppSettings = { state.eventSink(ShareLocationEvent.OpenAppSettings) },
onContinue = { state.eventSink(ShareLocationEvent.RequestPermissions) }, onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
appName = state.appName,
)
ShareLocationState.Dialog.LocationServiceDisabled -> LocationServiceDisabledDialog(
onContinue = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
) )
ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog( ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog(
@@ -103,12 +90,12 @@ fun ShareLocationView(
val scaffoldState = rememberBottomSheetScaffoldState( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded) bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded)
) )
val cameraState = rememberCameraState(firstPosition = CameraPosition(zoom = MapDefaults.DEFAULT_ZOOM)) val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
val userLocationState = rememberUserLocationState(state.hasLocationPermission) val userLocationState = rememberUserLocationState(state.hasLocationPermission)
LaunchedEffect(cameraState.isCameraMoving) { LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) { if (cameraState.moveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShareLocationEvent.StopTrackingUserPosition) state.eventSink(ShareLocationEvent.StopTrackingUserLocation)
} }
} }
@@ -159,7 +146,7 @@ fun ShareLocationView(
} }
LocationFloatingActionButton( LocationFloatingActionButton(
isMapCenteredOnUser = state.trackUserLocation, isMapCenteredOnUser = state.trackUserLocation,
onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserPosition) }, onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) },
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(all = 16.dp), .padding(all = 16.dp),
@@ -176,39 +163,34 @@ private fun BottomSheetContent(
navigateUp: () -> Unit, navigateUp: () -> Unit,
) { ) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
SharePinLocationItem( val userLocation = userLocationState.location
onClick = { if (state.trackUserLocation && userLocation != null) {
val positionTarget = cameraState.position.target ShareCurrentLocationItem {
state.eventSink( state.eventSink(
ShareLocationEvent.ShareStaticLocation( ShareLocationEvent.ShareStaticLocation(
location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), location = Location(
isPinned = true lat = userLocation.position.latitude,
lon = userLocation.position.longitude
),
isPinned = false
) )
) )
navigateUp() navigateUp()
} }
) } else {
ShareCurrentLocationItem( SharePinLocationItem(
onClick = { onClick = {
val userLocation = userLocationState.location val positionTarget = cameraState.position.target
if (state.hasLocationPermission) { state.eventSink(
if (userLocation == null) { ShareLocationEvent.ShareStaticLocation(
// location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude),
} else { isPinned = true
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(
lat = userLocation.position.latitude,
lon = userLocation.position.longitude
),
isPinned = false
)
) )
navigateUp() )
} navigateUp()
} }
} )
) }
if (state.canShareLiveLocation) { if (state.canShareLiveLocation) {
ShareLiveLocationItem { ShareLiveLocationItem {
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)

View File

@@ -19,11 +19,15 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.LocationConstraintsCheckResult
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
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.actions.LocationActions import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.checkLocationConstraints
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.features.location.impl.common.ui.LocationMarkerData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
@@ -54,13 +58,13 @@ class ShowLocationPresenter(
val permissionsState: PermissionsState = permissionsPresenter.present() val permissionsState: PermissionsState = permissionsPresenter.present()
var isTrackMyLocation by remember { mutableStateOf(false) } var isTrackMyLocation by remember { mutableStateOf(false) }
val appName by remember { derivedStateOf { buildMeta.applicationName } } val appName by remember { derivedStateOf { buildMeta.applicationName } }
var permissionDialog: ShowLocationState.Dialog by remember { var dialogState: LocationConstraintsDialogState by remember {
mutableStateOf(ShowLocationState.Dialog.None) mutableStateOf(LocationConstraintsDialogState.None)
} }
LaunchedEffect(permissionsState.permissions) { LaunchedEffect(permissionsState.permissions) {
if (permissionsState.isAnyGranted) { if (permissionsState.isAnyGranted) {
permissionDialog = ShowLocationState.Dialog.None dialogState = LocationConstraintsDialogState.None
} }
} }
@@ -71,29 +75,21 @@ class ShowLocationPresenter(
} }
is ShowLocationEvents.TrackMyLocation -> { is ShowLocationEvents.TrackMyLocation -> {
if (event.enabled) { if (event.enabled) {
when { val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
permissionsState.isAnyGranted -> { isTrackMyLocation = locationConstraints is LocationConstraintsCheckResult.Success
if (!locationActions.isLocationEnabled()) { dialogState = locationConstraints.toDialogState()
permissionDialog = ShowLocationState.Dialog.LocationServiceDisabled
} else {
isTrackMyLocation = true
}
}
permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale
else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied
}
} else { } else {
isTrackMyLocation = false isTrackMyLocation = false
} }
} }
ShowLocationEvents.DismissDialog -> permissionDialog = ShowLocationState.Dialog.None ShowLocationEvents.DismissDialog -> dialogState = LocationConstraintsDialogState.None
ShowLocationEvents.OpenAppSettings -> { ShowLocationEvents.OpenAppSettings -> {
locationActions.openSettings() locationActions.openAppSettings()
permissionDialog = ShowLocationState.Dialog.None dialogState = LocationConstraintsDialogState.None
} }
ShowLocationEvents.OpenLocationSettings -> { ShowLocationEvents.OpenLocationSettings -> {
locationActions.openLocationSettings() locationActions.openLocationSettings()
permissionDialog = ShowLocationState.Dialog.None dialogState = LocationConstraintsDialogState.None
} }
ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
} }
@@ -154,7 +150,7 @@ class ShowLocationPresenter(
} }
return ShowLocationState( return ShowLocationState(
permissionDialog = permissionDialog, dialogState = dialogState,
mode = mode, mode = mode,
markers = markers, markers = markers,
locationShares = locationShares, locationShares = locationShares,

View File

@@ -10,13 +10,14 @@ 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.api.ShowLocationMode import io.element.android.features.location.api.ShowLocationMode
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.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
data class ShowLocationState( data class ShowLocationState(
val permissionDialog: Dialog, val dialogState: LocationConstraintsDialogState,
val mode: ShowLocationMode, val mode: ShowLocationMode,
val markers: List<LocationMarkerData>, val markers: List<LocationMarkerData>,
val locationShares: List<LocationShareItem>, val locationShares: List<LocationShareItem>,
@@ -25,15 +26,7 @@ data class ShowLocationState(
val appName: String, val appName: String,
val eventSink: (ShowLocationEvents) -> Unit, val eventSink: (ShowLocationEvents) -> Unit,
) { ) {
val isSheetDraggable = locationShares.any { item -> item.isLive } val isSheetDraggable = locationShares.any { item -> item.isLive }
sealed interface Dialog {
data object None : Dialog
data object PermissionRationale : Dialog
data object PermissionDenied : Dialog
data object LocationServiceDisabled : Dialog
}
} }
data class LocationShareItem( data class LocationShareItem(

View File

@@ -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.api.ShowLocationMode import io.element.android.features.location.api.ShowLocationMode
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.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
@@ -25,13 +26,13 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
get() = sequenceOf( get() = sequenceOf(
aShowLocationState(), aShowLocationState(),
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied, constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
), ),
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale, constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
), ),
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.LocationServiceDisabled, constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
hasLocationPermission = true, hasLocationPermission = true,
), ),
aShowLocationState( aShowLocationState(
@@ -41,22 +42,11 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
hasLocationPermission = true, hasLocationPermission = true,
isTrackMyLocation = true, isTrackMyLocation = true,
), ),
aShowLocationState(
mode = aStaticLocationMode(senderName = "My favourite place!"),
),
aShowLocationState(
mode = aStaticLocationMode(
senderName = "For some reason I decided to write a small essay that wraps at just two lines!"
),
),
aShowLocationState(
mode = ShowLocationMode.Live,
),
) )
} }
fun aShowLocationState( fun aShowLocationState(
permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
mode: ShowLocationMode = aStaticLocationMode(), mode: ShowLocationMode = aStaticLocationMode(),
markers: List<LocationMarkerData>? = null, markers: List<LocationMarkerData>? = null,
locationSharers: List<LocationShareItem>? = null, locationSharers: List<LocationShareItem>? = null,
@@ -107,7 +97,7 @@ fun aShowLocationState(
ShowLocationMode.Live -> emptyList() ShowLocationMode.Live -> emptyList()
} }
return ShowLocationState( return ShowLocationState(
permissionDialog = permissionDialog, dialogState = constraintsDialogState,
mode = mode, mode = mode,
markers = effectiveMarkers, markers = effectiveMarkers,
locationShares = effectiveLocationSharers, locationShares = effectiveLocationSharers,

View File

@@ -27,10 +27,8 @@ 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.api.ShowLocationMode import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.ui.LocationServiceDisabledDialog import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
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.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
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
@@ -56,30 +54,21 @@ fun ShowLocationView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (state.permissionDialog) { LocationConstraintsDialog(
ShowLocationState.Dialog.None -> Unit state = state.dialogState,
ShowLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( appName = state.appName,
onContinue = { state.eventSink(ShowLocationEvents.OpenAppSettings) }, onRequestPermissions = { state.eventSink(ShowLocationEvents.RequestPermissions) },
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, onOpenAppSettings = { state.eventSink(ShowLocationEvents.OpenAppSettings) },
appName = state.appName, onOpenLocationSettings = { state.eventSink(ShowLocationEvents.OpenLocationSettings) },
) onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) },
ShowLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( )
onContinue = { state.eventSink(ShowLocationEvents.RequestPermissions) },
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) },
appName = state.appName,
)
ShowLocationState.Dialog.LocationServiceDisabled -> LocationServiceDisabledDialog(
onContinue = { state.eventSink(ShowLocationEvents.OpenLocationSettings) },
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) },
)
}
val initialPosition = when (val mode = state.mode) { val initialPosition = when (val mode = state.mode) {
is ShowLocationMode.Static -> CameraPosition( is ShowLocationMode.Static -> CameraPosition(
target = Position(latitude = mode.location.lat, longitude = mode.location.lon), target = Position(latitude = mode.location.lat, longitude = mode.location.lon),
zoom = MapDefaults.DEFAULT_ZOOM zoom = MapDefaults.DEFAULT_ZOOM
) )
ShowLocationMode.Live -> MapDefaults.centerCameraPosition ShowLocationMode.Live -> MapDefaults.defaultCameraPosition
} }
val cameraState = rememberCameraState(firstPosition = initialPosition) val cameraState = rememberCameraState(firstPosition = initialPosition)
val userLocationState = rememberUserLocationState(state.hasLocationPermission) val userLocationState = rememberUserLocationState(state.hasLocationPermission)

View File

@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 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 com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsState
import org.junit.Test
class LocationConstraintsCheckTest {
@Test
fun `checkLocationConstraints returns Success when permissions granted and location enabled`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheckResult.Success)
}
@Test
fun `checkLocationConstraints returns Success when some permissions granted and location enabled`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.SomeGranted,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheckResult.Success)
}
@Test
fun `checkLocationConstraints returns LocationServiceDisabled when permissions granted but location disabled`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
)
val locationActions = FakeLocationActions(isLocationEnabled = false)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheckResult.LocationServiceDisabled)
}
@Test
fun `checkLocationConstraints returns PermissionRationale when permissions denied with rationale`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheckResult.PermissionRationale)
}
@Test
fun `checkLocationConstraints returns PermissionDenied when permissions denied without rationale`() {
val permissionsState = aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
val result = checkLocationConstraints(permissionsState, locationActions)
assertThat(result).isEqualTo(LocationConstraintsCheckResult.PermissionDenied)
}
}

View File

@@ -30,7 +30,7 @@ class FakeLocationActions(
sharedLabel = label sharedLabel = label
} }
override fun openSettings() { override fun openAppSettings() {
openSettingsInvocationsCount++ openSettingsInvocationsCount++
} }

View File

@@ -14,7 +14,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -42,6 +44,8 @@ class DefaultShareLocationEntryPointTest {
messageComposerContext = FakeMessageComposerContext(), messageComposerContext = FakeMessageComposerContext(),
locationActions = FakeLocationActions(), locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
featureFlagService = FakeFeatureFlagService(),
client = FakeMatrixClient(),
) )
}, },
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),

View File

@@ -228,7 +228,7 @@ class ShareLocationPresenterTest {
assertThat(myLocationState.hasLocationPermission).isFalse() assertThat(myLocationState.hasLocationPermission).isFalse()
// Continue the dialog sends permission request to the permissions presenter // Continue the dialog sends permission request to the permissions presenter
myLocationState.eventSink(ShareLocationEvent.RequestPermissions) myLocationState.eventSink(ShareLocationEvent.StartTrackingUserLocation)
assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
} }
} }

View File

@@ -13,12 +13,15 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.impl.aPermissionsState import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
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
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -33,15 +36,25 @@ class ShowLocationPresenterTest {
private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakePermissionsPresenter = FakePermissionsPresenter()
private val fakeLocationActions = FakeLocationActions() private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val fakeDateFormatter = FakeDateFormatter()
private val location = Location(1.23, 4.56, 7.8f) private val location = Location(1.23, 4.56, 7.8f)
private val presenter = ShowLocationPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory { private fun createShowLocationPresenter(
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter mode: ShowLocationMode = ShowLocationMode.Static(
}, location = location,
locationActions = fakeLocationActions, senderName = "Alice",
senderId = UserId("@alice:matrix.org"),
senderAvatarUrl = null,
timestamp = System.currentTimeMillis(),
assetType = null,
),
locationActions: FakeLocationActions = fakeLocationActions,
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
locationActions = locationActions,
buildMeta = fakeBuildMeta, buildMeta = fakeBuildMeta,
location = location, dateFormatter = fakeDateFormatter,
description = A_DESCRIPTION,
) )
@Test @Test
@@ -54,11 +67,9 @@ class ShowLocationPresenterTest {
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
} }
@@ -74,11 +85,9 @@ class ShowLocationPresenterTest {
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
} }
@@ -89,11 +98,9 @@ class ShowLocationPresenterTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
} }
@@ -104,11 +111,9 @@ class ShowLocationPresenterTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.location).isEqualTo(location)
assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse() assertThat(initialState.isTrackMyLocation).isFalse()
} }
@@ -117,13 +122,12 @@ class ShowLocationPresenterTest {
@Test @Test
fun `uses action to share location`() = runTest { fun `uses action to share location`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ShowLocationEvents.Share) initialState.eventSink(ShowLocationEvents.Share(location))
assertThat(fakeLocationActions.sharedLocation).isEqualTo(location) assertThat(fakeLocationActions.sharedLocation).isEqualTo(location)
assertThat(fakeLocationActions.sharedLabel).isEqualTo(A_DESCRIPTION)
} }
} }
@@ -132,7 +136,7 @@ class ShowLocationPresenterTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.hasLocationPermission).isTrue()
@@ -149,7 +153,7 @@ class ShowLocationPresenterTest {
// Swipe the map to switch mode // Swipe the map to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(false)) initialState.eventSink(ShowLocationEvents.TrackMyLocation(false))
val trackLocationDisabledState = awaitItem() val trackLocationDisabledState = awaitItem()
assertThat(trackLocationDisabledState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse() assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse()
assertThat(trackLocationDisabledState.hasLocationPermission).isTrue() assertThat(trackLocationDisabledState.hasLocationPermission).isTrue()
} }
@@ -165,7 +169,7 @@ class ShowLocationPresenterTest {
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
@@ -173,14 +177,14 @@ class ShowLocationPresenterTest {
// Click on the button to switch mode // Click on the button to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val trackLocationState = awaitItem() val trackLocationState = awaitItem()
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog // Dismiss the dialog
initialState.eventSink(ShowLocationEvents.DismissDialog) initialState.eventSink(ShowLocationEvents.DismissDialog)
val dialogDismissedState = awaitItem() val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse() assertThat(dialogDismissedState.hasLocationPermission).isFalse()
} }
@@ -196,7 +200,7 @@ class ShowLocationPresenterTest {
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
@@ -204,7 +208,7 @@ class ShowLocationPresenterTest {
// Click on the button to switch mode // Click on the button to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val trackLocationState = awaitItem() val trackLocationState = awaitItem()
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse()
@@ -224,7 +228,7 @@ class ShowLocationPresenterTest {
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
@@ -232,14 +236,14 @@ class ShowLocationPresenterTest {
// Click on the button to switch mode // Click on the button to switch mode
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val trackLocationState = awaitItem() val trackLocationState = awaitItem()
assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionDenied) assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied)
assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.isTrackMyLocation).isFalse()
assertThat(trackLocationState.hasLocationPermission).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog // Dismiss the dialog
initialState.eventSink(ShowLocationEvents.DismissDialog) initialState.eventSink(ShowLocationEvents.DismissDialog)
val dialogDismissedState = awaitItem() val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
assertThat(dialogDismissedState.hasLocationPermission).isFalse() assertThat(dialogDismissedState.hasLocationPermission).isFalse()
} }
@@ -255,7 +259,7 @@ class ShowLocationPresenterTest {
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
// Skip initial state // Skip initial state
val initialState = awaitItem() val initialState = awaitItem()
@@ -267,7 +271,7 @@ class ShowLocationPresenterTest {
dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings) dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings)
val settingsOpenedState = awaitItem() val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
} }
} }
@@ -275,14 +279,53 @@ class ShowLocationPresenterTest {
@Test @Test
fun `application name is in state`() = runTest { fun `application name is in state`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() createShowLocationPresenter().present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo("app name") assertThat(initialState.appName).isEqualTo("app name")
} }
} }
companion object { @Test
private const val A_DESCRIPTION = "My happy place" fun `location service disabled shows dialog`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
fakeLocationActions.givenLocationEnabled(false)
moleculeFlow(RecompositionMode.Immediate) {
createShowLocationPresenter(locationActions = fakeLocationActions).present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasLocationPermission).isTrue()
// Try to track location when location services are disabled
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val dialogShownState = awaitItem()
assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled)
assertThat(dialogShownState.isTrackMyLocation).isFalse()
}
}
@Test
fun `open location settings from dialog`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
fakeLocationActions.givenLocationEnabled(false)
moleculeFlow(RecompositionMode.Immediate) {
createShowLocationPresenter(locationActions = fakeLocationActions).present()
}.test {
val initialState = awaitItem()
initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
val dialogShownState = awaitItem()
assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled)
// Open location settings
dialogShownState.eventSink(ShowLocationEvents.OpenLocationSettings)
val settingsOpenedState = awaitItem()
assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
}
} }
} }

View File

@@ -17,6 +17,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@@ -58,7 +60,8 @@ class ShowLocationViewTest {
) )
val shareContentDescription = rule.activity.getString(CommonStrings.action_share) val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
rule.onNodeWithContentDescription(shareContentDescription).performClick() rule.onNodeWithContentDescription(shareContentDescription).performClick()
eventsRecorder.assertSingle(ShowLocationEvents.Share) // The default aStaticLocationMode uses Location(1.23, 2.34, 4f)
eventsRecorder.assertSingle(ShowLocationEvents.Share(Location(1.23, 2.34, 4f)))
} }
@Test @Test
@@ -79,7 +82,7 @@ class ShowLocationViewTest {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied, constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
@@ -93,7 +96,7 @@ class ShowLocationViewTest {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionDenied, constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
@@ -107,7 +110,7 @@ class ShowLocationViewTest {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale, constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
@@ -121,7 +124,7 @@ class ShowLocationViewTest {
val eventsRecorder = EventsRecorder<ShowLocationEvents>() val eventsRecorder = EventsRecorder<ShowLocationEvents>()
rule.setShowLocationView( rule.setShowLocationView(
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale, constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),