mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Merge pull request #7663 from element-hq/feature/fga/maplibre-compose-update
Update maplibre compose to 0.15
This commit is contained in:
@@ -288,6 +288,10 @@ dependencies {
|
|||||||
implementation(projects.libraries.pushproviders.unifiedpush)
|
implementation(projects.libraries.pushproviders.unifiedpush)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Google Play Services fused location backend. Discovered at runtime via ServiceLoader by the
|
||||||
|
// location feature.
|
||||||
|
"gplayImplementation"(libs.maplibre.compose.location.runtime.gms)
|
||||||
|
|
||||||
implementation(libs.appyx.core)
|
implementation(libs.appyx.core)
|
||||||
implementation(libs.androidx.splash)
|
implementation(libs.androidx.splash)
|
||||||
implementation(libs.androidx.core)
|
implementation(libs.androidx.core)
|
||||||
@@ -325,6 +329,7 @@ licensee {
|
|||||||
allow("BSD-3-Clause")
|
allow("BSD-3-Clause")
|
||||||
allow("EPL-1.0")
|
allow("EPL-1.0")
|
||||||
allowUrl("https://opensource.org/license/bsd-3-clause")
|
allowUrl("https://opensource.org/license/bsd-3-clause")
|
||||||
|
allowUrl("https://opensource.org/license/bsd-2-clause")
|
||||||
allowUrl("https://opensource.org/licenses/MIT")
|
allowUrl("https://opensource.org/licenses/MIT")
|
||||||
allowUrl("https://developer.android.com/studio/terms.html")
|
allowUrl("https://developer.android.com/studio/terms.html")
|
||||||
allowUrl("https://www.zetetic.net/sqlcipher/license/")
|
allowUrl("https://www.zetetic.net/sqlcipher/license/")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ dependencies {
|
|||||||
implementation(projects.features.enterprise.api)
|
implementation(projects.features.enterprise.api)
|
||||||
implementation(projects.features.messages.api)
|
implementation(projects.features.messages.api)
|
||||||
implementation(libs.maplibre.compose)
|
implementation(libs.maplibre.compose)
|
||||||
|
runtimeOnly(libs.maplibre.compose.runtime.vulkan)
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|||||||
-10
@@ -9,11 +9,9 @@
|
|||||||
package io.element.android.features.location.impl.common
|
package io.element.android.features.location.impl.common
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import org.maplibre.compose.camera.CameraPosition
|
import org.maplibre.compose.camera.CameraPosition
|
||||||
import org.maplibre.compose.map.GestureOptions
|
import org.maplibre.compose.map.GestureOptions
|
||||||
import org.maplibre.compose.map.MapOptions
|
import org.maplibre.compose.map.MapOptions
|
||||||
import org.maplibre.compose.map.OrnamentOptions
|
|
||||||
import org.maplibre.compose.map.RenderOptions
|
import org.maplibre.compose.map.RenderOptions
|
||||||
import org.maplibre.spatialk.geojson.Position
|
import org.maplibre.spatialk.geojson.Position
|
||||||
|
|
||||||
@@ -24,14 +22,6 @@ object MapDefaults {
|
|||||||
val options = MapOptions(
|
val options = MapOptions(
|
||||||
renderOptions = RenderOptions.Standard,
|
renderOptions = RenderOptions.Standard,
|
||||||
gestureOptions = GestureOptions.Standard,
|
gestureOptions = GestureOptions.Standard,
|
||||||
ornamentOptions = OrnamentOptions(
|
|
||||||
isLogoEnabled = true,
|
|
||||||
logoAlignment = Alignment.BottomStart,
|
|
||||||
isAttributionEnabled = true,
|
|
||||||
attributionAlignment = Alignment.BottomEnd,
|
|
||||||
isCompassEnabled = false,
|
|
||||||
isScaleBarEnabled = false,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val defaultCameraPosition = CameraPosition(
|
val defaultCameraPosition = CameraPosition(
|
||||||
|
|||||||
+21
-7
@@ -7,16 +7,19 @@
|
|||||||
|
|
||||||
package io.element.android.features.location.impl.common.ui
|
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.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
@@ -32,7 +35,6 @@ import androidx.compose.material3.SnackbarHostState
|
|||||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
import androidx.compose.material3.rememberBottomSheetState
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
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.camera.rememberCameraState
|
||||||
import org.maplibre.compose.map.MapOptions
|
import org.maplibre.compose.map.MapOptions
|
||||||
import org.maplibre.compose.map.MaplibreMap
|
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.style.BaseStyle
|
||||||
import org.maplibre.compose.util.MaplibreComposable
|
import org.maplibre.compose.util.MaplibreComposable
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -115,10 +120,6 @@ fun MapBottomSheetScaffold(
|
|||||||
PaddingValues(bottom = bottomPadding)
|
PaddingValues(bottom = bottomPadding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Update camera position when sheet padding changes
|
|
||||||
LaunchedEffect(sheetPadding) {
|
|
||||||
cameraState.position = cameraState.position.copy(padding = sheetPadding)
|
|
||||||
}
|
|
||||||
BottomSheetScaffold(
|
BottomSheetScaffold(
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
sheetPeekHeight = sheetPeekHeight,
|
sheetPeekHeight = sheetPeekHeight,
|
||||||
@@ -135,14 +136,27 @@ fun MapBottomSheetScaffold(
|
|||||||
snackbarHost = snackbarHost,
|
snackbarHost = snackbarHost,
|
||||||
topBar = topBar,
|
topBar = topBar,
|
||||||
) {
|
) {
|
||||||
val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding)
|
|
||||||
val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions)
|
|
||||||
Box {
|
Box {
|
||||||
when (customMapTilerConfig) {
|
when (customMapTilerConfig) {
|
||||||
is AsyncData.Success -> {
|
is AsyncData.Success -> {
|
||||||
MaplibreMap(
|
MaplibreMap(
|
||||||
options = mapOptions,
|
options = mapOptions,
|
||||||
|
cameraPadding = sheetPadding,
|
||||||
|
contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()),
|
||||||
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)),
|
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(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
cameraState = cameraState,
|
cameraState = cameraState,
|
||||||
content = mapContent,
|
content = mapContent,
|
||||||
|
|||||||
+24
-12
@@ -7,14 +7,16 @@
|
|||||||
|
|
||||||
package io.element.android.features.location.impl.common.userlocation
|
package io.element.android.features.location.impl.common.userlocation
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import org.maplibre.compose.location.DesiredAccuracy
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import org.maplibre.compose.location.rememberNullLocationProvider
|
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 org.maplibre.spatialk.units.extensions.meters
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -22,17 +24,27 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
class DefaultUserLocationStateFactory : UserLocationState.Factory {
|
class DefaultUserLocationStateFactory : UserLocationState.Factory {
|
||||||
@Composable
|
@Composable
|
||||||
override fun create(hasLocationPermission: Boolean): UserLocationState {
|
override fun create(hasLocationPermission: Boolean): UserLocationState {
|
||||||
val locationProvider = if (!hasLocationPermission) {
|
val locationProvider = if (hasLocationPermission) {
|
||||||
rememberNullLocationProvider()
|
rememberLocationProvider()
|
||||||
} else {
|
} else {
|
||||||
@SuppressLint("MissingPermission")
|
rememberNotGrantedLocationProvider()
|
||||||
rememberPlatformLocationProvider(
|
|
||||||
updateInterval = 5.seconds,
|
|
||||||
desiredAccuracy = DesiredAccuracy.High,
|
|
||||||
minDistance = 5.meters,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
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)
|
return UserLocationState(location)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.userlocation
|
||||||
|
|
||||||
|
import android.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.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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 io.element.android.libraries.core.data.tryOrNull
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import org.maplibre.compose.location.AndroidLocationBackend
|
||||||
|
import org.maplibre.compose.location.LocationAccuracy
|
||||||
|
import org.maplibre.compose.location.LocationEvent
|
||||||
|
import org.maplibre.compose.location.LocationProvider
|
||||||
|
import org.maplibre.compose.location.LocationRequest
|
||||||
|
import org.maplibre.compose.location.LocationUnavailableReason
|
||||||
|
import org.maplibre.compose.location.asMapLibreLocation
|
||||||
|
import org.maplibre.spatialk.units.extensions.inMeters
|
||||||
|
import java.util.ServiceLoader
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the location provider to use.
|
||||||
|
*
|
||||||
|
* Mostly mirrors `createDefaultLocationProvider` from maplibre compose: any [AndroidLocationBackend]
|
||||||
|
* registered through [ServiceLoader] takes precedence, highest [priority]
|
||||||
|
* first. When no backend is available it falls back to [PlatformLocationProvider].
|
||||||
|
*/
|
||||||
|
fun createLocationProvider(context: Context): LocationProvider {
|
||||||
|
val backend = tryOrNull {
|
||||||
|
ServiceLoader.load(AndroidLocationBackend::class.java)
|
||||||
|
.filter { it.isAvailable(context) }
|
||||||
|
.minWithOrNull(compareByDescending<AndroidLocationBackend> { it.priority }.thenBy { it.id })
|
||||||
|
}
|
||||||
|
return backend?.createLocationProvider(context) ?: PlatformLocationProvider(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberLocationProvider(context: Context = LocalContext.current): LocationProvider {
|
||||||
|
return remember(context) { createLocationProvider(context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlatformLocationProvider(
|
||||||
|
private val context: Context,
|
||||||
|
) : LocationProvider {
|
||||||
|
@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 listener = object : LocationListenerCompat {
|
||||||
|
override fun onLocationChanged(location: Location) {
|
||||||
|
trySend(LocationEvent.Fix(location.asMapLibreLocation()))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
tryOrNull { context.unregisterReceiver(settingsReceiver) }
|
||||||
|
LocationManagerCompat.removeUpdates(locationManager, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private val PROVIDERS_BY_PRIORITY = listOf(
|
||||||
|
LocationManager.FUSED_PROVIDER,
|
||||||
|
LocationManager.GPS_PROVIDER,
|
||||||
|
LocationManager.NETWORK_PROVIDER,
|
||||||
|
)
|
||||||
|
private val handlerThread by lazy {
|
||||||
|
HandlerThread("PlatformLocationProvider").apply { start() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotGrantedLocationProvider : LocationProvider {
|
||||||
|
override fun updates(request: LocationRequest): Flow<LocationEvent> {
|
||||||
|
return flowOf(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
fun rememberNotGrantedLocationProvider(): NotGrantedLocationProvider {
|
||||||
|
return remember { NotGrantedLocationProvider() }
|
||||||
|
}
|
||||||
-129
@@ -1,129 +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.userlocation
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
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.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.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.LocationProvider
|
|
||||||
import org.maplibre.compose.location.PermissionException
|
|
||||||
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),
|
|
||||||
) : 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()
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
private val PROVIDERS_BY_PRIORITY = listOf(
|
|
||||||
LocationManager.FUSED_PROVIDER,
|
|
||||||
LocationManager.GPS_PROVIDER,
|
|
||||||
LocationManager.NETWORK_PROVIDER,
|
|
||||||
)
|
|
||||||
private val handlerThread by lazy { HandlerThread("PlatformLocationProvider") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
@@ -8,8 +8,10 @@
|
|||||||
package io.element.android.features.location.impl.common.userlocation
|
package io.element.android.features.location.impl.common.userlocation
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import org.maplibre.compose.location.Location
|
import org.maplibre.compose.location.Location
|
||||||
|
|
||||||
|
@Immutable
|
||||||
data class UserLocationState(val location: Location?) {
|
data class UserLocationState(val location: Location?) {
|
||||||
fun interface Factory {
|
fun interface Factory {
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+33
-27
@@ -14,7 +14,7 @@ import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
|||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.location.impl.common.userlocation.PlatformLocationProvider
|
import io.element.android.features.location.impl.common.userlocation.createLocationProvider
|
||||||
import io.element.android.features.location.impl.di.LocationBindings
|
import io.element.android.features.location.impl.di.LocationBindings
|
||||||
import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator
|
import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
@@ -30,15 +30,14 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.maplibre.compose.location.DesiredAccuracy
|
import org.maplibre.compose.location.LocationAccuracy
|
||||||
import org.maplibre.compose.location.PermissionException
|
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.inMeters
|
||||||
import org.maplibre.spatialk.units.extensions.meters
|
import org.maplibre.spatialk.units.extensions.meters
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -91,31 +90,38 @@ class LiveLocationSharingService : Service() {
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun startLocationUpdatesListener() {
|
private fun startLocationUpdatesListener() {
|
||||||
Timber.d("LiveLocationSharingService listening to location updates")
|
Timber.d("LiveLocationSharingService listening to location updates")
|
||||||
|
val locationProvider = createLocationProvider(context = applicationContext)
|
||||||
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
|
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
|
||||||
.flatMapLatest { minDistanceMeters ->
|
.flatMapLatest { minDistanceMeters ->
|
||||||
try {
|
val locationRequest = LocationRequest(
|
||||||
PlatformLocationProvider(
|
accuracy = LocationAccuracy.Balanced,
|
||||||
context = applicationContext,
|
minimumInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
|
||||||
updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
|
minimumDistance = minDistanceMeters.meters
|
||||||
minDistance = minDistanceMeters.meters,
|
)
|
||||||
desiredAccuracy = DesiredAccuracy.Balanced,
|
locationProvider.updates(locationRequest)
|
||||||
coroutineScope = coroutineScope
|
}
|
||||||
).location
|
.onEach { locationEvent ->
|
||||||
} catch (exception: PermissionException) {
|
when (locationEvent) {
|
||||||
Timber.e(exception, "Failed to create PlatformLocationProvider")
|
is LocationEvent.Fix -> {
|
||||||
coordinator.dispatchUnrecoverableError()
|
val location = ApiLocation(
|
||||||
emptyFlow()
|
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)
|
.launchIn(coroutineScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -107,6 +107,7 @@ fun ShareLocationView(
|
|||||||
val scaffoldState = rememberBottomSheetScaffoldState(
|
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||||
bottomSheetState = rememberBottomSheetState(
|
bottomSheetState = rememberBottomSheetState(
|
||||||
initialValue = SheetValue.Expanded,
|
initialValue = SheetValue.Expanded,
|
||||||
|
enabledValues = setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
|
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
|
||||||
|
|||||||
+1
-1
@@ -94,6 +94,7 @@ fun ShowLocationView(
|
|||||||
val scaffoldState = rememberBottomSheetScaffoldState(
|
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||||
bottomSheetState = rememberBottomSheetState(
|
bottomSheetState = rememberBottomSheetState(
|
||||||
initialValue = SheetValue.Expanded,
|
initialValue = SheetValue.Expanded,
|
||||||
|
enabledValues = setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
LaunchedEffect(state.isSheetDraggable) {
|
LaunchedEffect(state.isSheetDraggable) {
|
||||||
@@ -154,7 +155,6 @@ fun ShowLocationView(
|
|||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
|
||||||
val position = CameraPosition(
|
val position = CameraPosition(
|
||||||
padding = sheetPaddings,
|
|
||||||
target = Position(locationShare.location.lon, locationShare.location.lat),
|
target = Position(locationShare.location.lon, locationShare.location.lat),
|
||||||
// Force pointing to NORTH
|
// Force pointing to NORTH
|
||||||
bearing = 0.0,
|
bearing = 0.0,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ sqldelight = "2.3.2"
|
|||||||
wysiwyg = "2.42.1"
|
wysiwyg = "2.42.1"
|
||||||
telephoto = "0.19.0"
|
telephoto = "0.19.0"
|
||||||
haze = "1.7.2"
|
haze = "1.7.2"
|
||||||
|
maplibre-compose = "0.15.0"
|
||||||
|
|
||||||
# Dependency analysis
|
# Dependency analysis
|
||||||
dependencyAnalysis = "3.19.1"
|
dependencyAnalysis = "3.19.1"
|
||||||
@@ -215,10 +216,9 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
|||||||
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" }
|
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" }
|
||||||
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
|
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
|
||||||
statemachine = "com.freeletics.flowredux:compose:1.2.2"
|
statemachine = "com.freeletics.flowredux:compose:1.2.2"
|
||||||
maplibre = "org.maplibre.gl:android-sdk:13.5.0"
|
maplibre_compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre-compose" }
|
||||||
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
|
maplibre_compose_runtime_vulkan = { module = "org.maplibre.compose:maplibre-compose-runtime-vulkan-android", version.ref = "maplibre-compose" }
|
||||||
maplibre_compose = "org.maplibre.compose:maplibre-compose:0.14.0"
|
maplibre_compose_location_runtime_gms = { module = "org.maplibre.compose:location-runtime-gms", version.ref = "maplibre-compose" }
|
||||||
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
|
|
||||||
opusencoder = "io.element.android:opusencoder:1.2.0"
|
opusencoder = "io.element.android:opusencoder:1.2.0"
|
||||||
zxing_cpp = "io.github.zxing-cpp:android:3.1.1"
|
zxing_cpp = "io.github.zxing-cpp:android:3.1.1"
|
||||||
google_zxing = "com.google.zxing:core:3.5.4"
|
google_zxing = "com.google.zxing:core:3.5.4"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ uses-permission: name='android.permission.USE_FULL_SCREEN_INTENT'
|
|||||||
uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
|
uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
|
||||||
uses-permission: name='android.permission.VIBRATE'
|
uses-permission: name='android.permission.VIBRATE'
|
||||||
uses-permission: name='android.permission.ACCESS_LOCAL_NETWORK'
|
uses-permission: name='android.permission.ACCESS_LOCAL_NETWORK'
|
||||||
uses-permission: name='android.permission.ACCESS_WIFI_STATE'
|
|
||||||
uses-permission: name='android.permission.USE_BIOMETRIC'
|
uses-permission: name='android.permission.USE_BIOMETRIC'
|
||||||
uses-permission: name='android.permission.USE_FINGERPRINT'
|
uses-permission: name='android.permission.USE_FINGERPRINT'
|
||||||
uses-permission: name='com.google.android.c2dm.permission.RECEIVE'
|
uses-permission: name='com.google.android.c2dm.permission.RECEIVE'
|
||||||
@@ -81,8 +80,7 @@ uses-library-not-required:'androidx.window.sidecar'
|
|||||||
feature-group: label=''
|
feature-group: label=''
|
||||||
uses-feature-not-required: name='android.hardware.camera'
|
uses-feature-not-required: name='android.hardware.camera'
|
||||||
uses-feature-not-required: name='android.hardware.microphone'
|
uses-feature-not-required: name='android.hardware.microphone'
|
||||||
uses-feature-not-required: name='android.hardware.vulkan.version' version='4194307'
|
uses-feature-not-required: name='android.hardware.vulkan.version'
|
||||||
uses-feature-not-required: name='android.hardware.wifi'
|
|
||||||
uses-feature: name='android.hardware.faketouch'
|
uses-feature: name='android.hardware.faketouch'
|
||||||
uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'
|
uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'
|
||||||
uses-feature: name='android.hardware.location'
|
uses-feature: name='android.hardware.location'
|
||||||
|
|||||||
Reference in New Issue
Block a user