Let element enterprise be able to configure id for mapTiler. (#4446)

* Let element enterprise configure the ids for maptiler service.

* Disable location sharing and location viewer is the service is not available.

* Fix compilation issue on connected test

* Do not allow to reload the map if the mapId is not available.

* Update screenshots

* Rename file.

* Better to inject a string provider here, so we can unit test DefaultLocationService.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2025-03-21 17:06:52 +01:00
committed by GitHub
parent da0a2144bd
commit f0a6a0037c
24 changed files with 198 additions and 36 deletions

View File

@@ -5,6 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import config.BuildTimeConfig
import extension.readLocalProperty
plugins {
@@ -19,24 +20,36 @@ android {
resValue(
type = "string",
name = "maptiler_api_key",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey")
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_APIKEY
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey")
}
?: ""
)
resValue(
type = "string",
name = "maptiler_light_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
?: readLocalProperty("services.maptiler.lightMapId")
// fall back to maptiler's default light map.
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
?: readLocalProperty("services.maptiler.lightMapId")
}
// fall back to maptiler's default light map.
?: "basic-v2"
)
resValue(
type = "string",
name = "maptiler_dark_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
?: readLocalProperty("services.maptiler.darkMapId")
// fall back to maptiler's default dark map.
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
?: readLocalProperty("services.maptiler.darkMapId")
}
// fall back to maptiler's default dark map.
?: "basic-v2-dark"
)
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector 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
interface LocationService {
fun isServiceAvailable(): Boolean
}

View File

