Adapt location feature to updated maplibre-compose API

This commit is contained in:
ganfra
2026-09-04 18:16:20 +02:00
parent 7045465ffe
commit 3754258d88
7 changed files with 217 additions and 134 deletions
@@ -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(
@@ -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,
@@ -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)
}
}
@@ -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<LocationEvent> {
return flowOf(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied))
}
}
@Composable
fun rememberNotGrantedLocationProvider(): NotGrantedLocationProvider {
return remember { NotGrantedLocationProvider() }
}
@@ -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<Location?>
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<LocationEvent> = 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)
}
}
@@ -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)
}
@@ -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,