diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt index 1093e5760a..60e53b4f2b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt @@ -9,11 +9,9 @@ package io.element.android.features.location.impl.common import android.Manifest -import androidx.compose.ui.Alignment import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions -import org.maplibre.compose.map.OrnamentOptions import org.maplibre.compose.map.RenderOptions import org.maplibre.spatialk.geojson.Position @@ -24,14 +22,6 @@ object MapDefaults { val options = MapOptions( renderOptions = RenderOptions.Standard, gestureOptions = GestureOptions.Standard, - ornamentOptions = OrnamentOptions( - isLogoEnabled = true, - logoAlignment = Alignment.BottomStart, - isAttributionEnabled = true, - attributionAlignment = Alignment.BottomEnd, - isCompassEnabled = false, - isScaleBarEnabled = false, - ) ) val defaultCameraPosition = CameraPosition( diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index 84c4ab4fba..983e9fc029 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -7,16 +7,19 @@ package io.element.android.features.location.impl.common.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only @@ -32,7 +35,6 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -55,6 +57,9 @@ import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.overlay.ExpandingAttributionButton +import org.maplibre.compose.overlay.MapOverlay +import org.maplibre.compose.overlay.MaplibreLogo import org.maplibre.compose.style.BaseStyle import org.maplibre.compose.util.MaplibreComposable import kotlin.math.roundToInt @@ -115,10 +120,6 @@ fun MapBottomSheetScaffold( PaddingValues(bottom = bottomPadding) } } - // Update camera position when sheet padding changes - LaunchedEffect(sheetPadding) { - cameraState.position = cameraState.position.copy(padding = sheetPadding) - } BottomSheetScaffold( modifier = Modifier, sheetPeekHeight = sheetPeekHeight, @@ -135,14 +136,27 @@ fun MapBottomSheetScaffold( snackbarHost = snackbarHost, topBar = topBar, ) { - val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding) - val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions) Box { when (customMapTilerConfig) { is AsyncData.Success -> { MaplibreMap( options = mapOptions, + cameraPadding = sheetPadding, + contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()), baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)), + overlay = MapOverlay { + val style = styleState + Row( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MaplibreLogo() + ExpandingAttributionButton(cameraState = cameraState, styleState = style) + } + }, modifier = Modifier.fillMaxSize(), cameraState = cameraState, content = mapContent, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt index 01a2b92d4a..266c8f266c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt @@ -7,14 +7,16 @@ 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 androidx.compose.runtime.remember 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 kotlinx.coroutines.flow.mapNotNull +import org.maplibre.compose.location.LocationAccuracy +import org.maplibre.compose.location.LocationEvent +import org.maplibre.compose.location.LocationRequest import org.maplibre.spatialk.units.extensions.meters import kotlin.time.Duration.Companion.seconds @@ -22,17 +24,27 @@ import kotlin.time.Duration.Companion.seconds class DefaultUserLocationStateFactory : UserLocationState.Factory { @Composable override fun create(hasLocationPermission: Boolean): UserLocationState { - val locationProvider = if (!hasLocationPermission) { - rememberNullLocationProvider() + val locationProvider = if (hasLocationPermission) { + rememberPlatformLocationProvider() } else { - @SuppressLint("MissingPermission") - rememberPlatformLocationProvider( - updateInterval = 5.seconds, - desiredAccuracy = DesiredAccuracy.High, - minDistance = 5.meters, - ) + rememberNotGrantedLocationProvider() } - val location by locationProvider.location.collectAsState() + val locationFlow = remember(locationProvider) { + val locationRequest = LocationRequest( + accuracy = LocationAccuracy.High, + minimumInterval = 5.seconds, + minimumDistance = 5.meters, + ) + locationProvider + .updates(locationRequest) + .mapNotNull { event -> + when (event) { + is LocationEvent.Fix -> event.location + is LocationEvent.Unavailable -> null + } + } + } + val location by locationFlow.collectAsState(null) return UserLocationState(location) } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/NotGrantedLocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/NotGrantedLocationProvider.kt new file mode 100644 index 0000000000..4154817bc6 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/NotGrantedLocationProvider.kt @@ -0,0 +1,28 @@ +/* + * 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 androidx.compose.runtime.remember +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.maplibre.compose.location.LocationEvent +import org.maplibre.compose.location.LocationProvider +import org.maplibre.compose.location.LocationRequest +import org.maplibre.compose.location.LocationUnavailableReason + +class NotGrantedLocationProvider : LocationProvider { + override fun updates(request: LocationRequest): Flow { + return flowOf(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied)) + } +} + +@Composable +fun rememberNotGrantedLocationProvider(): NotGrantedLocationProvider { + return remember { NotGrantedLocationProvider() } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.kt index 54e6d19cf6..35bf7e67a7 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.kt @@ -9,83 +9,126 @@ package io.element.android.features.location.impl.common.userlocation import android.Manifest import android.annotation.SuppressLint +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager +import android.location.Location import android.location.LocationManager import android.os.Handler import android.os.HandlerThread -import androidx.annotation.RequiresPermission import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import androidx.core.location.LocationListenerCompat import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationRequestCompat import androidx.core.os.ExecutorCompat -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.stateIn -import org.maplibre.compose.location.DesiredAccuracy -import org.maplibre.compose.location.Location +import org.maplibre.compose.location.LocationAccuracy +import org.maplibre.compose.location.LocationEvent import org.maplibre.compose.location.LocationProvider -import org.maplibre.compose.location.PermissionException +import org.maplibre.compose.location.LocationRequest +import org.maplibre.compose.location.LocationUnavailableReason import org.maplibre.compose.location.asMapLibreLocation -import org.maplibre.spatialk.units.Length import org.maplibre.spatialk.units.extensions.inMeters -import kotlin.time.Duration -@SuppressLint("InlinedApi") class PlatformLocationProvider( - context: Context, - private val updateInterval: Duration, - private val minDistance: Length, - private val desiredAccuracy: DesiredAccuracy = DesiredAccuracy.High, - coroutineScope: CoroutineScope, - sharingStarted: SharingStarted = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000), + private val context: Context, ) : LocationProvider { - override val location: StateFlow - init { - if (!handlerThread.isAlive) handlerThread.start() - if ( - context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && - context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED - ) { - throw PermissionException() + @SuppressLint("MissingPermission") + override fun updates(request: LocationRequest): Flow = callbackFlow { + if (!context.hasLocationPermission()) { + trySend(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied)) + close() + return@callbackFlow } val locationManager = context.getSystemService(LocationManager::class.java) - val provider = PROVIDERS_BY_PRIORITY.firstOrNull { LocationManagerCompat.hasProvider(locationManager, it) } - val locationFlow = if (provider != null) { - createProviderFlow(locationManager, provider) - } else { - emptyFlow() - } - location = locationFlow.stateIn(coroutineScope, sharingStarted, null) - } + val listener = object : LocationListenerCompat { + override fun onLocationChanged(location: Location) { + trySend(LocationEvent.Fix(location.asMapLibreLocation())) + } - @RequiresPermission( - anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION] - ) - private fun createProviderFlow(locationManager: LocationManager, provider: String) = callbackFlow { - send(locationManager.getLastKnownLocation(provider)?.asMapLibreLocation()) - val listener = LocationListenerCompat { trySend(it.asMapLibreLocation()) } - val request = LocationRequestCompat.Builder(updateInterval.inWholeMilliseconds) - .setQuality(desiredAccuracy.toLocationRequestQuality()) - .setMinUpdateDistanceMeters(minDistance.inMeters.toFloat()) - .build() - LocationManagerCompat.requestLocationUpdates( - locationManager, - provider, - request, - ExecutorCompat.create(Handler(handlerThread.looper)), - listener, - ) - awaitClose { LocationManagerCompat.removeUpdates(locationManager, listener) } + override fun onProviderDisabled(provider: String) { + trySend(LocationEvent.Unavailable(LocationUnavailableReason.ServicesDisabled)) + } + } + var registered = false + + fun refreshRegistration() { + if (registered) { + LocationManagerCompat.removeUpdates(locationManager, listener) + registered = false + } + if (!LocationManagerCompat.isLocationEnabled(locationManager)) { + trySend(LocationEvent.Unavailable(LocationUnavailableReason.ServicesDisabled)) + return + } + val provider = PROVIDERS_BY_PRIORITY.firstOrNull { LocationManagerCompat.hasProvider(locationManager, it) } + if (provider == null) { + trySend(LocationEvent.Unavailable(LocationUnavailableReason.Unsupported)) + return + } + locationManager.getLastKnownLocation(provider)?.let { location -> + trySend(LocationEvent.Fix(location.asMapLibreLocation())) + } + val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds) + .setQuality(request.accuracy.toLocationRequestQuality()) + .setMinUpdateDistanceMeters(request.minimumDistance.inMeters.toFloat()) + .build() + LocationManagerCompat.requestLocationUpdates( + locationManager, + provider, + locationRequestCompat, + ExecutorCompat.create(Handler(handlerThread.looper)), + listener, + ) + registered = true + } + + // Re-select and re-register when the user toggles location settings, so updates resume + // automatically once a usable provider comes back. + val settingsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + try { + refreshRegistration() + } catch (error: SecurityException) { + trySend(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied, error)) + close() + } catch (error: IllegalArgumentException) { + trySend(LocationEvent.Unavailable(LocationUnavailableReason.UnexpectedFailure, error)) + close() + } + } + } + + try { + ContextCompat.registerReceiver( + context, + settingsReceiver, + IntentFilter().apply { + addAction(LocationManager.MODE_CHANGED_ACTION) + addAction(LocationManager.PROVIDERS_CHANGED_ACTION) + }, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + refreshRegistration() + } catch (error: SecurityException) { + trySend(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied, error)) + close() + } catch (error: IllegalArgumentException) { + trySend(LocationEvent.Unavailable(LocationUnavailableReason.UnexpectedFailure, error)) + close() + } + awaitClose { + runCatching { context.unregisterReceiver(settingsReceiver) } + LocationManagerCompat.removeUpdates(locationManager, listener) + } } private companion object { @@ -94,36 +137,27 @@ class PlatformLocationProvider( LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, ) - private val handlerThread by lazy { HandlerThread("PlatformLocationProvider") } + private val handlerThread by lazy { + HandlerThread("PlatformLocationProvider").apply { start() } + } } } -private fun DesiredAccuracy.toLocationRequestQuality(): Int = when (this) { - DesiredAccuracy.Highest, DesiredAccuracy.High -> LocationRequestCompat.QUALITY_HIGH_ACCURACY - DesiredAccuracy.Balanced -> LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY - DesiredAccuracy.Low, DesiredAccuracy.Lowest -> LocationRequestCompat.QUALITY_LOW_POWER +private fun Context.hasLocationPermission(): Boolean = + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED || + checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + +private fun LocationAccuracy.toLocationRequestQuality(): Int = when (this) { + LocationAccuracy.BestForNavigation, LocationAccuracy.High -> LocationRequestCompat.QUALITY_HIGH_ACCURACY + LocationAccuracy.Balanced -> LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY + LocationAccuracy.Low, LocationAccuracy.Lowest -> LocationRequestCompat.QUALITY_LOW_POWER } @Composable -@RequiresPermission( - anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION] -) -fun rememberPlatformLocationProvider( - updateInterval: Duration, - minDistance: Length, - desiredAccuracy: DesiredAccuracy = DesiredAccuracy.High, - context: Context = LocalContext.current, - coroutineScope: CoroutineScope = rememberCoroutineScope(), - sharingStarted: SharingStarted = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000), -): PlatformLocationProvider { - return remember(context, updateInterval, minDistance, desiredAccuracy, coroutineScope, sharingStarted) { - PlatformLocationProvider( - context = context, - updateInterval = updateInterval, - minDistance = minDistance, - desiredAccuracy = desiredAccuracy, - coroutineScope = coroutineScope, - sharingStarted = sharingStarted, - ) +fun rememberPlatformLocationProvider(context: Context = LocalContext.current): PlatformLocationProvider { + return remember(context) { + PlatformLocationProvider(context = context) } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt index 21a0a081d8..b5a966b094 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt @@ -30,15 +30,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import org.maplibre.compose.location.DesiredAccuracy -import org.maplibre.compose.location.PermissionException +import org.maplibre.compose.location.LocationAccuracy +import org.maplibre.compose.location.LocationEvent +import org.maplibre.compose.location.LocationRequest +import org.maplibre.compose.location.LocationUnavailableReason import org.maplibre.spatialk.units.extensions.inMeters import org.maplibre.spatialk.units.extensions.meters import timber.log.Timber @@ -91,31 +90,38 @@ class LiveLocationSharingService : Service() { @OptIn(ExperimentalCoroutinesApi::class) private fun startLocationUpdatesListener() { Timber.d("LiveLocationSharingService listening to location updates") + val locationProvider = PlatformLocationProvider(context = applicationContext) appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow() .flatMapLatest { minDistanceMeters -> - try { - PlatformLocationProvider( - context = applicationContext, - updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds, - minDistance = minDistanceMeters.meters, - desiredAccuracy = DesiredAccuracy.Balanced, - coroutineScope = coroutineScope - ).location - } catch (exception: PermissionException) { - Timber.e(exception, "Failed to create PlatformLocationProvider") - coordinator.dispatchUnrecoverableError() - emptyFlow() + val locationRequest = LocationRequest( + accuracy = LocationAccuracy.Balanced, + minimumInterval = UPDATE_INTERVAL_IN_SECOND.seconds, + minimumDistance = minDistanceMeters.meters + ) + locationProvider.updates(locationRequest) + } + .onEach { locationEvent -> + when (locationEvent) { + is LocationEvent.Fix -> { + val location = ApiLocation( + lat = locationEvent.location.position.value.latitude, + lon = locationEvent.location.position.value.longitude, + accuracy = locationEvent.location.position.accuracy?.inMeters?.toFloat(), + ) + coordinator.dispatch(location) + } + is LocationEvent.Unavailable -> { + when (locationEvent.reason) { + LocationUnavailableReason.ServicesDisabled, + LocationUnavailableReason.TemporarilyUnavailable -> Unit + LocationUnavailableReason.Unsupported, + LocationUnavailableReason.Misconfigured, + LocationUnavailableReason.PermissionDenied, + LocationUnavailableReason.UnexpectedFailure -> coordinator.dispatchUnrecoverableError() + } + } } } - .filterNotNull() - .map { location -> - ApiLocation( - lat = location.position.value.latitude, - lon = location.position.value.longitude, - accuracy = location.position.accuracy?.inMeters?.toFloat(), - ) - } - .onEach(coordinator::dispatch) .launchIn(coroutineScope) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 26eb1ee27a..4600992cd5 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -154,7 +154,6 @@ fun ShowLocationView( modifier = Modifier.clickable { state.eventSink(ShowLocationEvent.TrackMyLocation(false)) val position = CameraPosition( - padding = sheetPaddings, target = Position(locationShare.location.lon, locationShare.location.lat), // Force pointing to NORTH bearing = 0.0,