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. * Please see LICENSE files in the repository root for full details.
*/ */
import config.BuildTimeConfig
import extension.readLocalProperty import extension.readLocalProperty
plugins { plugins {
@ -19,24 +20,36 @@ android {
resValue( resValue(
type = "string", type = "string",
name = "maptiler_api_key", name = "maptiler_api_key",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") value = if (isEnterpriseBuild) {
?: readLocalProperty("services.maptiler.apikey") BuildTimeConfig.SERVICES_MAPTILER_APIKEY
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey")
}
?: "" ?: ""
) )
resValue( resValue(
type = "string", type = "string",
name = "maptiler_light_map_id", name = "maptiler_light_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID") value = if (isEnterpriseBuild) {
?: readLocalProperty("services.maptiler.lightMapId") BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID
// fall back to maptiler's default light map. } else {
System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
?: readLocalProperty("services.maptiler.lightMapId")
}
// fall back to maptiler's default light map.
?: "basic-v2" ?: "basic-v2"
) )
resValue( resValue(
type = "string", type = "string",
name = "maptiler_dark_map_id", name = "maptiler_dark_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID") value = if (isEnterpriseBuild) {
?: readLocalProperty("services.maptiler.darkMapId") BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID
// fall back to maptiler's default dark map. } else {
System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
?: readLocalProperty("services.maptiler.darkMapId")
}
// fall back to maptiler's default dark map.
?: "basic-v2-dark" ?: "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 { } else {
StaticMapPlaceholder( StaticMapPlaceholder(
showProgress = collectedState.value.isLoading(), showProgress = collectedState.value.isLoading(),
canReload = builder.isServiceAvailable(),
contentDescription = contentDescription, contentDescription = contentDescription,
width = maxWidth, width = maxWidth,
height = maxHeight, height = maxHeight,

View file

@ -57,6 +57,8 @@ internal class MapTilerStaticMapUrlBuilder(
// to keep the perceived content size constant at the expense of sharpness. // 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" 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> { 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.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -18,7 +20,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons 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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text 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 import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun StaticMapPlaceholder( internal fun StaticMapPlaceholder(
showProgress: Boolean, showProgress: Boolean,
canReload: Boolean,
contentDescription: String?, contentDescription: String?,
width: Dp, width: Dp,
height: Dp, height: Dp,
@ -54,7 +55,7 @@ internal fun StaticMapPlaceholder(
) )
if (showProgress) { if (showProgress) {
CircularProgressIndicator() CircularProgressIndicator()
} else { } else if (canReload) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -70,14 +71,24 @@ internal fun StaticMapPlaceholder(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun StaticMapPlaceholderPreview( internal fun StaticMapPlaceholderPreview() = ElementPreview {
@PreviewParameter(BooleanProvider::class) values: Boolean Column(
) = ElementPreview { modifier = Modifier.padding(8.dp),
StaticMapPlaceholder( verticalArrangement = Arrangement.spacedBy(8.dp)
showProgress = values, ) {
contentDescription = null, listOf(
width = 400.dp, true to false,
height = 400.dp, false to true,
onLoadMapClick = {}, 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, height: Int,
density: Float, density: Float,
): String ): String
fun isServiceAvailable(): Boolean
} }
fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context) fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context)

View file

@ -17,6 +17,21 @@ class MapTilerStaticMapUrlBuilderTest {
darkMapId = "aDarkMapId", 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 @Test
fun `static map 1x density`() { fun `static map 1x density`() {
assertThat( assertThat(

View file

@ -49,6 +49,7 @@ dependencies {
testImplementation(projects.libraries.testtags) testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) 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(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.location.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
testImplementation(projects.services.analytics.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.call.api.ElementCallEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.Location 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.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
@ -96,6 +97,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val elementCallEntryPoint: ElementCallEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint, private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val locationService: LocationService,
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache, private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val mentionSpanTheme: MentionSpanTheme, private val mentionSpanTheme: MentionSpanTheme,
@ -409,7 +411,7 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.LocationViewer( NavTarget.LocationViewer(
location = event.content.location, location = event.content.location,
description = event.content.description, description = event.content.description,
) ).takeIf { locationService.isServiceAvailable() }
} }
else -> null else -> null
} }

View file

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

View file

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

View file

@ -46,5 +46,4 @@ dependencies {
androidTestImplementation(libs.test.junit) androidTestImplementation(libs.test.junit)
androidTestImplementation(libs.test.truth) androidTestImplementation(libs.test.truth)
androidTestImplementation(libs.test.runner) 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 androidx.test.platform.app.InstrumentationRegistry
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.UserPushStore 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.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Test import org.junit.Test
@ -28,7 +27,7 @@ class DefaultUserPushStoreFactoryTest {
fun testParallelCreation() { fun testParallelCreation() {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val sessionId = SessionId("@alice:server.org") val sessionId = SessionId("@alice:server.org")
val userPushStoreFactory = DefaultUserPushStoreFactory(context, NoOpSessionObserver()) val userPushStoreFactory = DefaultUserPushStoreFactory(context)
var userPushStore1: UserPushStore? = null var userPushStore1: UserPushStore? = null
val thread1 = thread { val thread1 = thread {
userPushStore1 = userPushStoreFactory.getOrCreate(sessionId) 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_RELEASE = "1:912726360885:android:d097de99a4c23d2700427c"
const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c" const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c"
const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c" 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( class FakeStringProvider(
private val defaultResult: String = "A string" private val defaultResult: String = "A string"
) : StringProvider { ) : StringProvider {
var lastResIdParam: Int? = null
override fun getString(resId: Int): String { override fun getString(resId: Int): String {
lastResIdParam = resId
return defaultResult return defaultResult
} }
override fun getString(resId: Int, vararg formatArgs: Any?): String { override fun getString(resId: Int, vararg formatArgs: Any?): String {
lastResIdParam = resId
return defaultResult + formatArgs.joinToString() return defaultResult + formatArgs.joinToString()
} }
override fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any?): String { override fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any?): String {
lastResIdParam = resId
return defaultResult + " ($quantity) " + formatArgs.joinToString() return defaultResult + " ($quantity) " + formatArgs.joinToString()
} }
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:aad48b326c07ed1eda341817b9a30cc74ef6cc83dd80406dd26aed858112eda8 oid sha256:7275edb15579beb09be957ca2fb42ac95fc3131a9349446920db9797d79efca5
size 252914 size 439078

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a1dab0d4f896e496af650cab32f2b1fd8572ab698b6cbfd84166334216117149
size 255494

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e63a6eaedca373d7154c66d7f9fc5f52e4999dbfaafe00bb06cf94a86e2fb762 oid sha256:50de6c03736fc71fc3029dadf353c212bfc504a643d6216594f5417357daf16c
size 105538 size 173445

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ace6730f6dc2fe45aa2cb095b59453cf449de62a98508587439d277ff3fd23db
size 107041