@@ -103,6 +103,7 @@ fun StaticMapView(
} else {
StaticMapPlaceholder(
showProgress = collectedState.value.isLoading(),
canReload = builder.isServiceAvailable(),
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,

View File

@@ -57,6 +57,8 @@ internal class MapTilerStaticMapUrlBuilder(
// to keep the perceived content size constant at the expense of sharpness.
return "$MAPTILER_BASE_URL/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft"
}
override fun isServiceAvailable() = apiKey.isNotEmpty()
}
private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair<Int, Int> {

View File

@@ -9,8 +9,10 @@ package io.element.android.features.location.api.internal
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -18,7 +20,6 @@ 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.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -28,12 +29,12 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.libraries.designsystem.utils.BooleanProvider
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun StaticMapPlaceholder(
showProgress: Boolean,
canReload: Boolean,
contentDescription: String?,
width: Dp,
height: Dp,
@@ -54,7 +55,7 @@ internal fun StaticMapPlaceholder(
)
if (showProgress) {
CircularProgressIndicator()
} else {
} else if (canReload) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -70,14 +71,24 @@ internal fun StaticMapPlaceholder(
@PreviewsDayNight
@Composable
internal fun StaticMapPlaceholderPreview(
@PreviewParameter(BooleanProvider::class) values: Boolean
) = ElementPreview {
StaticMapPlaceholder(
showProgress = values,
contentDescription = null,
width = 400.dp,
height = 400.dp,
onLoadMapClick = {},
)
internal fun StaticMapPlaceholderPreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(
true to false,
false to true,
false to false,
).forEach { (showProgress, canReload) ->
StaticMapPlaceholder(
showProgress = showProgress,
canReload = canReload,
contentDescription = null,
width = 400.dp,
height = 200.dp,
onLoadMapClick = {},
)
}
}
}

View File

@@ -22,6 +22,8 @@ interface StaticMapUrlBuilder {
height: Int,
density: Float,
): String
fun isServiceAvailable(): Boolean
}
fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context)

View File

@@ -17,6 +17,21 @@ class MapTilerStaticMapUrlBuilderTest {
darkMapId = "aDarkMapId",
)
@Test
fun `isServiceAvailable returns true if api key is not empty`() {
assertThat(builder.isServiceAvailable()).isTrue()
}
@Test
fun `isServiceAvailable returns false if api key is empty`() {
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
apiKey = "",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
)
assertThat(builderWithoutKey.isServiceAvailable()).isFalse()
}
@Test
fun `static map 1x density`() {
assertThat(

View File

@@ -49,6 +49,7 @@ dependencies {
testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025 New Vector 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.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.R
import io.element.android.libraries.di.AppScope
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultLocationService @Inject constructor(
private val stringProvider: StringProvider,
) : LocationService {
override fun isServiceAvailable(): Boolean {
return stringProvider.getString(R.string.maptiler_api_key).isNotEmpty()
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector 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.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.R
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import org.junit.Test
class DefaultLocationServiceTest {
@Test
fun `if apiKey is empty, isServiceAvailable should return false`() {
val fakeStringProvider = FakeStringProvider(
defaultResult = ""
)
val locationService = DefaultLocationService(
stringProvider = fakeStringProvider,
)
assertThat(locationService.isServiceAvailable()).isFalse()
assertThat(fakeStringProvider.lastResIdParam).isEqualTo(R.string.maptiler_api_key)
}
@Test
fun `if apiKey is not empty, isServiceAvailable should return true`() {
val locationService = DefaultLocationService(
stringProvider = FakeStringProvider(
defaultResult = "aKey"
)
)
assertThat(locationService.isServiceAvailable()).isTrue()
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.location.test"
}
dependencies {
implementation(projects.features.location.api)
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector 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.test
import io.element.android.features.location.api.LocationService
class FakeLocationService(
private val isServiceAvailable: Boolean,
) : LocationService {
override fun isServiceAvailable() = isServiceAvailable
}

View File

@@ -75,6 +75,7 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.location.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.services.analytics.test)

View File

@@ -28,6 +28,7 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
@@ -96,6 +97,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val elementCallEntryPoint: ElementCallEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val analyticsService: AnalyticsService,
private val locationService: LocationService,
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val mentionSpanTheme: MentionSpanTheme,
@@ -409,7 +411,7 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.LocationViewer(
location = event.content.location,
description = event.content.description,
)
).takeIf { locationService.isServiceAvailable() }
}
else -> null
}

View File

@@ -30,6 +30,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.location.api.LocationService
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
@@ -104,6 +105,7 @@ class MessageComposerPresenter @AssistedInject constructor(
private val mediaSender: MediaSender,
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val locationService: LocationService,
private val messageComposerContext: DefaultMessageComposerContext,
private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val roomAliasSuggestionsDataSource: RoomAliasSuggestionsDataSource,
@@ -155,7 +157,8 @@ class MessageComposerPresenter @AssistedInject constructor(
val canShareLocation = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing) &&
locationService.isServiceAvailable()
}
val canCreatePoll = remember { mutableStateOf(false) }

View File

@@ -18,6 +18,8 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
@@ -1536,6 +1538,7 @@ class MessageComposerPresenterTest {
navigator: MessagesNavigator = FakeMessagesNavigator(),
pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService,
locationService: LocationService = FakeLocationService(true),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
@@ -1558,6 +1561,7 @@ class MessageComposerPresenterTest {
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService,
locationService = locationService,
messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),

View File

@@ -46,5 +46,4 @@ dependencies {
androidTestImplementation(libs.test.junit)
androidTestImplementation(libs.test.truth)
androidTestImplementation(libs.test.runner)
androidTestImplementation(projects.libraries.sessionStorage.test)
}

View File

@@ -10,7 +10,6 @@ package io.element.android.libraries.pushstore.impl
import androidx.test.platform.app.InstrumentationRegistry
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.UserPushStore
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Test
@@ -28,7 +27,7 @@ class DefaultUserPushStoreFactoryTest {
fun testParallelCreation() {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val sessionId = SessionId("@alice:server.org")
val userPushStoreFactory = DefaultUserPushStoreFactory(context, NoOpSessionObserver())
val userPushStoreFactory = DefaultUserPushStoreFactory(context)
var userPushStore1: UserPushStore? = null
val thread1 = thread {
userPushStore1 = userPushStoreFactory.getOrCreate(sessionId)

View File

@@ -13,4 +13,8 @@ object BuildTimeConfig {
const val GOOGLE_APP_ID_RELEASE = "1:912726360885:android:d097de99a4c23d2700427c"
const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c"
const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c"
val SERVICES_MAPTILER_APIKEY: String? = null
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null
val SERVICES_MAPTILER_DARK_MAPID: String? = null
}

View File

@@ -12,15 +12,19 @@ import io.element.android.services.toolbox.api.strings.StringProvider
class FakeStringProvider(
private val defaultResult: String = "A string"
) : StringProvider {
var lastResIdParam: Int? = null
override fun getString(resId: Int): String {
lastResIdParam = resId
return defaultResult
}
override fun getString(resId: Int, vararg formatArgs: Any?): String {
lastResIdParam = resId
return defaultResult + formatArgs.joinToString()
}
override fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any?): String {
lastResIdParam = resId
return defaultResult + " ($quantity) " + formatArgs.joinToString()
}
}