diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f85d8dc33c..a297ea7702 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -288,6 +288,10 @@ dependencies { 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.androidx.splash) implementation(libs.androidx.core) @@ -325,6 +329,7 @@ licensee { allow("BSD-3-Clause") allow("EPL-1.0") allowUrl("https://opensource.org/license/bsd-3-clause") + allowUrl("https://opensource.org/license/bsd-2-clause") allowUrl("https://opensource.org/licenses/MIT") allowUrl("https://developer.android.com/studio/terms.html") allowUrl("https://www.zetetic.net/sqlcipher/license/") diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 43e4f58cab..1fcb2e1fc4 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(projects.features.enterprise.api) implementation(projects.features.messages.api) implementation(libs.maplibre.compose) + runtimeOnly(libs.maplibre.compose.runtime.vulkan) implementation(libs.coil) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt index 1093e5760a..60e53b4f2b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt @@ -9,11 +9,9 @@ package io.element.android.features.location.impl.common import android.Manifest -import androidx.compose.ui.Alignment import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions -import org.maplibre.compose.map.OrnamentOptions import org.maplibre.compose.map.RenderOptions import org.maplibre.spatialk.geojson.Position @@ -24,14 +22,6 @@ object MapDefaults { val options = MapOptions( renderOptions = RenderOptions.Standard, gestureOptions = GestureOptions.Standard, - ornamentOptions = OrnamentOptions( - isLogoEnabled = true, - logoAlignment = Alignment.BottomStart, - isAttributionEnabled = true, - attributionAlignment = Alignment.BottomEnd, - isCompassEnabled = false, - isScaleBarEnabled = false, - ) ) val defaultCameraPosition = CameraPosition( diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index 84c4ab4fba..983e9fc029 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -7,16 +7,19 @@ package io.element.android.features.location.impl.common.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only @@ -32,7 +35,6 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -55,6 +57,9 @@ import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.overlay.ExpandingAttributionButton +import org.maplibre.compose.overlay.MapOverlay +import org.maplibre.compose.overlay.MaplibreLogo import org.maplibre.compose.style.BaseStyle import org.maplibre.compose.util.MaplibreComposable import kotlin.math.roundToInt @@ -115,10 +120,6 @@ fun MapBottomSheetScaffold( PaddingValues(bottom = bottomPadding) } } - // Update camera position when sheet padding changes - LaunchedEffect(sheetPadding) { - cameraState.position = cameraState.position.copy(padding = sheetPadding) - } BottomSheetScaffold( modifier = Modifier, sheetPeekHeight = sheetPeekHeight, @@ -135,14 +136,27 @@ fun MapBottomSheetScaffold( snackbarHost = snackbarHost, topBar = topBar, ) { - val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding) - val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions) Box { when (customMapTilerConfig) { is AsyncData.Success -> { MaplibreMap( options = mapOptions, + cameraPadding = sheetPadding, + contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()), baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)), + overlay = MapOverlay { + val style = styleState + Row( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MaplibreLogo() + ExpandingAttributionButton(cameraState = cameraState, styleState = style) + } + }, modifier = Modifier.fillMaxSize(), cameraState = cameraState, content = mapContent, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt index 01a2b92d4a..083cd9dd45 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt @@ -7,14 +7,16 @@ package io.element.android.features.location.impl.common.userlocation -import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.di.RoomScope -import org.maplibre.compose.location.DesiredAccuracy -import org.maplibre.compose.location.rememberNullLocationProvider +import kotlinx.coroutines.flow.mapNotNull +import org.maplibre.compose.location.LocationAccuracy +import org.maplibre.compose.location.LocationEvent +import org.maplibre.compose.location.LocationRequest import org.maplibre.spatialk.units.extensions.meters import kotlin.time.Duration.Companion.seconds @@ -22,17 +24,27 @@ import kotlin.time.Duration.Companion.seconds class DefaultUserLocationStateFactory : UserLocationState.Factory { @Composable override fun create(hasLocationPermission: Boolean): UserLocationState { - val locationProvider = if (!hasLocationPermission) { - rememberNullLocationProvider() + val locationProvider = if (hasLocationPermission) { + rememberLocationProvider() } else { - @SuppressLint("MissingPermission") - rememberPlatformLocationProvider( - updateInterval = 5.seconds, - desiredAccuracy = DesiredAccuracy.High, - minDistance = 5.meters, - ) + rememberNotGrantedLocationProvider() } - val location by locationProvider.location.collectAsState() + val locationFlow = remember(locationProvider) { + val locationRequest = LocationRequest( + accuracy = LocationAccuracy.High, + minimumInterval = 5.seconds, + minimumDistance = 5.meters, + ) + locationProvider + .updates(locationRequest) + .mapNotNull { event -> + when (event) { + is LocationEvent.Fix -> event.location + is LocationEvent.Unavailable -> null + } + } + } + val location by locationFlow.collectAsState(null) return UserLocationState(location) } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt new file mode 100644 index 0000000000..0251f03238 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt @@ -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 { 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 = 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 { + return flowOf(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied)) + } +} +@Composable +fun rememberNotGrantedLocationProvider(): NotGrantedLocationProvider { + return remember { NotGrantedLocationProvider() } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.kt deleted file mode 100644 index 54e6d19cf6..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.kt +++ /dev/null @@ -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 - - 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/userlocation/UserLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt index d9dc2037a6..250ad536b7 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt @@ -8,8 +8,10 @@ package io.element.android.features.location.impl.common.userlocation import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import org.maplibre.compose.location.Location +@Immutable data class UserLocationState(val location: Location?) { fun interface Factory { @Composable diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt index 21a0a081d8..d8868021dc 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,7 +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.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.live.notification.LiveLocationSharingNotificationCreator import io.element.android.libraries.architecture.bindings @@ -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 = createLocationProvider(context = applicationContext) appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow() .flatMapLatest { minDistanceMeters -> - try { - PlatformLocationProvider( - context = applicationContext, - updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds, - minDistance = minDistanceMeters.meters, - desiredAccuracy = DesiredAccuracy.Balanced, - coroutineScope = coroutineScope - ).location - } catch (exception: PermissionException) { - Timber.e(exception, "Failed to create PlatformLocationProvider") - coordinator.dispatchUnrecoverableError() - emptyFlow() + val locationRequest = LocationRequest( + accuracy = LocationAccuracy.Balanced, + minimumInterval = UPDATE_INTERVAL_IN_SECOND.seconds, + minimumDistance = minDistanceMeters.meters + ) + locationProvider.updates(locationRequest) + } + .onEach { locationEvent -> + when (locationEvent) { + is LocationEvent.Fix -> { + val location = ApiLocation( + lat = locationEvent.location.position.value.latitude, + lon = locationEvent.location.position.value.longitude, + accuracy = locationEvent.location.position.accuracy?.inMeters?.toFloat(), + ) + coordinator.dispatch(location) + } + is LocationEvent.Unavailable -> { + when (locationEvent.reason) { + LocationUnavailableReason.ServicesDisabled, + LocationUnavailableReason.TemporarilyUnavailable -> Unit + LocationUnavailableReason.Unsupported, + LocationUnavailableReason.Misconfigured, + LocationUnavailableReason.PermissionDenied, + LocationUnavailableReason.UnexpectedFailure -> coordinator.dispatchUnrecoverableError() + } + } } } - .filterNotNull() - .map { location -> - ApiLocation( - lat = location.position.value.latitude, - lon = location.position.value.longitude, - accuracy = location.position.accuracy?.inMeters?.toFloat(), - ) - } - .onEach(coordinator::dispatch) .launchIn(coroutineScope) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 36827cc919..4151b5fce3 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -107,6 +107,7 @@ fun ShareLocationView( val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.Expanded, + enabledValues = setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded), ) ) val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 26eb1ee27a..1e4200c7d7 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -94,6 +94,7 @@ fun ShowLocationView( val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.Expanded, + enabledValues = setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded), ) ) LaunchedEffect(state.isSheetDraggable) { @@ -154,7 +155,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, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb8fdb2f51..d98f00b3a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ sqldelight = "2.3.2" wysiwyg = "2.42.1" telephoto = "0.19.0" haze = "1.7.2" +maplibre-compose = "0.15.0" # Dependency analysis 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_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.2.2" -maplibre = "org.maplibre.gl:android-sdk:13.5.0" -maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" -maplibre_compose = "org.maplibre.compose:maplibre-compose:0.14.0" -maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" +maplibre_compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre-compose" } +maplibre_compose_runtime_vulkan = { module = "org.maplibre.compose:maplibre-compose-runtime-vulkan-android", version.ref = "maplibre-compose" } +maplibre_compose_location_runtime_gms = { module = "org.maplibre.compose:location-runtime-gms", version.ref = "maplibre-compose" } opusencoder = "io.element.android:opusencoder:1.2.0" zxing_cpp = "io.github.zxing-cpp:android:3.1.1" google_zxing = "com.google.zxing:core:3.5.4" diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index 033229b0cb..ec400cbd4c 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -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.VIBRATE' 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_FINGERPRINT' uses-permission: name='com.google.android.c2dm.permission.RECEIVE' @@ -81,8 +80,7 @@ uses-library-not-required:'androidx.window.sidecar' feature-group: label='' uses-feature-not-required: name='android.hardware.camera' 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.wifi' + uses-feature-not-required: name='android.hardware.vulkan.version' uses-feature: name='android.hardware.faketouch' uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps' uses-feature: name='android.hardware.location'