diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c0bc7f85f..831f2765b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,8 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APK + env: + ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES - name: Upload debug APKs uses: actions/upload-artifact@v3 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7d240080b0..95c2deb8eb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -35,6 +35,7 @@ jobs: run: | ./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES env: + ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }} ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }} ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }} diff --git a/docs/maps.md b/docs/maps.md new file mode 100644 index 0000000000..cc00905986 --- /dev/null +++ b/docs/maps.md @@ -0,0 +1,42 @@ +# Use of maps + + + +* [Overview](#overview) +* [Local development with MapTiler](#local-development-with-maptiler) +* [Making releasable builds with MapTiler](#making-releasable-builds-with-maptiler) +* [Using other map sources or MapTiler styles](#using-other-map-sources-or-maptiler-styles) + + + +## Overview + +Element Android uses [MapTiler](https://www.maptiler.com/) to provide map +imagery where required. MapTiler requires an API key, which we bake in to +the app at release time. + +## Local development with MapTiler + +If you're developing the application and want maps to render properly you can +sign up for the [MapTiler free tier](https://www.maptiler.com/cloud/pricing/). + +Place your API key in `local.properties` with the key +`services.maptiler.apikey`, e.g.: + +```properties +services.maptiler.apikey=abCd3fGhijK1mN0pQr5t +``` + +## 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. + +## 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. diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index 0e517fd3e6..6de297fe77 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -14,14 +14,33 @@ * limitations under the License. */ +import java.util.Properties + plugins { id("io.element.android-compose-library") alias(libs.plugins.ksp) id("kotlin-parcelize") } +fun readLocalProperty(name: String) = Properties().apply { + try { + load(rootProject.file("local.properties").reader()) + } catch (ignored: java.io.IOException) { + } +}[name] + android { namespace = "io.element.android.features.location.api" + + defaultConfig { + resValue( + type = "string", + name = "maptiler_api_key", + value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") + ?: readLocalProperty("services.maptiler.apikey") as? String + ?: "" + ) + } } dependencies { 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 3d09c36604..c8762b3989 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 @@ -34,9 +34,8 @@ 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.AttributionPlacement import io.element.android.features.location.api.internal.StaticMapPlaceholder -import io.element.android.features.location.api.internal.buildStaticMapsApiUrl +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 @@ -64,6 +63,7 @@ fun StaticMapView( modifier = modifier, contentAlignment = Alignment.Center ) { + val context = LocalContext.current var retryHash by remember { mutableStateOf(0) } val painter = rememberAsyncImagePainter( model = if (constraints.isZero) { @@ -72,17 +72,16 @@ fun StaticMapView( } else { ImageRequest.Builder(LocalContext.current) .data( - buildStaticMapsApiUrl( + staticMapUrl( + context = context, lat = lat, lon = lon, - desiredZoom = zoom, + zoom = zoom, darkMode = darkMode, - attributionPlacement = AttributionPlacement.BottomLeft, // Size the map based on DP rather than pixels, as otherwise the features and attribution // end up being illegibly tiny on high density displays. - desiredWidth = constraints.maxWidth.toDp().value.toInt(), - desiredHeight = constraints.maxHeight.toDp().value.toInt(), - doubleScale = true, + width = constraints.maxWidth.toDp().value.toInt(), + height = constraints.maxHeight.toDp().value.toInt(), ) ) .size(width = constraints.maxWidth, height = constraints.maxHeight) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt new file mode 100644 index 0000000000..355741dbaa --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt @@ -0,0 +1,55 @@ +/* + * 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 + +/** + * 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 "${baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft" +} + +/** + * Provides the URL to a MapLibre style document, used for rendering dynamic maps. + */ +fun tileStyleUrl( + context: Context, + darkMode: Boolean, +): String { + return "${baseUrl(darkMode)}/style.json?key=${context.apiKey}" +} + +private fun baseUrl(darkMode: Boolean) = + "https://api.maptiler.com/maps/" + + if (darkMode) + "dea61faf-292b-4774-9660-58fcef89a7f3" + else + "9bc819c8-e627-474a-a348-ec144fe3d810" + +private val Context.apiKey: String + get() = getString(R.string.maptiler_api_key) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt deleted file mode 100644 index 66bbb906fc..0000000000 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt +++ /dev/null @@ -1,91 +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 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_2X = "@2x" -private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048 -private const val STATIC_MAP_MAX_ZOOM = 22.0 - -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" -} - -internal enum class AttributionPlacement(val value: String) { - BottomRight("bottomright"), - BottomLeft("bottomleft"), - TopLeft("topleft"), - TopRight("topright"), - Hidden("false"), -} - -/** - * 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, - doubleScale: Boolean, - attributionPlacement: AttributionPlacement, -): 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() - } - } - - val mapId = if (darkMode) DARK_MAP_ID else LIGHT_MAP_ID - val scaleSuffix = if (doubleScale) STATIC_MAP_SCALE_2X else "" - - return "$BASE_URL/maps/$mapId/static/${lon},${lat},${zoom}/${width}x${height}${scaleSuffix}.$STATIC_MAP_FORMAT" + - "?key=$API_KEY&attribution=${attributionPlacement.value}" -} diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt deleted file mode 100644 index 71e5988185..0000000000 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt +++ /dev/null @@ -1,117 +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 com.google.common.truth.Truth.assertThat -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, - doubleScale = false, - attributionPlacement = AttributionPlacement.BottomLeft, - ) - ).isEqualTo( - "https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" + - "?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft" - ) - } - - @Test - fun `buildStaticMapsApiUrl builds dark mode url`() { - assertThat( - buildStaticMapsApiUrl( - lat = 1.234, - lon = 5.678, - desiredZoom = 1.2, - desiredWidth = 100, - desiredHeight = 200, - darkMode = true, - doubleScale = false, - attributionPlacement = AttributionPlacement.BottomLeft, - ) - ).isEqualTo( - "https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp" + - "?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft" - ) - } - - @Test - fun `buildStaticMapsApiUrl builds double scale mode url`() { - assertThat( - buildStaticMapsApiUrl( - lat = 1.234, - lon = 5.678, - desiredZoom = 1.2, - desiredWidth = 100, - desiredHeight = 200, - darkMode = false, - doubleScale = true, - attributionPlacement = AttributionPlacement.BottomLeft, - ) - ).isEqualTo( - "https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200@2x.webp" + - "?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft" - ) - } - - @Test - fun `buildStaticMapsApiUrl builds no attribution url`() { - assertThat( - buildStaticMapsApiUrl( - lat = 1.234, - lon = 5.678, - desiredZoom = 1.2, - desiredWidth = 100, - desiredHeight = 200, - darkMode = false, - doubleScale = false, - attributionPlacement = AttributionPlacement.Hidden, - ) - ).isEqualTo( - "https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" + - "?key=fU3vlMsMn4Jb6dnEIFsx&attribution=false" - ) - } - - @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, - doubleScale = false, - attributionPlacement = AttributionPlacement.BottomLeft, - ) - ).isEqualTo( - "https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp" + - "?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft" - ) - } -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt index 18d568d4a4..a344d8571e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/map/MapView.kt @@ -50,7 +50,7 @@ import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM import io.element.android.features.location.api.Location -import io.element.android.features.location.api.internal.buildTileServerUrl +import io.element.android.features.location.api.internal.tileStyleUrl import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Text @@ -102,7 +102,7 @@ fun MapView( isCompassEnabled = false isRotateGesturesEnabled = false } - map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style -> + map.setStyle(tileStyleUrl(context, darkMode)) { style -> mapRefs = MapRefs( map = map, symbolManager = SymbolManager(mapView, map, style).apply {