Browse Source
Adds `libraries/map` which contains some initial building blocks that will be used by the location sharing feature. Ref: https://github.com/vector-im/element-meta/issues/1684feature/julioromano/geocoding_api
Marco Romano
1 year ago
committed by
GitHub
25 changed files with 1082 additions and 0 deletions
@ -0,0 +1,53 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2022 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
plugins { |
||||||
|
id("io.element.android-compose-library") |
||||||
|
alias(libs.plugins.anvil) |
||||||
|
alias(libs.plugins.kotlin.serialization) |
||||||
|
alias(libs.plugins.ksp) |
||||||
|
} |
||||||
|
|
||||||
|
android { |
||||||
|
namespace = "io.element.android.features.location.api" |
||||||
|
} |
||||||
|
|
||||||
|
anvil { |
||||||
|
generateDaggerFactories.set(true) |
||||||
|
} |
||||||
|
|
||||||
|
dependencies { |
||||||
|
implementation(libs.dagger) |
||||||
|
implementation(projects.libraries.designsystem) |
||||||
|
implementation(projects.libraries.network) |
||||||
|
implementation(projects.libraries.core) |
||||||
|
implementation(projects.libraries.uiStrings) |
||||||
|
implementation(libs.maplibre) |
||||||
|
implementation(libs.network.retrofit) |
||||||
|
implementation(libs.maplibre.annotation) |
||||||
|
implementation(libs.coil.compose) |
||||||
|
implementation(libs.serialization.json) |
||||||
|
implementation(libs.accompanist.permission) |
||||||
|
ksp(libs.showkase.processor) |
||||||
|
|
||||||
|
testImplementation(libs.test.junit) |
||||||
|
testImplementation(libs.coroutines.test) |
||||||
|
testImplementation(libs.molecule.runtime) |
||||||
|
testImplementation(libs.test.truth) |
||||||
|
testImplementation(libs.test.turbine) |
||||||
|
testImplementation(libs.test.truth) |
||||||
|
testImplementation(projects.libraries.matrix.test) |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.location.api |
||||||
|
|
||||||
|
/** |
||||||
|
* Represents a location sample emitted by the device's location subsystem. |
||||||
|
*/ |
||||||
|
data class Location( |
||||||
|
val lat: Double, |
||||||
|
val lon: Double, |
||||||
|
val accuracy: Float, |
||||||
|
) |
@ -0,0 +1,297 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.location.api |
||||||
|
|
||||||
|
import android.annotation.SuppressLint |
||||||
|
import androidx.annotation.DrawableRes |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.material.icons.Icons |
||||||
|
import androidx.compose.material.icons.filled.LocationOn |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.runtime.DisposableEffect |
||||||
|
import androidx.compose.runtime.LaunchedEffect |
||||||
|
import androidx.compose.runtime.Stable |
||||||
|
import androidx.compose.runtime.getValue |
||||||
|
import androidx.compose.runtime.mutableStateOf |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import androidx.compose.runtime.setValue |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.platform.LocalContext |
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode |
||||||
|
import androidx.compose.ui.tooling.preview.Preview |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import androidx.compose.ui.viewinterop.AndroidView |
||||||
|
import com.mapbox.mapboxsdk.Mapbox |
||||||
|
import com.mapbox.mapboxsdk.camera.CameraPosition |
||||||
|
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory |
||||||
|
import com.mapbox.mapboxsdk.geometry.LatLng |
||||||
|
import com.mapbox.mapboxsdk.maps.MapView |
||||||
|
import com.mapbox.mapboxsdk.maps.MapboxMap |
||||||
|
import com.mapbox.mapboxsdk.maps.Style |
||||||
|
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager |
||||||
|
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions |
||||||
|
import io.element.android.features.location.api.internal.buildTileServerUrl |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight |
||||||
|
import io.element.android.libraries.designsystem.theme.ElementTheme |
||||||
|
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon |
||||||
|
import kotlinx.collections.immutable.ImmutableList |
||||||
|
import kotlinx.collections.immutable.toImmutableList |
||||||
|
import timber.log.Timber |
||||||
|
import kotlin.coroutines.resume |
||||||
|
import kotlin.coroutines.suspendCoroutine |
||||||
|
|
||||||
|
/** |
||||||
|
* Composable wrapper around MapLibre's [MapView]. |
||||||
|
*/ |
||||||
|
@SuppressLint("MissingPermission") |
||||||
|
@Composable |
||||||
|
fun MapView( |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
mapState: MapState = rememberMapState(), |
||||||
|
darkMode: Boolean = !ElementTheme.colors.isLight, |
||||||
|
onLocationClick: () -> Unit, |
||||||
|
) { |
||||||
|
// When in preview, early return a Box with the received modifier preserving layout |
||||||
|
if (LocalInspectionMode.current) { |
||||||
|
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return. |
||||||
|
Box(modifier = modifier) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
val context = LocalContext.current |
||||||
|
val mapView = remember { |
||||||
|
Mapbox.getInstance(context) |
||||||
|
MapView(context) |
||||||
|
} |
||||||
|
var mapRefs by remember { mutableStateOf<MapRefs?>(null) } |
||||||
|
|
||||||
|
// Build map |
||||||
|
LaunchedEffect(darkMode) { |
||||||
|
mapView.awaitMap().let { map -> |
||||||
|
map.uiSettings.apply { |
||||||
|
isCompassEnabled = false |
||||||
|
} |
||||||
|
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style -> |
||||||
|
mapRefs = MapRefs( |
||||||
|
map = map, |
||||||
|
symbolManager = SymbolManager(mapView, map, style).apply { |
||||||
|
iconAllowOverlap = true |
||||||
|
}, |
||||||
|
style = style |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Update state position when moving map |
||||||
|
DisposableEffect(mapRefs) { |
||||||
|
var listener: MapboxMap.OnCameraIdleListener? = null |
||||||
|
|
||||||
|
mapRefs?.let { mapRefs -> |
||||||
|
listener = MapboxMap.OnCameraIdleListener { |
||||||
|
mapRefs.map.cameraPosition.target?.let { target -> |
||||||
|
val position = MapState.CameraPosition( |
||||||
|
lat = target.latitude, |
||||||
|
lon = target.longitude, |
||||||
|
zoom = mapRefs.map.cameraPosition.zoom |
||||||
|
) |
||||||
|
mapState.position = position |
||||||
|
Timber.d("Camera moved to: $position") |
||||||
|
} |
||||||
|
}.apply { |
||||||
|
mapRefs.map.addOnCameraIdleListener(this) |
||||||
|
Timber.d("Added OnCameraIdleListener $this") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onDispose { |
||||||
|
mapRefs?.let { mapRefs -> |
||||||
|
listener?.let { |
||||||
|
mapRefs.map.removeOnCameraIdleListener(it).apply { |
||||||
|
Timber.d("Removed OnCameraIdleListener $it") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Move map to given position when state has changed |
||||||
|
LaunchedEffect(mapRefs, mapState.position) { |
||||||
|
mapRefs?.map?.moveCamera( |
||||||
|
CameraUpdateFactory.newCameraPosition( |
||||||
|
CameraPosition.Builder() |
||||||
|
.target(LatLng(mapState.position.lat, mapState.position.lon)) |
||||||
|
.zoom(mapState.position.zoom).build() |
||||||
|
) |
||||||
|
) |
||||||
|
Timber.d("Camera position updated to: ${mapState.position}") |
||||||
|
} |
||||||
|
|
||||||
|
// Draw pin |
||||||
|
LaunchedEffect(mapRefs, mapState.location) { |
||||||
|
mapRefs?.let { mapRefs -> |
||||||
|
mapState.location?.let { location -> |
||||||
|
context.getDrawable(R.drawable.pin)?.let { mapRefs.style.addImage("pin", it) } |
||||||
|
mapRefs.symbolManager.create( |
||||||
|
SymbolOptions() |
||||||
|
.withLatLng(LatLng(location.lat, location.lon)) |
||||||
|
.withIconImage("pin") |
||||||
|
.withIconSize(1.3f) |
||||||
|
) |
||||||
|
Timber.d("Shown pin at location: $location") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
// Draw markers |
||||||
|
LaunchedEffect(mapRefs, mapState.markers) { |
||||||
|
mapRefs?.let { mapRefs -> |
||||||
|
mapState.markers.forEachIndexed { index, marker -> |
||||||
|
context.getDrawable(marker.drawable)?.let { mapRefs.style.addImage("marker_$index", it) } |
||||||
|
mapRefs.symbolManager.create( |
||||||
|
SymbolOptions() |
||||||
|
.withLatLng(LatLng(marker.lat, marker.lon)) |
||||||
|
.withIconImage("marker_$index") |
||||||
|
.withIconSize(1.0f) |
||||||
|
) |
||||||
|
Timber.d("Shown marker at location: $marker") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Suppress("ModifierReused") |
||||||
|
Box(modifier = modifier) { |
||||||
|
AndroidView(factory = { mapView }) |
||||||
|
FloatingActionButton( |
||||||
|
onClick = onLocationClick, |
||||||
|
modifier = Modifier |
||||||
|
.align(Alignment.BottomEnd) |
||||||
|
.padding(16.dp), |
||||||
|
) { |
||||||
|
Icon( |
||||||
|
imageVector = Icons.Filled.LocationOn, |
||||||
|
contentDescription = null, // TODO |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun rememberMapState( |
||||||
|
position: MapState.CameraPosition = MapState.CameraPosition(lat = 0.0, lon = 0.0, zoom = 0.0), |
||||||
|
location: Location? = null, |
||||||
|
markers: ImmutableList<MapState.Marker> = emptyList<MapState.Marker>().toImmutableList(), |
||||||
|
): MapState = remember { |
||||||
|
MapState( |
||||||
|
position = position, |
||||||
|
location = location, |
||||||
|
markers = markers, |
||||||
|
) |
||||||
|
} // TODO(Use remember saveable with Parcelable custom saver) |
||||||
|
|
||||||
|
@Stable |
||||||
|
class MapState( |
||||||
|
position: CameraPosition, // The position of the camera, it's what will be shared |
||||||
|
location: Location? = null, // The location retrieved by the location subsystem, if any. |
||||||
|
markers: ImmutableList<Marker> = emptyList<Marker>().toImmutableList(), // The pin's location, if any. |
||||||
|
) { |
||||||
|
var position: CameraPosition by mutableStateOf(position) |
||||||
|
var location: Location? by mutableStateOf(location) |
||||||
|
var markers: ImmutableList<Marker> by mutableStateOf(markers) |
||||||
|
|
||||||
|
override fun toString(): String { |
||||||
|
return "MapState(position=$position, location=$location, markers=$markers)" |
||||||
|
} |
||||||
|
|
||||||
|
@Stable |
||||||
|
data class CameraPosition( |
||||||
|
val lat: Double, |
||||||
|
val lon: Double, |
||||||
|
val zoom: Double, |
||||||
|
) |
||||||
|
|
||||||
|
@Stable |
||||||
|
data class Marker( |
||||||
|
@DrawableRes val drawable: Int, |
||||||
|
val lat: Double, |
||||||
|
val lon: Double, |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
private class MapRefs( |
||||||
|
val map: MapboxMap, |
||||||
|
val symbolManager: SymbolManager, |
||||||
|
val style: Style |
||||||
|
) |
||||||
|
|
||||||
|
/** |
||||||
|
* A suspending function that provides an instance of [MapboxMap] from this [MapView]. This is |
||||||
|
* an alternative to [MapView.getMapAsync] by using coroutines to obtain the [MapboxMap]. |
||||||
|
* |
||||||
|
* Inspired from [com.google.maps.android.ktx.awaitMap] |
||||||
|
* |
||||||
|
* @return the [MapboxMap] instance |
||||||
|
*/ |
||||||
|
private suspend inline fun MapView.awaitMap(): MapboxMap = |
||||||
|
suspendCoroutine { continuation -> |
||||||
|
getMapAsync { |
||||||
|
continuation.resume(it) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Preview |
||||||
|
@Composable |
||||||
|
fun MapViewLightPreview() = |
||||||
|
ElementPreviewLight { ContentToPreview() } |
||||||
|
|
||||||
|
@Preview |
||||||
|
@Composable |
||||||
|
fun MapViewDarkPreview() = |
||||||
|
ElementPreviewDark { ContentToPreview() } |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun ContentToPreview() { |
||||||
|
MapView( |
||||||
|
modifier = Modifier.size(400.dp), |
||||||
|
mapState = rememberMapState( |
||||||
|
position = MapState.CameraPosition( |
||||||
|
lat = 0.0, |
||||||
|
lon = 0.0, |
||||||
|
zoom = 0.0, |
||||||
|
), |
||||||
|
location = Location( |
||||||
|
lat = 0.0, |
||||||
|
lon = 0.0, |
||||||
|
accuracy = 0.0f, |
||||||
|
), |
||||||
|
markers = listOf( |
||||||
|
MapState.Marker( |
||||||
|
drawable = R.drawable.pin, |
||||||
|
lat = 0.0, |
||||||
|
lon = 0.0, |
||||||
|
) |
||||||
|
).toImmutableList() |
||||||
|
), |
||||||
|
onLocationClick = {}, |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,135 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.location.api |
||||||
|
|
||||||
|
import androidx.compose.foundation.Image |
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.runtime.getValue |
||||||
|
import androidx.compose.runtime.mutableStateOf |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import androidx.compose.runtime.setValue |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.graphics.Color |
||||||
|
import androidx.compose.ui.layout.ContentScale |
||||||
|
import androidx.compose.ui.platform.LocalContext |
||||||
|
import androidx.compose.ui.tooling.preview.Preview |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import coil.compose.AsyncImagePainter |
||||||
|
import coil.compose.rememberAsyncImagePainter |
||||||
|
import coil.request.ImageRequest |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight |
||||||
|
import io.element.android.libraries.designsystem.theme.ElementTheme |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon |
||||||
|
import io.element.android.features.location.api.internal.StaticMapPlaceholder |
||||||
|
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl |
||||||
|
import timber.log.Timber |
||||||
|
|
||||||
|
/** |
||||||
|
* Shows a static map image downloaded via a third party service's static maps API. |
||||||
|
*/ |
||||||
|
@Composable |
||||||
|
fun StaticMapView( |
||||||
|
lat: Double, |
||||||
|
lon: Double, |
||||||
|
zoom: Double, |
||||||
|
contentDescription: String?, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
darkMode: Boolean = !ElementTheme.colors.isLight, |
||||||
|
) { |
||||||
|
// Using BoxWithConstraints to: |
||||||
|
// 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints. |
||||||
|
// 2) Request the static map image of the exact required size in Px to fill the AsyncImage. |
||||||
|
BoxWithConstraints( |
||||||
|
modifier = modifier, |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
var retryHash by remember { mutableStateOf(0) } |
||||||
|
val painter = rememberAsyncImagePainter( |
||||||
|
model = if (constraints.isZero) { |
||||||
|
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). |
||||||
|
null |
||||||
|
} else { |
||||||
|
ImageRequest.Builder(LocalContext.current) |
||||||
|
.data( |
||||||
|
buildStaticMapsApiUrl( |
||||||
|
lat = lat, |
||||||
|
lon = lon, |
||||||
|
desiredZoom = zoom, |
||||||
|
desiredWidth = constraints.maxWidth, |
||||||
|
desiredHeight = constraints.maxHeight, |
||||||
|
darkMode = darkMode, |
||||||
|
) |
||||||
|
) |
||||||
|
.size(width = constraints.maxWidth, height = constraints.maxHeight) |
||||||
|
.setParameter("retry_hash", retryHash, memoryCacheKey = null) |
||||||
|
.build() |
||||||
|
}.apply { |
||||||
|
Timber.d("Static map image request: ${this?.data}") |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
if (painter.state is AsyncImagePainter.State.Success) { |
||||||
|
Image( |
||||||
|
painter = painter, |
||||||
|
contentDescription = contentDescription, |
||||||
|
modifier = Modifier.size(width = maxWidth, height = maxHeight), |
||||||
|
// The returned image can be smaller than the requested size due to the static maps API having |
||||||
|
// a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details. |
||||||
|
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. |
||||||
|
contentScale = ContentScale.Fit, |
||||||
|
) |
||||||
|
Icon( |
||||||
|
resourceId = R.drawable.pin, |
||||||
|
contentDescription = null, |
||||||
|
tint = Color.Unspecified |
||||||
|
) |
||||||
|
} else { |
||||||
|
StaticMapPlaceholder( |
||||||
|
showProgress = painter.state is AsyncImagePainter.State.Loading, |
||||||
|
contentDescription = contentDescription, |
||||||
|
modifier = Modifier.size(width = maxWidth, height = maxHeight), |
||||||
|
darkMode = darkMode, |
||||||
|
onLoadMapClick = { retryHash++ } |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Preview |
||||||
|
@Composable |
||||||
|
fun StaticMapViewLightPreview() = |
||||||
|
ElementPreviewLight { ContentToPreview() } |
||||||
|
|
||||||
|
@Preview |
||||||
|
@Composable |
||||||
|
fun StaticMapViewDarkPreview() = |
||||||
|
ElementPreviewDark { ContentToPreview() } |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun ContentToPreview() { |
||||||
|
StaticMapView( |
||||||
|
lat = 0.0, |
||||||
|
lon = 0.0, |
||||||
|
zoom = 0.0, |
||||||
|
contentDescription = null, |
||||||
|
modifier = Modifier.size(400.dp), |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,80 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.location.api.internal |
||||||
|
|
||||||
|
import kotlin.math.roundToInt |
||||||
|
|
||||||
|
private const val API_KEY = "fU3vlMsMn4Jb6dnEIFsx" |
||||||
|
private const val BASE_URL = "https://api.maptiler.com" |
||||||
|
private const val LIGHT_MAP_ID = "9bc819c8-e627-474a-a348-ec144fe3d810" |
||||||
|
private const val DARK_MAP_ID = "dea61faf-292b-4774-9660-58fcef89a7f3" |
||||||
|
private const val STATIC_MAP_FORMAT = "webp" |
||||||
|
private const val STATIC_MAP_SCALE = "" // Either "" (empty string) for normal image or "@2x" for retina images. |
||||||
|
private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048 |
||||||
|
private const val STATIC_MAP_MAX_ZOOM = 22.0 |
||||||
|
|
||||||
|
internal fun buildTileServerUrl( |
||||||
|
darkMode: Boolean |
||||||
|
): String = if (!darkMode) { |
||||||
|
"$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY" |
||||||
|
} else { |
||||||
|
"$BASE_URL/maps/$DARK_MAP_ID/style.json?key=$API_KEY" |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Builds a valid URL for maptiler.com static map api based on the given params. |
||||||
|
* |
||||||
|
* Coerces width and height to the API maximum of 2048 keeping the requested aspect ratio. |
||||||
|
* Coerces zoom to the API maximum of 22. |
||||||
|
* |
||||||
|
* NB: This will throw if either width or height are <= 0. You need to handle this case upstream |
||||||
|
* (hint: views can't have negative width or height but can have 0 width or height sometimes). |
||||||
|
*/ |
||||||
|
internal fun buildStaticMapsApiUrl( |
||||||
|
lat: Double, |
||||||
|
lon: Double, |
||||||
|
desiredZoom: Double, |
||||||
|
desiredWidth: Int, |
||||||
|
desiredHeight: Int, |
||||||
|
darkMode: Boolean |
||||||
|
): String { |
||||||
|
require(desiredWidth > 0 && desiredHeight > 0) { |
||||||
|
"Width ($desiredHeight) and height ($desiredHeight) must be > 0" |
||||||
|
} |
||||||
|
require(desiredZoom >= 0) { "Zoom ($desiredZoom) must be >= 0" } |
||||||
|
val zoom = desiredZoom.coerceAtMost(STATIC_MAP_MAX_ZOOM) // API will error if outside 0-22 range. |
||||||
|
val width: Int |
||||||
|
val height: Int |
||||||
|
if (desiredWidth <= STATIC_MAP_MAX_WIDTH_HEIGHT && desiredHeight <= STATIC_MAP_MAX_WIDTH_HEIGHT) { |
||||||
|
width = desiredWidth |
||||||
|
height = desiredHeight |
||||||
|
} else { |
||||||
|
val aspectRatio = desiredWidth.toDouble() / desiredHeight.toDouble() |
||||||
|
if (desiredWidth >= desiredHeight) { |
||||||
|
width = desiredWidth.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT) |
||||||
|
height = (width / aspectRatio).roundToInt() |
||||||
|
} else { |
||||||
|
height = desiredHeight.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT) |
||||||
|
width = (height * aspectRatio).roundToInt() |
||||||
|
} |
||||||
|
} |
||||||
|
return if (!darkMode) { |
||||||
|
"$BASE_URL/maps/$LIGHT_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY" |
||||||
|
} else { |
||||||
|
"$BASE_URL/maps/$DARK_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,111 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.location.api.internal |
||||||
|
|
||||||
|
import androidx.compose.foundation.Image |
||||||
|
import androidx.compose.foundation.clickable |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.material.icons.Icons |
||||||
|
import androidx.compose.material.icons.filled.Refresh |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.layout.ContentScale |
||||||
|
import androidx.compose.ui.res.painterResource |
||||||
|
import androidx.compose.ui.res.stringResource |
||||||
|
import androidx.compose.ui.tooling.preview.Preview |
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark |
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight |
||||||
|
import io.element.android.libraries.designsystem.theme.ElementTheme |
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon |
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text |
||||||
|
import io.element.android.features.location.api.R |
||||||
|
import io.element.android.libraries.ui.strings.R as StringsR |
||||||
|
|
||||||
|
@Composable |
||||||
|
internal fun StaticMapPlaceholder( |
||||||
|
showProgress: Boolean, |
||||||
|
contentDescription: String?, |
||||||
|
modifier: Modifier = Modifier, |
||||||
|
darkMode: Boolean = !ElementTheme.colors.isLight, |
||||||
|
onLoadMapClick: () -> Unit, |
||||||
|
) { |
||||||
|
Box( |
||||||
|
contentAlignment = Alignment.Center, |
||||||
|
) { |
||||||
|
Image( |
||||||
|
painter = painterResource( |
||||||
|
id = if (darkMode) R.drawable.blurred_map_dark |
||||||
|
else R.drawable.blurred_map_light |
||||||
|
), |
||||||
|
contentDescription = contentDescription, |
||||||
|
modifier = modifier, |
||||||
|
contentScale = ContentScale.FillBounds, |
||||||
|
) |
||||||
|
if (showProgress) { |
||||||
|
CircularProgressIndicator() |
||||||
|
} else { |
||||||
|
Box( |
||||||
|
modifier = modifier.clickable(onClick = onLoadMapClick), |
||||||
|
contentAlignment = Alignment.Center, |
||||||
|
) { |
||||||
|
Column( |
||||||
|
horizontalAlignment = Alignment.CenterHorizontally, |
||||||
|
) { |
||||||
|
Icon( |
||||||
|
imageVector = Icons.Default.Refresh, |
||||||
|
contentDescription = null |
||||||
|
) |
||||||
|
Text(text = stringResource(id = StringsR.string.action_static_map_load)) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Preview |
||||||
|
@Composable |
||||||
|
fun StaticMapPlaceholderLightPreview( |
||||||
|
@PreviewParameter(BooleanParameterProvider::class) values: Boolean |
||||||
|
) = ElementPreviewLight { ContentToPreview(values) } |
||||||
|
|
||||||
|
@Preview |
||||||
|
@Composable |
||||||
|
fun StaticMapPlaceholderDarkPreview( |
||||||
|
@PreviewParameter(BooleanParameterProvider::class) values: Boolean |
||||||
|
) = ElementPreviewDark { ContentToPreview(values) } |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun ContentToPreview(showProgress: Boolean) { |
||||||
|
StaticMapPlaceholder( |
||||||
|
showProgress = showProgress, |
||||||
|
contentDescription = null, |
||||||
|
modifier = Modifier.size(400.dp), |
||||||
|
onLoadMapClick = {}, |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
internal class BooleanParameterProvider : PreviewParameterProvider<Boolean> { |
||||||
|
override val values: Sequence<Boolean> |
||||||
|
get() = sequenceOf(true, false) |
||||||
|
} |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 48 KiB |
@ -0,0 +1,23 @@ |
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||||
|
android:width="50dp" |
||||||
|
android:height="108dp" |
||||||
|
android:viewportWidth="50" |
||||||
|
android:viewportHeight="108"> |
||||||
|
<group> |
||||||
|
<clip-path |
||||||
|
android:pathData="M0,0h50v108h-50z"/> |
||||||
|
<path |
||||||
|
android:pathData="M25,54L18.94,48L31.06,48L25,54Z" |
||||||
|
android:fillColor="#1B1D22"/> |
||||||
|
<path |
||||||
|
android:pathData="M25,25m-25,0a25,25 0,1 1,50 0a25,25 0,1 1,-50 0" |
||||||
|
android:fillColor="#1B1D22"/> |
||||||
|
<group> |
||||||
|
<clip-path |
||||||
|
android:pathData="M13,13h24v24h-24z"/> |
||||||
|
<path |
||||||
|
android:pathData="M25,13C20.36,13 16.6,16.86 16.6,21.63C16.6,26.77 21.9,33.86 24.09,36.56C24.57,37.15 25.44,37.15 25.92,36.56C28.1,33.86 33.4,26.77 33.4,21.63C33.4,16.86 29.64,13 25,13ZM25,24.71C23.34,24.71 22,23.33 22,21.63C22,19.93 23.34,18.55 25,18.55C26.66,18.55 28,19.93 28,21.63C28,23.33 26.66,24.71 25,24.71Z" |
||||||
|
android:fillColor="#ffffff"/> |
||||||
|
</group> |
||||||
|
</group> |
||||||
|
</vector> |
@ -0,0 +1,71 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.location.api.internal |
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat |
||||||
|
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl |
||||||
|
import org.junit.Test |
||||||
|
|
||||||
|
class BuildStaticMapsApiUrlTest { |
||||||
|
@Test |
||||||
|
fun `buildStaticMapsApiUrl builds light mode url`() { |
||||||
|
assertThat( |
||||||
|
buildStaticMapsApiUrl( |
||||||
|
lat = 1.234, |
||||||
|
lon = 5.678, |
||||||
|
desiredZoom = 1.2, |
||||||
|
desiredWidth = 100, |
||||||
|
desiredHeight = 200, |
||||||
|
darkMode = false |
||||||
|
) |
||||||
|
).isEqualTo( |
||||||
|
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx" |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `buildStaticMapsApiUrl builds dark mode url`() { |
||||||
|
assertThat( |
||||||
|
buildStaticMapsApiUrl( |
||||||
|
lat = 1.234, |
||||||
|
lon = 5.678, |
||||||
|
desiredZoom = 1.2, |
||||||
|
desiredWidth = 100, |
||||||
|
desiredHeight = 200, |
||||||
|
darkMode = true |
||||||
|
) |
||||||
|
).isEqualTo( |
||||||
|
"https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx" |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `buildStaticMapsApiUrl coerces zoom at 22 and width and height at max 2048 keeping aspect ratio`() { |
||||||
|
assertThat( |
||||||
|
buildStaticMapsApiUrl( |
||||||
|
lat = 1.234, |
||||||
|
lon = 5.678, |
||||||
|
desiredZoom = 100.0, |
||||||
|
desiredWidth = 8192, |
||||||
|
desiredHeight = 4096, |
||||||
|
darkMode = false |
||||||
|
) |
||||||
|
).isEqualTo( |
||||||
|
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp?key=fU3vlMsMn4Jb6dnEIFsx" |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2022 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
plugins { |
||||||
|
id("io.element.android-compose-library") |
||||||
|
alias(libs.plugins.anvil) |
||||||
|
alias(libs.plugins.kotlin.serialization) |
||||||
|
} |
||||||
|
|
||||||
|
android { |
||||||
|
namespace = "io.element.android.features.location.fake" |
||||||
|
} |
||||||
|
|
||||||
|
anvil { |
||||||
|
generateDaggerFactories.set(true) |
||||||
|
} |
||||||
|
|
||||||
|
dependencies { |
||||||
|
implementation(libs.dagger) |
||||||
|
api(projects.features.location.api) |
||||||
|
implementation(projects.libraries.designsystem) |
||||||
|
implementation(projects.libraries.di) |
||||||
|
implementation(projects.libraries.network) |
||||||
|
implementation(projects.libraries.core) |
||||||
|
implementation(libs.maplibre) |
||||||
|
implementation(libs.network.retrofit) |
||||||
|
implementation(libs.maplibre.annotation) |
||||||
|
implementation(libs.coil.compose) |
||||||
|
implementation(libs.serialization.json) |
||||||
|
implementation(libs.accompanist.permission) |
||||||
|
|
||||||
|
testImplementation(libs.test.junit) |
||||||
|
testImplementation(libs.coroutines.test) |
||||||
|
testImplementation(libs.molecule.runtime) |
||||||
|
testImplementation(libs.test.truth) |
||||||
|
testImplementation(libs.test.turbine) |
||||||
|
testImplementation(libs.test.truth) |
||||||
|
testImplementation(projects.libraries.matrix.test) |
||||||
|
} |
@ -0,0 +1,35 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.location.fake |
||||||
|
|
||||||
|
import io.element.android.features.location.api.Location |
||||||
|
import kotlinx.coroutines.delay |
||||||
|
import kotlinx.coroutines.flow.Flow |
||||||
|
import kotlinx.coroutines.flow.flow |
||||||
|
|
||||||
|
fun fakeLocationUpdatesFlow(): Flow<Location> = flow { |
||||||
|
while (true) { |
||||||
|
delay(1_000) |
||||||
|
emit(aLocation()) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun aLocation() = Location( |
||||||
|
lat = 51.49404, |
||||||
|
lon = -0.25484, |
||||||
|
accuracy = 5f |
||||||
|
) |
@ -0,0 +1,54 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2022 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
plugins { |
||||||
|
id("io.element.android-compose-library") |
||||||
|
alias(libs.plugins.anvil) |
||||||
|
alias(libs.plugins.kotlin.serialization) |
||||||
|
alias(libs.plugins.ksp) |
||||||
|
} |
||||||
|
|
||||||
|
android { |
||||||
|
namespace = "io.element.android.features.location.impl" |
||||||
|
} |
||||||
|
|
||||||
|
anvil { |
||||||
|
generateDaggerFactories.set(true) |
||||||
|
} |
||||||
|
|
||||||
|
dependencies { |
||||||
|
implementation(libs.dagger) |
||||||
|
api(projects.features.location.api) |
||||||
|
implementation(projects.libraries.di) |
||||||
|
implementation(projects.libraries.designsystem) |
||||||
|
implementation(projects.libraries.network) |
||||||
|
implementation(projects.libraries.core) |
||||||
|
implementation(libs.maplibre) |
||||||
|
implementation(libs.network.retrofit) |
||||||
|
implementation(libs.maplibre.annotation) |
||||||
|
implementation(libs.coil.compose) |
||||||
|
implementation(libs.serialization.json) |
||||||
|
implementation(libs.accompanist.permission) |
||||||
|
ksp(libs.showkase.processor) |
||||||
|
|
||||||
|
testImplementation(libs.test.junit) |
||||||
|
testImplementation(libs.coroutines.test) |
||||||
|
testImplementation(libs.molecule.runtime) |
||||||
|
testImplementation(libs.test.truth) |
||||||
|
testImplementation(libs.test.turbine) |
||||||
|
testImplementation(libs.test.truth) |
||||||
|
testImplementation(projects.libraries.matrix.test) |
||||||
|
} |
@ -0,0 +1,21 @@ |
|||||||
|
<!-- |
||||||
|
~ Copyright (c) 2023 New Vector Ltd |
||||||
|
~ |
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
~ you may not use this file except in compliance with the License. |
||||||
|
~ You may obtain a copy of the License at |
||||||
|
~ |
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
~ |
||||||
|
~ Unless required by applicable law or agreed to in writing, software |
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
~ See the License for the specific language governing permissions and |
||||||
|
~ limitations under the License. |
||||||
|
--> |
||||||
|
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> |
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> |
||||||
|
</manifest> |
@ -0,0 +1,96 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.features.location.impl |
||||||
|
|
||||||
|
import android.Manifest |
||||||
|
import android.content.Context |
||||||
|
import android.location.LocationManager |
||||||
|
import androidx.annotation.RequiresPermission |
||||||
|
import androidx.core.content.getSystemService |
||||||
|
import androidx.core.location.LocationListenerCompat |
||||||
|
import androidx.core.location.LocationManagerCompat |
||||||
|
import androidx.core.location.LocationRequestCompat |
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers |
||||||
|
import io.element.android.features.location.api.Location |
||||||
|
import kotlinx.coroutines.asExecutor |
||||||
|
import kotlinx.coroutines.channels.awaitClose |
||||||
|
import kotlinx.coroutines.channels.trySendBlocking |
||||||
|
import kotlinx.coroutines.flow.Flow |
||||||
|
import kotlinx.coroutines.flow.callbackFlow |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns a cold [Flow] that, once collected, emits [Location] updates every second. |
||||||
|
*/ |
||||||
|
@RequiresPermission( |
||||||
|
anyOf = [ |
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION, |
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION |
||||||
|
] |
||||||
|
) |
||||||
|
fun locationUpdatesFlow( |
||||||
|
context: Context, |
||||||
|
coroutineDispatchers: CoroutineDispatchers, |
||||||
|
): Flow<Location> = callbackFlow { |
||||||
|
val locationManager: LocationManager = checkNotNull(context.getSystemService()) |
||||||
|
val provider = locationManager.bestAvailableProvider() |
||||||
|
// Try to eagerly emit the last known location as fast as possible |
||||||
|
locationManager.getLastKnownLocation(provider)?.let { location -> |
||||||
|
trySendBlocking( |
||||||
|
Location( |
||||||
|
lat = location.latitude, |
||||||
|
lon = location.longitude, |
||||||
|
accuracy = location.accuracy |
||||||
|
) |
||||||
|
) |
||||||
|
} |
||||||
|
val locationListener = LocationListenerCompat { location -> |
||||||
|
trySendBlocking( |
||||||
|
Location( |
||||||
|
lat = location.latitude, |
||||||
|
lon = location.longitude, |
||||||
|
accuracy = location.accuracy |
||||||
|
) |
||||||
|
) |
||||||
|
} |
||||||
|
LocationManagerCompat.requestLocationUpdates( |
||||||
|
locationManager, |
||||||
|
provider, |
||||||
|
buildLocationRequest(), |
||||||
|
coroutineDispatchers.io.asExecutor(), |
||||||
|
locationListener, |
||||||
|
) |
||||||
|
awaitClose { |
||||||
|
LocationManagerCompat.removeUpdates(locationManager, locationListener) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun LocationManager.bestAvailableProvider(): String = |
||||||
|
checkNotNull(getProviders(true).maxByOrNull { providerPriority(it) }) { |
||||||
|
"No location provider available" |
||||||
|
} |
||||||
|
|
||||||
|
private fun providerPriority(provider: String): Int = when (provider) { |
||||||
|
LocationManager.FUSED_PROVIDER -> 4 |
||||||
|
LocationManager.GPS_PROVIDER -> 3 |
||||||
|
LocationManager.NETWORK_PROVIDER -> 2 |
||||||
|
LocationManager.PASSIVE_PROVIDER -> 1 |
||||||
|
else -> 0 |
||||||
|
} |
||||||
|
|
||||||
|
private fun buildLocationRequest() = LocationRequestCompat.Builder(1_000).apply { |
||||||
|
setMinUpdateIntervalMillis(1_000) |
||||||
|
}.build() |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue