Do not mix the homeserver MapTiler config with the build time one

The map URL builders now only use the MapTilerConfig they are given, falling
back to the public MapTiler defaults for the missing values, instead of the
values defined at build time. This avoids for instance sending the build time
API key to a tile server provided by the homeserver.

When there is no config, no static map URL builder is created, so no request
is performed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 10:11:07 +02:00
parent 80cfc2be8d
commit 0149e6f65b
8 changed files with 118 additions and 87 deletions
@@ -152,8 +152,8 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
Triple(painter, state, ContentScale.Crop)
} else {
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero
model = if (builder == null || constraints.isZero) {
// Avoid building a URL if there is no map provider or if any of the size constraints is zero
null
} else {
ImageRequest.Builder(context)
@@ -195,7 +195,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
else -> {
StaticMapPlaceholder(
painter = painterResource(R.drawable.blurred_map),
canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
canReload = builder?.isServiceAvailable() == true && state is AsyncImagePainter.State.Error,
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Public MapTiler defaults, used when a [MapTilerConfig] does not define a value.
* Values from another configuration (e.g. the build time one) must never be used as a fallback,
* to avoid sending an API key to a server it was not meant for.
*/
internal object MapTilerDefaults {
const val BASE_URL = "https://api.maptiler.com/maps"
const val LIGHT_MAP_ID = "basic-v2"
const val DARK_MAP_ID = "basic-v2-dark"
}
internal fun MapTilerConfig.baseUrlOrDefault(): String {
return baseUrl?.takeIf { it.isNotBlank() }?.removeSuffix("/") ?: MapTilerDefaults.BASE_URL
}
internal fun MapTilerConfig.lightMapIdOrDefault(): String {
return lightStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.LIGHT_MAP_ID
}
internal fun MapTilerConfig.darkMapIdOrDefault(): String {
return darkStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.DARK_MAP_ID
}
@@ -9,7 +9,6 @@
package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
import kotlin.math.roundToInt
/**
@@ -23,18 +22,11 @@ internal class MapTilerStaticMapUrlBuilder(
private val lightMapId: String,
private val darkMapId: String,
) : StaticMapUrlBuilder {
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
constructor(mapTilerConfig: MapTilerConfig) : this(
baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"),
apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY,
lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID,
baseUrl = mapTilerConfig.baseUrlOrDefault(),
apiKey = mapTilerConfig.apiKey,
lightMapId = mapTilerConfig.lightMapIdOrDefault(),
darkMapId = mapTilerConfig.darkMapIdOrDefault(),
)
override fun build(
@@ -11,33 +11,18 @@
package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
internal class MapTilerTileServerStyleUriBuilder(
private val baseUrl: String,
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : TileServerStyleUriBuilder {
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
internal class MapTilerTileServerStyleUriBuilder : TileServerStyleUriBuilder {
override fun build(
customMapTilerConfig: MapTilerConfig?,
mapTilerConfig: MapTilerConfig,
darkMode: Boolean,
): String {
val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl
val apiKey = customMapTilerConfig?.apiKey ?: apiKey
val baseUrl = mapTilerConfig.baseUrlOrDefault()
val mapId = if (darkMode) {
customMapTilerConfig?.darkStyleId ?: darkMapId
mapTilerConfig.darkMapIdOrDefault()
} else {
customMapTilerConfig?.lightStyleId ?: lightMapId
mapTilerConfig.lightMapIdOrDefault()
}
return "$baseUrl/$mapId/style.json?key=$apiKey"
return "$baseUrl/$mapId/style.json?key=${mapTilerConfig.apiKey}"
}
}
@@ -36,17 +36,13 @@ interface StaticMapUrlBuilder {
density: Float,
): String
/** Whether a map provider is configured for this build; the URLs are unusable when it is not. */
/** Whether an API key is configured; the URLs are unusable when it is not. */
fun isServiceAvailable(): Boolean
}
@Composable
fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder {
fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder? {
return remember(mapTilerConfig) {
if (mapTilerConfig != null) {
MapTilerStaticMapUrlBuilder(mapTilerConfig)
} else {
MapTilerStaticMapUrlBuilder()
}
mapTilerConfig?.let { MapTilerStaticMapUrlBuilder(it) }
}
}
@@ -22,11 +22,11 @@ interface TileServerStyleUriBuilder {
/**
* Builds the style URI the map view loads its tiles from.
*
* @param customMapTilerConfig the custom MapTiler configuration received from the server, or null if not available.
* @param mapTilerConfig the MapTiler configuration to use.
* @param darkMode whether to request the dark map style.
*/
fun build(
customMapTilerConfig: MapTilerConfig?,
mapTilerConfig: MapTilerConfig,
darkMode: Boolean,
): String
}
@@ -38,13 +38,16 @@ interface TileServerStyleUriBuilder {
*/
@Composable
fun rememberTileStyleUrl(
customMapTileConfig: MapTilerConfig?,
mapTilerConfig: MapTilerConfig?,
): String {
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode, customMapTileConfig) {
MapTilerTileServerStyleUriBuilder().build(
customMapTilerConfig = customMapTileConfig,
darkMode = darkMode,
)
return remember(darkMode, mapTilerConfig) {
// Without config, the map is not displayed, so the style URL is not used.
mapTilerConfig?.let {
MapTilerTileServerStyleUriBuilder().build(
mapTilerConfig = it,
darkMode = darkMode,
)
}.orEmpty()
}
}
@@ -9,6 +9,7 @@
package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import org.junit.Test
class MapTilerStaticMapUrlBuilderTest {
@@ -24,6 +25,38 @@ class MapTilerStaticMapUrlBuilderTest {
assertThat(builder.isServiceAvailable()).isTrue()
}
@Test
fun `builder created from a config only uses the config values`() {
val configBuilder = MapTilerStaticMapUrlBuilder(
MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/maps/",
)
)
assertThat(configBuilder.isServiceAvailable()).isTrue()
assertThat(
configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = true, width = 800, height = 600, density = 1f)
).isEqualTo("https://custom.url/maps/dark/static/-4.56,1.23,7.8/800x600.webp?key=API_KEY&attribution=topright")
}
@Test
fun `builder created from a partial config falls back to the public MapTiler defaults`() {
val configBuilder = MapTilerStaticMapUrlBuilder(
MapTilerConfig(
apiKey = "",
lightStyleId = null,
darkStyleId = null,
baseUrl = null,
)
)
assertThat(configBuilder.isServiceAvailable()).isFalse()
assertThat(
configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = false, width = 800, height = 600, density = 1f)
).isEqualTo("https://api.maptiler.com/maps/basic-v2/static/-4.56,1.23,7.8/800x600.webp?key=&attribution=topright")
}
@Test
fun `isServiceAvailable returns false if api key is empty`() {
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
@@ -13,75 +13,64 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest {
private val builder = MapTilerTileServerStyleUriBuilder(
baseUrl = "https://base.url",
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
private val builder = MapTilerTileServerStyleUriBuilder()
private val config = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/maps/",
)
@Test
fun `light map uri`() {
assertThat(
builder.build(
customMapTilerConfig = null,
mapTilerConfig = config,
darkMode = false,
)
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
).isEqualTo("https://custom.url/maps/light/style.json?key=API_KEY")
}
@Test
fun `dark map uri`() {
assertThat(
builder.build(
customMapTilerConfig = null,
mapTilerConfig = config,
darkMode = true,
)
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
).isEqualTo("https://custom.url/maps/dark/style.json?key=API_KEY")
}
@Test
fun `custom map uri light`() {
fun `missing values fall back to the public MapTiler defaults`() {
val partialConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = null,
darkStyleId = " ",
baseUrl = " ",
)
assertThat(
builder.build(
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
mapTilerConfig = partialConfig,
darkMode = false,
)
).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY")
}
@Test
fun `custom map uri dark`() {
).isEqualTo("https://api.maptiler.com/maps/basic-v2/style.json?key=API_KEY")
assertThat(
builder.build(
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
mapTilerConfig = partialConfig,
darkMode = true,
)
).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY")
).isEqualTo("https://api.maptiler.com/maps/basic-v2-dark/style.json?key=API_KEY")
}
@Test
fun `blank custom map uri falls back to the built-in style`() {
fun `blank api key is kept as is`() {
assertThat(
builder.build(
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = " "
),
mapTilerConfig = config.copy(apiKey = ""),
darkMode = false,
)
).isEqualTo("https://base.url/light/style.json?key=API_KEY")
).isEqualTo("https://custom.url/maps/light/style.json?key=")
}
}