Browse Source

Static images improvements (#933)

1. On devices less than xhdpi request a 1x image from MapTiler (such devices are generally old, slower and with little memory so avoiding to get the 2x image only to have to shrink it later could help).
2. Coerce too big width/height combos within the API limits keeping the aspect ratio (this will allow requests on big horizontal displays to succeed).
3. Don't crash when given weird width/height combos (i.e. zero or negative).
4. Introduce interfaces to hide this whole logic and make it easier for forks to implement their own.

Related to:
- https://github.com/vector-im/element-meta/issues/1678
pull/945/head
Marco Romano 1 year ago committed by GitHub
parent
commit
57d04e487c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 15
      docs/maps.md
  2. 17
      features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
  3. 30
      features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerConfig.kt
  4. 93
      features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt
  5. 39
      features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
  6. 74
      features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt
  7. 36
      features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt
  8. 50
      features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
  9. 191
      features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt
  10. 43
      features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
  11. 1
      features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt

15
docs/maps.md

@ -27,16 +27,21 @@ Place your API key in `local.properties` with the key @@ -27,16 +27,21 @@ Place your API key in `local.properties` with the key
services.maptiler.apikey=abCd3fGhijK1mN0pQr5t
```
Optionally you can also place your custom MapTyler style ids for light and dark maps
in the `local.properties` with the keys `services.maptiler.lightMapId` and
`services.maptiler.darkMapId`. If you don't specify these, the default MapTiler "basic-v2"
styles will be used.
## Making releasable builds with MapTiler
To insert the MapTiler API key when building an APK, set the
`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build
environment.
If you've added custom styles also set the `ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID`
and `ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID` environment variables accordingly.
## Using other map sources or MapTiler styles
If you wish to use an alternative map provider, or custom MapTiler styles,
you can customise the functions in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`.
We've kept this file small and self contained to minimise the chances of merge
collisions in forks.
If you wish to use an alternative map provider, you can provide your own implementations of
`TileServerStyleUriBuilder` and `StaticMapUrlBuilder` in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/`.

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

@ -29,16 +29,16 @@ import androidx.compose.ui.Modifier @@ -29,16 +29,16 @@ 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.platform.LocalDensity
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.staticMapUrl
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber
@ -65,23 +65,22 @@ fun StaticMapView( @@ -65,23 +65,22 @@ fun StaticMapView(
) {
val context = LocalContext.current
var retryHash by remember { mutableStateOf(0) }
val builder = remember { StaticMapUrlBuilder(context) }
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)
ImageRequest.Builder(context)
.data(
staticMapUrl(
context = context,
builder.build(
lat = lat,
lon = lon,
zoom = zoom,
darkMode = darkMode,
// Size the map based on DP rather than pixels, as otherwise the features and attribution
// end up being illegibly tiny on high density displays.
width = constraints.maxWidth.toDp().value.toInt(),
height = constraints.maxHeight.toDp().value.toInt(),
width = constraints.maxWidth,
height = constraints.maxHeight,
density = LocalDensity.current.density,
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)

30
features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerConfig.kt

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
/*
* 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 android.content.Context
import io.element.android.features.location.api.R
internal const val MAPTILER_BASE_URL = "https://api.maptiler.com/maps"
internal fun Context.mapId(darkMode: Boolean) = when (darkMode) {
true -> getString(R.string.maptiler_dark_map_id)
false -> getString(R.string.maptiler_light_map_id)
}
internal val Context.apiKey: String
get() = getString(R.string.maptiler_api_key)

93
features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
/*
* 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 android.content.Context
import kotlin.math.roundToInt
/**
* Builds an URL for MapTiler's Static Maps API.
*
* https://docs.maptiler.com/cloud/api/static-maps/
*/
internal class MapTilerStaticMapUrlBuilder(
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : StaticMapUrlBuilder {
constructor(context: Context) : this(
apiKey = context.apiKey,
lightMapId = context.mapId(darkMode = false),
darkMapId = context.mapId(darkMode = true),
)
override fun build(
lat: Double,
lon: Double,
zoom: Double,
darkMode: Boolean,
width: Int,
height: Int,
density: Float
): String {
val mapId = if (darkMode) darkMapId else lightMapId
val zoom = zoom.coerceIn(zoomRange)
// Request @2x density for xhdpi and above (xhdpi == 320dpi == 2x density).
val is2x = density >= 2
// Scale requested width/height according to the reported display density.
val (width, height) = coerceWidthAndHeight(
width = (width / density).roundToInt(),
height = (height / density).roundToInt(),
is2x = is2x,
)
val scale = if (is2x) "@2x" else ""
// Since Maptiler doesn't support arbitrary dpi scaling, we stick to 2x sized
// images even on displays with density higher than 2x, thereby yielding an
// image smaller than the available space in pixels.
// The resulting image will have to be scaled to fit the available space in order
// to keep the perceived content size constant at the expense of sharpness.
return "$MAPTILER_BASE_URL/${mapId}/static/${lon},${lat},${zoom}/${width}x${height}${scale}.webp?key=${apiKey}&attribution=bottomleft"
}
}
private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair<Int, Int> {
if (width <= 0 || height <= 0) {
// This effectively yields an URL which asks for a 0x0 image which will result in an HTTP error,
// but it's better than e.g. asking for a 1x1 image which would be unreadable and increase usage costs.
return 0 to 0
}
val aspectRatio = width.toDouble() / height.toDouble()
val range = if (is2x) widthHeightRange2x else widthHeightRange
return if (width >= height) {
width.coerceIn(range).let { width ->
width to (width / aspectRatio).roundToInt()
}
} else {
height.coerceIn(range).let { height ->
(height * aspectRatio).roundToInt() to height
}
}
}
private val widthHeightRange = 1..2048 // API will error if outside 1-2048 range @1x.
private val widthHeightRange2x = 1..1024 // API will error if outside 1-1024 range @2x.
private val zoomRange = 0.0..22.0 // API will error if outside 0-22 range.

39
features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
/*
* 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.
*/
@file:JvmName("TileServerStyleUriBuilderKt")
package io.element.android.features.location.api.internal
import android.content.Context
internal class MapTilerTileServerStyleUriBuilder(
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : TileServerStyleUriBuilder {
constructor(context: Context) : this(
apiKey = context.apiKey,
lightMapId = context.mapId(darkMode = false),
darkMapId = context.mapId(darkMode = true),
)
override fun build(darkMode: Boolean): String {
val mapId = if (darkMode) darkMapId else lightMapId
return "${MAPTILER_BASE_URL}/${mapId}/style.json?key=${apiKey}"
}
}

74
features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt

@ -1,74 +0,0 @@ @@ -1,74 +0,0 @@
/*
* 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 android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.element.android.features.location.api.R
import io.element.android.libraries.theme.ElementTheme
/**
* Provides the URL to an image that contains a statically-generated map of the given location.
*/
fun staticMapUrl(
context: Context,
lat: Double,
lon: Double,
zoom: Double,
width: Int,
height: Int,
darkMode: Boolean,
): String {
return "${context.baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft"
}
/**
* Utility function to remember the tile server URL based on the current theme.
*/
@Composable
fun rememberTileStyleUrl(): String {
val context = LocalContext.current
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) {
tileStyleUrl(
context = context,
darkMode = darkMode
)
}
}
/**
* Provides the URL to a MapLibre style document, used for rendering dynamic maps.
*/
private fun tileStyleUrl(
context: Context,
darkMode: Boolean,
): String {
return "${context.baseUrl(darkMode)}/style.json?key=${context.apiKey}"
}
private fun Context.baseUrl(darkMode: Boolean) =
"https://api.maptiler.com/maps/" +
if (darkMode)
getString(R.string.maptiler_dark_map_id)
else
getString(R.string.maptiler_light_map_id)
private val Context.apiKey: String
get() = getString(R.string.maptiler_api_key)

36
features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
/*
* 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 android.content.Context
/**
* Builds an URL for a 3rd party service provider static maps API.
*/
interface StaticMapUrlBuilder {
fun build(
lat: Double,
lon: Double,
zoom: Double,
darkMode: Boolean,
width: Int,
height: Int,
density: Float,
): String
}
fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context)

50
features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
/*
* 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 android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.element.android.libraries.theme.ElementTheme
/**
* Builds a style URI for a MapLibre compatible tile server.
*
* Used for rendering dynamic maps.
*/
interface TileServerStyleUriBuilder {
fun build(
darkMode: Boolean,
): String
}
fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder(context = context)
/**
* Provides and remembers a style URI for a MapLibre compatible tile server.
*
* Used for rendering dynamic maps.
*/
@Composable
fun rememberTileStyleUrl(): String {
val context = LocalContext.current
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) {
TileServerStyleUriBuilder(context).build(darkMode)
}
}

191
features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt

@ -0,0 +1,191 @@ @@ -0,0 +1,191 @@
/*
* 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 org.junit.Test
class MapTilerStaticMapUrlBuilderTest {
private val builder = MapTilerStaticMapUrlBuilder(
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
)
@Test
fun `static map 1x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 800,
height = 600,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `static map 1,5x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 1200,
height = 900,
density = 1.5f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `static map 2x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 1600,
height = 1200,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `static map 3x density`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 2400,
height = 1800,
density = 3f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `too big image is coerced keeping aspect ratio`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 4096,
height = 2048,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 2048,
height = 4096,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 4096,
height = 2048,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 2048,
height = 4096,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = Int.MAX_VALUE,
height = Int.MAX_VALUE,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
}
@Test
fun `too small image is coerced to 0x0`() {
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 0,
height = 0,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = 0,
height = 0,
density = 2f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat(
builder.build(
lat = 1.23,
lon = -4.56,
zoom = 7.8,
darkMode = false,
width = Int.MIN_VALUE,
height = Int.MIN_VALUE,
density = 1f,
)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
}
}

43
features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
/*
* 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 org.junit.Test
class MapTilerTileServerStyleUriBuilderTest {
private val builder = MapTilerTileServerStyleUriBuilder(
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
)
@Test
fun `light map uri`() {
assertThat(
builder.build(darkMode = false)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/style.json?key=anApiKey")
}
@Test
fun `dark map uri`() {
assertThat(
builder.build(darkMode = true)
).isEqualTo("https://api.maptiler.com/maps/aDarkMapId/style.json?key=anApiKey")
}
}

1
features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt

@ -40,7 +40,6 @@ import com.mapbox.mapboxsdk.camera.CameraPosition @@ -40,7 +40,6 @@ import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.MapDefaults
import io.element.android.features.location.impl.send.SendLocationState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight

Loading…
Cancel
Save