Merge pull request #6935 from element-hq/feature/fga/location_provider

Change : location provider
This commit is contained in:
ganfra
2026-06-02 17:15:56 +02:00
committed by GitHub
7 changed files with 180 additions and 20 deletions
@@ -0,0 +1,129 @@
/*
* 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
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,
)
}
}
@@ -16,7 +16,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import org.maplibre.compose.location.DesiredAccuracy import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.Location import org.maplibre.compose.location.Location
import org.maplibre.compose.location.rememberAndroidLocationProvider
import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberNullLocationProvider
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
@@ -32,7 +31,7 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState
val locationProvider = if (isPreview || !hasLocationPermission) { val locationProvider = if (isPreview || !hasLocationPermission) {
rememberNullLocationProvider() rememberNullLocationProvider()
} else { } else {
rememberAndroidLocationProvider( rememberPlatformLocationProvider(
updateInterval = 5.seconds, updateInterval = 5.seconds,
desiredAccuracy = DesiredAccuracy.High, desiredAccuracy = DesiredAccuracy.High,
minDistance = 5.meters, minDistance = 5.meters,
@@ -133,6 +133,11 @@ class DefaultActiveLiveLocationShareManager(
} }
} }
override suspend fun onUnrecoverableError() {
Timber.d("ActiveLiveLocationShareManager unrecoverable error, stopping all shares")
localSharingRoomIds.value.toList().forEach { stopShare(it) }
}
override suspend fun onLocationUpdate(location: Location) { override suspend fun onLocationUpdate(location: Location) {
val activeSharesCount = localSharingRoomIds.value.size val activeSharesCount = localSharingRoomIds.value.size
Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)") Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)")
@@ -9,6 +9,7 @@ package io.element.android.features.location.impl.live.service
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
fun interface LiveLocationReceiver { interface LiveLocationReceiver {
suspend fun onLocationUpdate(location: Location) suspend fun onLocationUpdate(location: Location)
suspend fun onUnrecoverableError()
} }
@@ -77,6 +77,17 @@ class LiveLocationSharingCoordinator internal constructor(
} }
} }
suspend fun dispatchUnrecoverableError() {
Timber.d("LiveLocationSharingCoordinator dispatching unrecoverable error")
receivers.forEach { (sessionId, receiver) ->
runCatchingExceptions {
receiver.onUnrecoverableError()
}.onFailure {
Timber.e(it, "Failed to dispatch unrecoverable error for session $sessionId")
}
}
}
suspend fun dispatch(location: Location) { suspend fun dispatch(location: Location) {
val currentTimeMillis = nowMillis() val currentTimeMillis = nowMillis()
val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load() val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load()
@@ -14,6 +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.PlatformLocationProvider
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
@@ -29,13 +30,15 @@ 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.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.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.maplibre.compose.location.AndroidLocationProvider import kotlinx.coroutines.launch
import org.maplibre.compose.location.DesiredAccuracy import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.PermissionException
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
@@ -48,7 +51,6 @@ class LiveLocationSharingService : Service() {
@Inject lateinit var coordinator: LiveLocationSharingCoordinator @Inject lateinit var coordinator: LiveLocationSharingCoordinator
@Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator @Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator
@Inject lateinit var appPreferencesStore: AppPreferencesStore @Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var appForegroundStateService: AppForegroundStateService @Inject lateinit var appForegroundStateService: AppForegroundStateService
@AppCoroutineScope @AppCoroutineScope
@@ -62,8 +64,8 @@ class LiveLocationSharingService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.d("LiveLocationSharingService onCreate") Timber.d("LiveLocationSharingService onCreate")
bindings<LocationBindings>().inject(this)
runCatchingExceptions { runCatchingExceptions {
bindings<LocationBindings>().inject(this)
appForegroundStateService.updateIsSharingLiveLocation(true) appForegroundStateService.updateIsSharingLiveLocation(true)
coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService") coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService")
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION) val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION)
@@ -81,6 +83,7 @@ class LiveLocationSharingService : Service() {
startLocationUpdatesListener() startLocationUpdatesListener()
}.onFailure { }.onFailure {
Timber.e(it, "Failed to start live location sharing service") Timber.e(it, "Failed to start live location sharing service")
appCoroutineScope.launch { coordinator.dispatchUnrecoverableError() }
stopSelf() stopSelf()
} }
} }
@@ -90,14 +93,19 @@ class LiveLocationSharingService : Service() {
Timber.d("LiveLocationSharingService listening to location updates") Timber.d("LiveLocationSharingService listening to location updates")
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow() appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
.flatMapLatest { minDistanceMeters -> .flatMapLatest { minDistanceMeters ->
val locationProvider = AndroidLocationProvider( try {
context = applicationContext, PlatformLocationProvider(
updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds, context = applicationContext,
minDistance = minDistanceMeters.meters, updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
desiredAccuracy = DesiredAccuracy.Balanced, minDistance = minDistanceMeters.meters,
coroutineScope = coroutineScope desiredAccuracy = DesiredAccuracy.Balanced,
) coroutineScope = coroutineScope
locationProvider.location ).location
} catch (exception: PermissionException) {
Timber.e(exception, "Failed to create PlatformLocationProvider")
coordinator.dispatchUnrecoverableError()
emptyFlow()
}
} }
.filterNotNull() .filterNotNull()
.map { location -> .map { location ->
@@ -27,7 +27,7 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { 0L }, nowMillis = { 0L },
) )
coordinator.register(A_SESSION_ID, LiveLocationReceiver { }) coordinator.register(A_SESSION_ID, liveLocationReceiver())
coordinator.unregister(A_SESSION_ID) coordinator.unregister(A_SESSION_ID)
assertThat(startCount).isEqualTo(1) assertThat(startCount).isEqualTo(1)
@@ -43,8 +43,8 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { 4_000L }, nowMillis = { 4_000L },
) )
coordinator.register(A_SESSION_ID) { error("boom") } coordinator.register(A_SESSION_ID, liveLocationReceiver { error("boom") })
coordinator.register(A_SESSION_ID_2) { location -> delivered += location } coordinator.register(A_SESSION_ID_2, liveLocationReceiver { delivered += it })
coordinator.dispatch(Location(lat = 1.0, lon = 2.0, accuracy = 3f)) coordinator.dispatch(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
assertThat(delivered).containsExactly(Location(lat = 1.0, lon = 2.0, accuracy = 3f)) assertThat(delivered).containsExactly(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
@@ -60,7 +60,7 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { nowMillis }, nowMillis = { nowMillis },
) )
coordinator.register(A_SESSION_ID) { location -> delivered += location } coordinator.register(A_SESSION_ID, liveLocationReceiver { delivered += it })
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f) val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
@@ -79,7 +79,7 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { nowMillis }, nowMillis = { nowMillis },
) )
coordinator.register(A_SESSION_ID) { location -> delivered += location } coordinator.register(A_SESSION_ID, liveLocationReceiver { delivered += it })
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f) val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f) val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
@@ -101,7 +101,7 @@ class LiveLocationSharingCoordinatorTest {
nowMillis = { nowMillis }, nowMillis = { nowMillis },
) )
coordinator.register(A_SESSION_ID) { location -> delivered += location } coordinator.register(A_SESSION_ID, liveLocationReceiver { delivered += it })
val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f) val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f) val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
@@ -113,3 +113,10 @@ class LiveLocationSharingCoordinatorTest {
assertThat(delivered).containsExactly(firstLocation, secondLocation).inOrder() assertThat(delivered).containsExactly(firstLocation, secondLocation).inOrder()
} }
} }
private fun liveLocationReceiver(
onLocation: suspend (Location) -> Unit = {},
) = object : LiveLocationReceiver {
override suspend fun onLocationUpdate(location: Location) = onLocation(location)
override suspend fun onUnrecoverableError() = Unit
}