Browse Source

Set up SDK & reusable map view component (#476)

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/1684
feature/julioromano/geocoding_api
Marco Romano 1 year ago committed by GitHub
parent
commit
882f75864c
  1. 1
      build.gradle.kts
  2. 53
      features/location/api/build.gradle.kts
  3. 26
      features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt
  4. 297
      features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt
  5. 135
      features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
  6. 80
      features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt
  7. 111
      features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt
  8. BIN
      features/location/api/src/main/res/drawable/blurred_map_dark.png
  9. BIN
      features/location/api/src/main/res/drawable/blurred_map_light.png
  10. 23
      features/location/api/src/main/res/drawable/pin.xml
  11. 71
      features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt
  12. 52
      features/location/fake/build.gradle.kts
  13. 35
      features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt
  14. 54
      features/location/impl/build.gradle.kts
  15. 21
      features/location/impl/src/main/AndroidManifest.xml
  16. 96
      features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt
  17. 3
      gradle/libs.versions.toml
  18. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_0,NEXUS_5,1.0,en].png
  19. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_1,NEXUS_5,1.0,en].png
  20. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_0,NEXUS_5,1.0,en].png
  21. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_1,NEXUS_5,1.0,en].png
  22. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png
  23. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png
  24. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewDarkPreview_0_null,NEXUS_5,1.0,en].png
  25. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewLightPreview_0_null,NEXUS_5,1.0,en].png

1
build.gradle.kts

@ -247,6 +247,7 @@ koverMerged { @@ -247,6 +247,7 @@ koverMerged {
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
excludes += "io.element.android.features.location.api.MapState"
}
bound {
minValue = 90

53
features/location/api/build.gradle.kts

@ -0,0 +1,53 @@ @@ -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)
}

26
features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt

@ -0,0 +1,26 @@ @@ -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,
)

297
features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt

@ -0,0 +1,297 @@ @@ -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 = {},
)
}

135
features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt

@ -0,0 +1,135 @@ @@ -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),
)
}

80
features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt

@ -0,0 +1,80 @@ @@ -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"
}
}

111
features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt

@ -0,0 +1,111 @@ @@ -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)
}

BIN
features/location/api/src/main/res/drawable/blurred_map_dark.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
features/location/api/src/main/res/drawable/blurred_map_light.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

23
features/location/api/src/main/res/drawable/pin.xml

@ -0,0 +1,23 @@ @@ -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>

71
features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt

@ -0,0 +1,71 @@ @@ -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"
)
}
}

52
features/location/fake/build.gradle.kts

@ -0,0 +1,52 @@ @@ -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)
}

35
features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt

@ -0,0 +1,35 @@ @@ -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
)

54
features/location/impl/build.gradle.kts

@ -0,0 +1,54 @@ @@ -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)
}

21
features/location/impl/src/main/AndroidManifest.xml

@ -0,0 +1,21 @@ @@ -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>

96
features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt

@ -0,0 +1,96 @@ @@ -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()

3
gradle/libs.versions.toml

@ -155,6 +155,8 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" @@ -155,6 +155,8 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.1.0"
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:1.0.0"
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"
@ -186,6 +188,7 @@ android_application = { id = "com.android.application", version.ref = "android_g @@ -186,6 +188,7 @@ android_application = { id = "com.android.application", version.ref = "android_g
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewDarkPreview_0_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewLightPreview_0_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save