diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index 36dbfdb139..811c0d89b7 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -26,7 +26,7 @@ android { defaultConfig { buildConfigFieldStr( name = "MAPTILER_BASE_URL", - value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps" + value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL, ) buildConfigFieldStr( name = "MAPTILER_API_KEY", @@ -35,8 +35,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") ?: readLocalProperty("services.maptiler.apikey") - } - ?: "" + }, ) buildConfigFieldStr( name = "MAPTILER_LIGHT_MAP_ID", @@ -45,9 +44,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID") ?: readLocalProperty("services.maptiler.lightMapId") - } - // fall back to maptiler's default light map. - ?: "basic-v2" + }, ) buildConfigFieldStr( name = "MAPTILER_DARK_MAP_ID", @@ -56,9 +53,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID") ?: readLocalProperty("services.maptiler.darkMapId") - } - // fall back to maptiler's default dark map. - ?: "basic-v2-dark" + }, ) } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt index 00f9d5190c..bcaa5ac49c 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt @@ -19,14 +19,13 @@ val LocalMapTilerConfig = staticCompositionLocalOf { buildTimeMapTilerConfig() } * The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided. */ fun buildTimeMapTilerConfig(): MapTilerConfig? { - return BuildConfig.MAPTILER_API_KEY - .takeIf { it.isNotEmpty() } - ?.let { apiKey -> - MapTilerConfig( - apiKey = apiKey, - lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, - baseUrl = BuildConfig.MAPTILER_BASE_URL - ) - } + // BuildConfig fields are null when not provided at build time. + val apiKey: String? = BuildConfig.MAPTILER_API_KEY + if (apiKey.isNullOrEmpty()) return null + return MapTilerConfig( + apiKey = apiKey, + lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, + darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, + baseUrl = BuildConfig.MAPTILER_BASE_URL, + ) } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index f6a70f6653..2e8e8b5a60 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -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, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt new file mode 100644 index 0000000000..509278b3dd --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt @@ -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 +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt index cd339f67b0..c19d305f3d 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -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( diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt index d817a3307f..fc7ddc3fe0 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt @@ -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}" } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt index 31992b1dd6..553a4ed160 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt @@ -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) } } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt index 67936525e5..6195f8fad3 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt @@ -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() } } diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt index 3d17107104..c46b7a41d5 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt @@ -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( diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt index c153ab1ce4..12c3a7ec63 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt @@ -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=") } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt index 00c7fc89b5..8a81895e1f 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt @@ -11,7 +11,6 @@ package io.element.android.features.location.impl import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig -import io.element.android.features.location.api.BuildConfig import io.element.android.features.location.api.buildTimeMapTilerConfig import io.element.android.libraries.matrix.test.AN_EXCEPTION import kotlinx.coroutines.CoroutineScope @@ -30,7 +29,7 @@ class DefaultLocationServiceTest { ) runCurrent() assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) - assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) } @Test @@ -40,7 +39,7 @@ class DefaultLocationServiceTest { ) runCurrent() assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) - assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) } @Test diff --git a/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt b/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt index ee43b88154..6ae99db5e5 100644 --- a/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt +++ b/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt @@ -10,14 +10,17 @@ package extension import com.android.build.api.dsl.VariantDimension +/** + * Adds a String field to the generated BuildConfig class. If [value] is null, the field will be null. + */ fun VariantDimension.buildConfigFieldStr( name: String, - value: String, + value: String?, ) { buildConfigField( type = "String", name = name, - value = "\"$value\"" + value = value?.let { "\"$it\"" } ?: "null" ) }