Check location is enabled

This commit is contained in:
ganfra 2026-03-10 21:48:37 +01:00
parent 4bfe467ac1
commit b284984dad
14 changed files with 139 additions and 22 deletions

View file

@ -10,8 +10,11 @@ package io.element.android.features.location.impl.common.actions
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.provider.Settings
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.location.LocationManagerCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
@ -43,6 +46,23 @@ class AndroidLocationActions(
override fun openSettings() { override fun openSettings() {
context.openAppSettingsPage() context.openAppSettingsPage()
} }
override fun isLocationEnabled(): Boolean {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return LocationManagerCompat.isLocationEnabled(locationManager)
}
override fun openLocationSettings() {
runCatchingExceptions {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}.onSuccess {
Timber.v("Open location settings succeed")
}.onFailure {
Timber.e(it, "Open location settings failed")
}
}
} }
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap // Ref: https://developer.android.com/guide/components/intents-common#ViewMap

View file

@ -13,4 +13,6 @@ 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 openSettings()
fun isLocationEnabled(): Boolean
fun openLocationSettings()
} }

View file

@ -0,0 +1,27 @@
/*
* 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

@ -25,4 +25,5 @@ sealed interface 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
} }

View file

@ -33,12 +33,10 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -89,7 +87,13 @@ class ShareLocationPresenter(
shareStaticLocation(event) shareStaticLocation(event)
} }
ShareLocationEvent.StartTrackingUserPosition -> when { ShareLocationEvent.StartTrackingUserPosition -> when {
permissionsState.isAnyGranted -> trackUserPosition = true permissionsState.isAnyGranted -> {
if (!locationActions.isLocationEnabled()) {
dialogState = ShareLocationState.Dialog.LocationServiceDisabled
} else {
trackUserPosition = true
}
}
permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale
else -> dialogState = ShareLocationState.Dialog.PermissionDenied else -> dialogState = ShareLocationState.Dialog.PermissionDenied
} }
@ -99,9 +103,19 @@ class ShareLocationPresenter(
locationActions.openSettings() locationActions.openSettings()
dialogState = ShareLocationState.Dialog.None dialogState = ShareLocationState.Dialog.None
} }
ShareLocationEvent.OpenLocationSettings -> {
locationActions.openLocationSettings()
dialogState = ShareLocationState.Dialog.None
}
ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
ShareLocationEvent.ShowLiveLocationDurationPicker -> dialogState = when { ShareLocationEvent.ShowLiveLocationDurationPicker -> dialogState = when {
permissionsState.isAnyGranted -> ShareLocationState.Dialog.LiveLocationDuration permissionsState.isAnyGranted -> {
if (!locationActions.isLocationEnabled()) {
ShareLocationState.Dialog.LocationServiceDisabled
} else {
ShareLocationState.Dialog.LiveLocationDuration
}
}
permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale
else -> ShareLocationState.Dialog.PermissionDenied else -> ShareLocationState.Dialog.PermissionDenied
} }

View file

@ -23,6 +23,7 @@ data class ShareLocationState(
data object None : Dialog data object None : Dialog
data object PermissionRationale : Dialog data object PermissionRationale : Dialog
data object PermissionDenied : Dialog data object PermissionDenied : Dialog
data object LocationServiceDisabled : Dialog
data object LiveLocationDuration : Dialog data object LiveLocationDuration : Dialog
} }
} }

View file

@ -32,6 +32,11 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
trackUserPosition = false, trackUserPosition = false,
hasLocationPermission = false, hasLocationPermission = false,
), ),
aShareLocationState(
permissionDialog = ShareLocationState.Dialog.LocationServiceDisabled,
trackUserPosition = false,
hasLocationPermission = true,
),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.None, permissionDialog = ShareLocationState.Dialog.None,
trackUserPosition = false, trackUserPosition = false,

View file

@ -38,6 +38,7 @@ 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.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog 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.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.LocationPin
@ -90,6 +91,10 @@ fun ShareLocationView(
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
appName = state.appName, appName = state.appName,
) )
ShareLocationState.Dialog.LocationServiceDisabled -> LocationServiceDisabledDialog(
onContinue = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
)
ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog( ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog(
onSelectDuration = { duration -> onSelectDuration = { duration ->
state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration)) state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))

View file

@ -16,4 +16,5 @@ sealed interface ShowLocationEvents {
data object DismissDialog : ShowLocationEvents data object DismissDialog : ShowLocationEvents
data object RequestPermissions : ShowLocationEvents data object RequestPermissions : ShowLocationEvents
data object OpenAppSettings : ShowLocationEvents data object OpenAppSettings : ShowLocationEvents
data object OpenLocationSettings : ShowLocationEvents
} }

View file

@ -72,7 +72,13 @@ class ShowLocationPresenter(
is ShowLocationEvents.TrackMyLocation -> { is ShowLocationEvents.TrackMyLocation -> {
if (event.enabled) { if (event.enabled) {
when { when {
permissionsState.isAnyGranted -> isTrackMyLocation = true permissionsState.isAnyGranted -> {
if (!locationActions.isLocationEnabled()) {
permissionDialog = ShowLocationState.Dialog.LocationServiceDisabled
} else {
isTrackMyLocation = true
}
}
permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale
else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied
} }
@ -85,6 +91,10 @@ class ShowLocationPresenter(
locationActions.openSettings() locationActions.openSettings()
permissionDialog = ShowLocationState.Dialog.None permissionDialog = ShowLocationState.Dialog.None
} }
ShowLocationEvents.OpenLocationSettings -> {
locationActions.openLocationSettings()
permissionDialog = ShowLocationState.Dialog.None
}
ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
} }
} }

View file

@ -32,6 +32,7 @@ data class ShowLocationState(
data object None : Dialog data object None : Dialog
data object PermissionRationale : Dialog data object PermissionRationale : Dialog
data object PermissionDenied : Dialog data object PermissionDenied : Dialog
data object LocationServiceDisabled : Dialog
} }
} }

View file

@ -30,6 +30,10 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
aShowLocationState( aShowLocationState(
permissionDialog = ShowLocationState.Dialog.PermissionRationale, permissionDialog = ShowLocationState.Dialog.PermissionRationale,
), ),
aShowLocationState(
permissionDialog = ShowLocationState.Dialog.LocationServiceDisabled,
hasLocationPermission = true,
),
aShowLocationState( aShowLocationState(
hasLocationPermission = true, hasLocationPermission = true,
), ),

View file

@ -9,6 +9,8 @@
package io.element.android.features.location.impl.show package io.element.android.features.location.impl.show
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -23,23 +25,21 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons 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.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.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog import io.element.android.features.location.impl.common.PermissionRationaleDialog
import io.element.android.compound.theme.ElementTheme
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.libraries.designsystem.theme.components.Text
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
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -72,6 +72,10 @@ fun ShowLocationView(
onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) },
appName = state.appName, 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) {
@ -99,18 +103,18 @@ fun ShowLocationView(
} }
val scaffoldState = rememberBottomSheetScaffoldState( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = bottomSheetState = rememberStandardBottomSheetState(
if(state.isSheetDraggable) { initialValue =
SheetValue.PartiallyExpanded if (state.isSheetDraggable) {
}else { SheetValue.PartiallyExpanded
SheetValue.Expanded } else {
} SheetValue.Expanded
}
) )
) )
MapBottomSheetScaffold( MapBottomSheetScaffold(
//sheetPeekHeight = 180.dp, sheetDragHandle = if (state.isSheetDraggable) {
sheetDragHandle = if(state.isSheetDraggable) { { BottomSheetDefaults.DragHandle() }
{BottomSheetDefaults.DragHandle()}
} else { } else {
null null
}, },
@ -130,8 +134,9 @@ fun ShowLocationView(
}, },
sheetContent = { sheetPaddings -> sheetContent = { sheetPaddings ->
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
Spacer(Modifier.height(20.dp))
Text( Text(
text = "On the map", text = stringResource(CommonStrings.screen_static_location_sheet_title),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
@ -142,7 +147,11 @@ fun ShowLocationView(
onShareClick = { state.eventSink(ShowLocationEvents.Share(locationShare.location)) }, onShareClick = { state.eventSink(ShowLocationEvents.Share(locationShare.location)) },
modifier = Modifier.clickable { modifier = Modifier.clickable {
state.eventSink(ShowLocationEvents.TrackMyLocation(false)) state.eventSink(ShowLocationEvents.TrackMyLocation(false))
val position = CameraPosition(padding = sheetPaddings, target = Position(locationShare.location.lon, locationShare.location.lat), zoom = MapDefaults.DEFAULT_ZOOM) val position = CameraPosition(
padding = sheetPaddings,
target = Position(locationShare.location.lon, locationShare.location.lat),
zoom = MapDefaults.DEFAULT_ZOOM
)
coroutineScope.launch { coroutineScope.launch {
cameraState.animateTo(finalPosition = position) cameraState.animateTo(finalPosition = position)
} }

View file

@ -10,7 +10,9 @@ package io.element.android.features.location.impl.common.actions
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
class FakeLocationActions : LocationActions { class FakeLocationActions(
private var isLocationEnabled: Boolean = true,
) : LocationActions {
var sharedLocation: Location? = null var sharedLocation: Location? = null
private set private set
@ -20,6 +22,9 @@ class FakeLocationActions : LocationActions {
var openSettingsInvocationsCount = 0 var openSettingsInvocationsCount = 0
private set private set
var openLocationSettingsInvocationsCount = 0
private set
override fun share(location: Location, label: String?) { override fun share(location: Location, label: String?) {
sharedLocation = location sharedLocation = location
sharedLabel = label sharedLabel = label
@ -28,4 +33,16 @@ class FakeLocationActions : LocationActions {
override fun openSettings() { override fun openSettings() {
openSettingsInvocationsCount++ openSettingsInvocationsCount++
} }
override fun isLocationEnabled(): Boolean {
return isLocationEnabled
}
override fun openLocationSettings() {
openLocationSettingsInvocationsCount++
}
fun givenLocationEnabled(enabled: Boolean) {
isLocationEnabled = enabled
}
} }