From 1713555a67984fb64c939f714d8f5cd687381320 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 2 Jun 2026 10:29:23 +0200 Subject: [PATCH 1/3] Introduce PlatformLocationProvider to replace the maplibre one. --- .../impl/common/PlatformLocationProvider.kt | 130 ++++++++++++++++++ .../location/impl/common/UserLocationState.kt | 3 +- .../service/LiveLocationSharingService.kt | 4 +- 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PlatformLocationProvider.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PlatformLocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PlatformLocationProvider.kt new file mode 100644 index 0000000000..dbc4ac9040 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PlatformLocationProvider.kt @@ -0,0 +1,130 @@ +/* + * 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 + + 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, + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/UserLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/UserLocationState.kt index 632ddef38d..01533a421d 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/UserLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/UserLocationState.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalInspectionMode import org.maplibre.compose.location.DesiredAccuracy import org.maplibre.compose.location.Location -import org.maplibre.compose.location.rememberAndroidLocationProvider import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.spatialk.units.extensions.meters import kotlin.time.Duration.Companion.seconds @@ -32,7 +31,7 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState val locationProvider = if (isPreview || !hasLocationPermission) { rememberNullLocationProvider() } else { - rememberAndroidLocationProvider( + rememberPlatformLocationProvider( updateInterval = 5.seconds, desiredAccuracy = DesiredAccuracy.High, minDistance = 5.meters, 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 a926f1255b..b5b8342b96 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 @@ -14,6 +14,7 @@ import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION import android.os.IBinder import androidx.core.app.ServiceCompat 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.live.notification.LiveLocationSharingNotificationCreator import io.element.android.libraries.architecture.bindings @@ -34,7 +35,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import org.maplibre.compose.location.AndroidLocationProvider import org.maplibre.compose.location.DesiredAccuracy import org.maplibre.spatialk.units.extensions.inMeters import org.maplibre.spatialk.units.extensions.meters @@ -90,7 +90,7 @@ class LiveLocationSharingService : Service() { Timber.d("LiveLocationSharingService listening to location updates") appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow() .flatMapLatest { minDistanceMeters -> - val locationProvider = AndroidLocationProvider( + val locationProvider = PlatformLocationProvider( context = applicationContext, updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds, minDistance = minDistanceMeters.meters, From 1a40e460aea4557659470cf97a3eb55cf953c952 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 2 Jun 2026 12:23:14 +0200 Subject: [PATCH 2/3] Handle unrecoverable location errors and stop sharing --- .../impl/common/PlatformLocationProvider.kt | 1 - .../DefaultActiveLiveLocationShareManager.kt | 5 ++++ .../impl/live/service/LiveLocationReceiver.kt | 3 +- .../service/LiveLocationSharingCoordinator.kt | 11 ++++++++ .../service/LiveLocationSharingService.kt | 28 ++++++++++++------- 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PlatformLocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PlatformLocationProvider.kt index dbc4ac9040..7988c73e68 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PlatformLocationProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PlatformLocationProvider.kt @@ -58,7 +58,6 @@ class PlatformLocationProvider( ) { throw PermissionException() } - val locationManager = context.getSystemService(LocationManager::class.java) val provider = PROVIDERS_BY_PRIORITY.firstOrNull { LocationManagerCompat.hasProvider(locationManager, it) } val locationFlow = if (provider != null) { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt index fd16bea515..c24d3f9cc2 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt @@ -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) { val activeSharesCount = localSharingRoomIds.value.size Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)") diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt index adba75730c..90311e7d4a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.impl.live.service import io.element.android.features.location.api.Location -fun interface LiveLocationReceiver { +interface LiveLocationReceiver { suspend fun onLocationUpdate(location: Location) + suspend fun onUnrecoverableError() {} } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt index e39acb14e8..63618baa3c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt @@ -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) { val currentTimeMillis = nowMillis() val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load() 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 b5b8342b96..d682ba0df6 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,12 +30,15 @@ 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.spatialk.units.extensions.inMeters import org.maplibre.spatialk.units.extensions.meters import timber.log.Timber @@ -48,7 +51,6 @@ class LiveLocationSharingService : Service() { @Inject lateinit var coordinator: LiveLocationSharingCoordinator @Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator @Inject lateinit var appPreferencesStore: AppPreferencesStore - @Inject lateinit var appForegroundStateService: AppForegroundStateService @AppCoroutineScope @@ -62,8 +64,8 @@ class LiveLocationSharingService : Service() { override fun onCreate() { super.onCreate() Timber.d("LiveLocationSharingService onCreate") + bindings().inject(this) runCatchingExceptions { - bindings().inject(this) appForegroundStateService.updateIsSharingLiveLocation(true) coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService") val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION) @@ -81,6 +83,7 @@ class LiveLocationSharingService : Service() { startLocationUpdatesListener() }.onFailure { Timber.e(it, "Failed to start live location sharing service") + appCoroutineScope.launch { coordinator.dispatchUnrecoverableError() } stopSelf() } } @@ -90,14 +93,19 @@ class LiveLocationSharingService : Service() { Timber.d("LiveLocationSharingService listening to location updates") appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow() .flatMapLatest { minDistanceMeters -> - val locationProvider = PlatformLocationProvider( - context = applicationContext, - updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds, - minDistance = minDistanceMeters.meters, - desiredAccuracy = DesiredAccuracy.Balanced, - coroutineScope = coroutineScope - ) - locationProvider.location + 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() + } } .filterNotNull() .map { location -> From a9fe0a356071f3e493805fb8119dc2ff7f6fd96e Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 2 Jun 2026 13:14:03 +0200 Subject: [PATCH 3/3] Fix LiveLocationReceiver interface and update tests --- .../impl/live/service/LiveLocationReceiver.kt | 2 +- .../LiveLocationSharingCoordinatorTest.kt | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt index 90311e7d4a..697a074093 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt @@ -11,5 +11,5 @@ import io.element.android.features.location.api.Location interface LiveLocationReceiver { suspend fun onLocationUpdate(location: Location) - suspend fun onUnrecoverableError() {} + suspend fun onUnrecoverableError() } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt index f74322b4c5..6cee53a482 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt @@ -27,7 +27,7 @@ class LiveLocationSharingCoordinatorTest { nowMillis = { 0L }, ) - coordinator.register(A_SESSION_ID, LiveLocationReceiver { }) + coordinator.register(A_SESSION_ID, liveLocationReceiver()) coordinator.unregister(A_SESSION_ID) assertThat(startCount).isEqualTo(1) @@ -43,8 +43,8 @@ class LiveLocationSharingCoordinatorTest { nowMillis = { 4_000L }, ) - coordinator.register(A_SESSION_ID) { error("boom") } - coordinator.register(A_SESSION_ID_2) { location -> delivered += location } + coordinator.register(A_SESSION_ID, liveLocationReceiver { error("boom") }) + coordinator.register(A_SESSION_ID_2, liveLocationReceiver { delivered += it }) coordinator.dispatch(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 }, ) - 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) @@ -79,7 +79,7 @@ class LiveLocationSharingCoordinatorTest { 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 secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f) @@ -101,7 +101,7 @@ class LiveLocationSharingCoordinatorTest { 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 secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f) @@ -113,3 +113,10 @@ class LiveLocationSharingCoordinatorTest { 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 +}