Disable location sharing when no MapTiler config is available

LocationService now exposes the MapTiler config to use: the one provided by the
homeserver takes precedence over the build time one, and null means that no map
provider is configured. It is now session scoped, and isServiceAvailable()
returns a StateFlow derived from this config.

When no config is available:
- the location sharing option is hidden from the composer, and location events
  cannot be opened in the location viewer;
- the "Location sharing" entry is hidden from the settings;
- location events in the timeline render a placeholder, without performing any
  request to MapTiler.

The show and share location screens now get the config from LocationService
rather than fetching the homeserver config by themselves.

Closes element-hq/element-android-enterprise#69

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-13 17:15:37 +02:00
parent 5104e044ff
commit 4b97d6ab96
28 changed files with 212 additions and 106 deletions
@@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
@@ -52,13 +51,13 @@ import io.element.android.features.callnative.api.NativeCallHost
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.location.api.LocalMapTilerConfig
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
@@ -153,7 +152,7 @@ class LoggedInFlowNode(
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val locationService: LocationService,
private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
@@ -636,10 +635,7 @@ class LoggedInFlowNode(
enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
val currentMapTilerConfig = LocalMapTilerConfig.current
val updatedMapTilerConfig by produceState(currentMapTilerConfig) {
value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig
}
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
@@ -648,7 +644,7 @@ class LoggedInFlowNode(
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
CompositionLocalProvider(LocalMapTilerConfig provides mapTilerConfig) {
val isOnline by syncService.isOnline.collectAsState()
// Outside the connectivity indicator so the two strips stack rather than fight: an
// offline banner during a call belongs under the call bar, not over it.
@@ -11,13 +11,22 @@ import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Provides a [MapTilerConfig] for the MapLibre maps.
* Provides the [MapTilerConfig] for the MapLibre maps, or null if no map provider is configured.
*/
val LocalMapTilerConfig = staticCompositionLocalOf {
MapTilerConfig(
apiKey = BuildConfig.MAPTILER_API_KEY,
lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID,
baseUrl = BuildConfig.MAPTILER_BASE_URL
)
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
)
}
}
@@ -8,10 +8,21 @@
package io.element.android.features.location.api
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.libraries.core.coroutine.mapState
import kotlinx.coroutines.flow.StateFlow
/**
* Tells whether the location features can be used at all, which depends on the build having a map provider configured.
* Tells whether the location features can be used at all, which depends on a map provider being configured,
* either by the homeserver or at build time.
*/
interface LocationService {
/**
* The [MapTilerConfig] to use to render the maps, or null if no map provider is configured.
* The configuration provided by the homeserver takes precedence over the one defined at build time.
*/
val mapTilerConfig: StateFlow<MapTilerConfig?>
/** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */
fun isServiceAvailable(): Boolean
fun isServiceAvailable(): StateFlow<Boolean> = mapTilerConfig.mapState { it != null }
}
@@ -50,11 +50,12 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
/**
* Shows a static map image downloaded via a third party service's static maps API.
*
* Handles 4 distinct cases:
* Handles 5 distinct cases:
* 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching
* 2. Null location - shows blurred placeholder, no pin, no loading
* 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
* 4. Success (location != null, loaded) - shows actual map with pin
* 3. Null mapTilerConfig (no map provider configured) - shows blurred placeholder, no pin, no fetching
* 4. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
* 5. Success (location != null, loaded) - shows actual map with pin
*/
@Composable
fun StaticMapView(
@@ -84,7 +85,8 @@ fun StaticMapView(
)
}
// Case 2: Null location - show blurred placeholder, no pin, no loading
location == null -> {
// Case 3: No map provider configured - show blurred placeholder, no pin, no fetching
location == null || mapTilerConfig == null && !LocalInspectionMode.current -> {
StaticMapPlaceholder(
painter = painterResource(R.drawable.blurred_map),
canReload = false,
@@ -94,7 +96,7 @@ fun StaticMapView(
onLoadMapClick = {}
)
}
// Cases 3 & 4: Non-null location - fetch map
// Cases 4 & 5: Non-null location - fetch map
else -> LoadableMapContent(
mapTilerConfig = mapTilerConfig,
location = location,
@@ -8,14 +8,31 @@
package io.element.android.features.location.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.location.api.BuildConfig
import dev.zacsweers.metro.SingleIn
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.buildTimeMapTilerConfig
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
@ContributesBinding(AppScope::class)
class DefaultLocationService : LocationService {
override fun isServiceAvailable(): Boolean {
return BuildConfig.MAPTILER_API_KEY.isNotEmpty()
}
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class)
class DefaultLocationService(
customMapTilerConfigProvider: CustomMapTilerConfigProvider,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
) : LocationService {
override val mapTilerConfig: StateFlow<MapTilerConfig?> = flow {
customMapTilerConfigProvider.get().getOrNull()?.let { emit(it) }
}.stateIn(
scope = sessionCoroutineScope,
started = SharingStarted.Eagerly,
initialValue = buildTimeMapTilerConfig(),
)
}
@@ -14,7 +14,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -22,7 +21,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
@@ -38,7 +37,6 @@ import io.element.android.features.location.impl.common.userlocation.UserLocatio
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.extensions.flatMap
@@ -73,7 +71,7 @@ class ShareLocationPresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore,
private val userLocationStateFactory: UserLocationState.Factory,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val locationService: LocationService,
) : Presenter<ShareLocationState> {
@AssistedFactory
fun interface Factory {
@@ -94,10 +92,7 @@ class ShareLocationPresenter(
var pendingLiveLocationShare by remember { mutableStateOf(false) }
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState()
val customMapConfig by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
}
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
perms.sendLiveLocationPermissions()
}
@@ -201,7 +196,7 @@ class ShareLocationPresenter(
}
return ShareLocationState(
customMapTilerConfig = customMapConfig,
mapTilerConfig = mapTilerConfig,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
@@ -12,12 +12,11 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState(
val customMapTilerConfig: AsyncData<MapTilerConfig?>,
val mapTilerConfig: MapTilerConfig?,
val currentUser: MatrixUser,
val dialogState: Dialog,
val trackUserLocation: Boolean,
@@ -12,8 +12,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.features.location.impl.show.aPreviewMapTilerConfig
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.persistentListOf
@@ -71,13 +71,13 @@ class ShareLocationStatePreviewParam : PreviewParameterProvider<ShareLocationSta
canShareLiveLocation = true,
),
aShareLocationState(
customMapTilerConfig = AsyncData.Loading(),
mapTilerConfig = null,
),
)
}
fun aShareLocationState(
customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(),
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false,
@@ -88,7 +88,7 @@ fun aShareLocationState(
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState {
return ShareLocationState(
customMapTilerConfig = customMapTilerConfig,
mapTilerConfig = mapTilerConfig,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
@@ -130,7 +130,7 @@ private fun ShareLocationViewContent(
navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull())
val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig)
val mapState = rememberMapState(
baseStyle = BaseStyle.Uri(tileStyleUrl),
initialCameraPosition = MapDefaults.defaultCameraPosition,
@@ -181,7 +181,7 @@ private fun ShareLocationScaffold(
)
)
MapBottomSheetScaffold(
isReady = state.customMapTilerConfig.isReady(),
isReady = state.mapTilerConfig != null,
mapState = mapState,
modifier = modifier,
scaffoldState = scaffoldState,
@@ -22,8 +22,8 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.api.live.isCurrentlySharing
@@ -39,7 +39,6 @@ import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.features.location.impl.common.userlocation.toLocationMeasurement
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.meta.BuildMeta
@@ -68,7 +67,7 @@ class ShowLocationPresenter(
private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val userLocationStateFactory: UserLocationState.Factory,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val locationService: LocationService,
) : Presenter<ShowLocationState> {
@AssistedFactory fun interface Factory {
fun create(mode: ShowLocationMode): ShowLocationPresenter
@@ -86,10 +85,7 @@ class ShowLocationPresenter(
mutableStateOf(LocationConstraintsDialogState.None)
}
val customMapTilerConfig by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
}
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
fun checkLocationConstraints() {
val locationConstraints = checkLocationConstraints(
@@ -221,7 +217,7 @@ class ShowLocationPresenter(
userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
}
return ShowLocationState(
customMapTilerConfig = customMapTilerConfig,
mapTilerConfig = mapTilerConfig,
dialogState = dialogState,
locationShares = locationShares,
focusedLocation = focusedLocation,
@@ -13,7 +13,6 @@ import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.ui.LocationMarkerData
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
@@ -21,7 +20,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState(
val customMapTilerConfig: AsyncData<MapTilerConfig?>,
val mapTilerConfig: MapTilerConfig?,
val isLive: Boolean,
val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList<LocationShareItem>,
@@ -13,7 +13,6 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
@@ -37,14 +36,21 @@ class ShowLocationStatePreviewParam : PreviewParameterProvider<ShowLocationState
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
),
aShowLocationState(isTrackMyLocation = true),
aShowLocationState(customMapTilerConfig = AsyncData.Loading()),
aShowLocationState(mapTilerConfig = null),
)
}
private const val APP_NAME = "ApplicationName"
internal fun aPreviewMapTilerConfig() = MapTilerConfig(
apiKey = "preview_api_key",
lightStyleId = null,
darkStyleId = null,
baseUrl = null,
)
fun aShowLocationState(
customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(),
isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
@@ -56,7 +62,7 @@ fun aShowLocationState(
eventSink: (ShowLocationEvent) -> Unit = {},
): ShowLocationState {
return ShowLocationState(
customMapTilerConfig = customMapTilerConfig,
mapTilerConfig = mapTilerConfig,
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation,
@@ -102,7 +102,7 @@ private fun ShowLocationViewContent(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull())
val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig)
val mapState = rememberMapState(
baseStyle = BaseStyle.Uri(tileStyleUrl),
initialCameraPosition = MapDefaults.defaultCameraPosition,
@@ -178,7 +178,7 @@ private fun ShowLocationScaffold(
}
}
MapBottomSheetScaffold(
isReady = state.customMapTilerConfig.isReady(),
isReady = state.mapTilerConfig != null,
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
} else {
@@ -9,15 +9,56 @@
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
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultLocationServiceTest {
@Test
fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() {
val locationService = DefaultLocationService()
assertThat(locationService.isServiceAvailable()).isEqualTo(
BuildConfig.MAPTILER_API_KEY.isNotEmpty()
fun `when the homeserver does not provide a config, the build time config is used`() = runTest {
val locationService = createDefaultLocationService(
customMapTilerConfigProvider = { Result.success(null) },
)
runCurrent()
assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig())
assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty())
}
@Test
fun `when the homeserver config cannot be retrieved, the build time config is used`() = runTest {
val locationService = createDefaultLocationService(
customMapTilerConfigProvider = { Result.failure(AN_EXCEPTION) },
)
runCurrent()
assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig())
assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty())
}
@Test
fun `when the homeserver provides a config, it is used and the service is available`() = runTest {
val config = aMapTilerConfig(apiKey = "homeserver_api_key")
val locationService = createDefaultLocationService(
customMapTilerConfigProvider = { Result.success(config) },
)
runCurrent()
assertThat(locationService.mapTilerConfig.value).isEqualTo(config)
assertThat(locationService.isServiceAvailable().value).isTrue()
}
private fun TestScope.createDefaultLocationService(
customMapTilerConfigProvider: CustomMapTilerConfigProvider,
sessionCoroutineScope: CoroutineScope = backgroundScope,
) = DefaultLocationService(
customMapTilerConfigProvider = customMapTilerConfigProvider,
sessionCoroutineScope = sessionCoroutineScope,
)
}
@@ -16,6 +16,7 @@ import io.element.android.features.location.impl.common.actions.FakeLocationActi
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -58,7 +59,7 @@ class DefaultShareLocationEntryPointTest {
sessionId = room.sessionId,
),
userLocationStateFactory = FakeUserLocationStateFactory(),
customMapTilerConfigProvider = { Result.success(null) },
locationService = FakeLocationService(),
)
},
analyticsService = FakeAnalyticsService(),
@@ -19,9 +19,9 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
import io.element.android.features.location.impl.common.actions.FakeLocationActions
@@ -31,6 +31,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.matrix.api.core.EventId
@@ -89,7 +90,7 @@ class ShareLocationPresenterTest {
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
client: FakeMatrixClient = fakeMatrixClient,
customMapTilerConfigProvider: () -> Result<MapTilerConfig?> = { Result.success(null) },
locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()),
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
@@ -103,7 +104,7 @@ class ShareLocationPresenterTest {
liveLocationShareManager = liveLocationShareManager,
liveLocationStore = liveLocationStore,
userLocationStateFactory = FakeUserLocationStateFactory(),
customMapTilerConfigProvider = customMapTilerConfigProvider,
locationService = locationService,
)
@Test
@@ -118,26 +119,24 @@ class ShareLocationPresenterTest {
val shareLocationPresenter = createShareLocationPresenter()
shareLocationPresenter.test {
val state = awaitFirstItem()
assertThat(state.customMapTilerConfig.isLoading()).isFalse()
assertThat(state.mapTilerConfig).isNotNull()
assertThat(state.trackUserLocation).isTrue()
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
}
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
fun `present - mapTilerConfig is provided by the location service`() = runTest {
val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
val shareLocationPresenter = createShareLocationPresenter(
client = FakeMatrixClient(
sessionId = A_USER_ID,
),
customMapTilerConfigProvider = { Result.success(mapTilerConfig) }
locationService = FakeLocationService(mapTilerConfig = mapTilerConfig),
)
shareLocationPresenter.test {
val state = awaitItem()
assertThat(state.customMapTilerConfig.isLoading()).isTrue()
val finalState = awaitItem()
assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig)
assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig)
cancelAndIgnoreRemainingEvents()
}
}
@@ -18,6 +18,7 @@ import io.element.android.features.location.impl.common.FakeUserLocationStateFac
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.location.test.FakeLocationService
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.core.aBuildMeta
@@ -51,7 +52,7 @@ class DefaultShowLocationEntryPointTest {
joinedRoom = joinedRoom,
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
userLocationStateFactory = FakeUserLocationStateFactory(),
customMapTilerConfigProvider = { Result.success(null) },
locationService = FakeLocationService(),
)
},
analyticsService = FakeAnalyticsService(),
@@ -12,9 +12,9 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
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.Location
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
@@ -24,6 +24,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.location.test.FakeLocationService
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -66,7 +67,7 @@ class ShowLocationPresenterTest {
locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
customMapTilerConfigProvider: CustomMapTilerConfigProvider = { Result.success(null) },
locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()),
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
@@ -77,18 +78,16 @@ class ShowLocationPresenterTest {
joinedRoom = joinedRoom,
liveLocationShareManager = liveLocationShareManager,
userLocationStateFactory = FakeUserLocationStateFactory(),
customMapTilerConfigProvider = customMapTilerConfigProvider,
locationService = locationService,
)
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
fun `present - mapTilerConfig is provided by the location service`() = runTest {
val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
val shareLocationPresenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(mapTilerConfig) })
shareLocationPresenter.test {
val state = awaitItem()
assertThat(state.customMapTilerConfig.isLoading()).isTrue()
val finalState = awaitItem()
assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig)
val showLocationPresenter = createShowLocationPresenter(locationService = FakeLocationService(mapTilerConfig = mapTilerConfig))
showLocationPresenter.test {
assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig)
cancelAndIgnoreRemainingEvents()
}
}
@@ -120,7 +119,7 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
val presenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(aMapTilerConfig()) })
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
@@ -407,9 +406,6 @@ class ShowLocationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.locationShares).isEmpty()
// Wait until we receive the custom map style URL result
skipItems(1)
// Emit a new live share
liveSharesFlow.value = listOf(
aLiveLocationShare(userId = userId)
+1
View File
@@ -16,6 +16,7 @@ android {
dependencies {
api(projects.features.location.api)
implementation(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
@@ -8,10 +8,21 @@
package io.element.android.features.location.test
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.LocationService
import kotlinx.coroutines.flow.MutableStateFlow
class FakeLocationService(
private val isServiceAvailable: Boolean = false,
mapTilerConfig: MapTilerConfig? = null,
) : LocationService {
override fun isServiceAvailable() = isServiceAvailable
constructor(isServiceAvailable: Boolean) : this(
MapTilerConfig(
apiKey = "test_api_key",
lightStyleId = null,
darkStyleId = null,
baseUrl = null,
).takeIf { isServiceAvailable }
)
override val mapTilerConfig = MutableStateFlow(mapTilerConfig)
}
@@ -776,7 +776,7 @@ class MessagesFlowNode(
assetType = event.content.assetType,
)
}
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable().value }
} else {
displayVulkanNotSupportedError = true
null
@@ -184,10 +184,7 @@ class MessageComposerPresenter(
val cameraPermissionState = cameraPermissionPresenter.present()
val canShareLocation = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canShareLocation.value = locationService.isServiceAvailable()
}
val canShareLocation by remember { locationService.isServiceAvailable() }.collectAsState()
val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages)
.collectAsState(initial = false)
@@ -431,7 +428,7 @@ class MessageComposerPresenter(
isInThreadTimeline = isInThread,
showAttachmentSourcePicker = showAttachmentSourcePicker,
showTextFormatting = showTextFormatting,
canShareLocation = canShareLocation.value,
canShareLocation = canShareLocation,
suggestions = suggestions.toImmutableList(),
resolveMentionDisplay = resolveMentionDisplay,
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
@@ -75,6 +75,7 @@ dependencies {
implementation(projects.features.analytics.api)
implementation(projects.features.enterprise.api)
implementation(projects.features.licenses.api)
implementation(projects.features.location.api)
implementation(projects.features.logout.api)
implementation(projects.features.deactivation.api)
implementation(projects.features.invite.api)
@@ -109,6 +110,7 @@ dependencies {
testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.features.licenses.test)
testImplementation(projects.features.location.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.features.logout.test)
testImplementation(projects.libraries.indicator.test)
@@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme
import io.element.android.features.location.api.LocationService
import io.element.android.features.preferences.impl.account.PreferencesAccountState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
@@ -47,6 +48,7 @@ class PreferencesRootPresenter(
private val snackbarDispatcher: SnackbarDispatcher,
private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider,
private val featureFlagService: FeatureFlagService,
private val locationService: LocationService,
private val sessionStore: SessionStore,
private val appPreferencesStore: AppPreferencesStore,
private val userStatusPresenter: Presenter<UserStatusState>,
@@ -115,6 +117,8 @@ class PreferencesRootPresenter(
val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState()
val isLocationServiceAvailable by remember { locationService.isServiceAvailable() }.collectAsState()
fun handleEvent(event: PreferencesRootEvent) {
when (event) {
is PreferencesRootEvent.OnVersionInfoClick -> {
@@ -150,6 +154,7 @@ class PreferencesRootPresenter(
showAnalyticsSettings = hasAnalyticsProviders,
showDeveloperSettings = showDeveloperSettings,
showLabsItem = showLabsItem,
showLocationSettings = isLocationServiceAvailable,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvent,
)
@@ -32,6 +32,7 @@ data class PreferencesRootState(
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val showLabsItem: Boolean,
val showLocationSettings: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (PreferencesRootEvent) -> Unit,
)
@@ -53,6 +53,8 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
// Minimal state
aPreferencesRootState(userStatusState = null),
// Location sharing not available
aPreferencesRootState(showLocationSettings = false),
)
}
@@ -68,6 +70,7 @@ fun aPreferencesRootState(
showAnalyticsSettings: Boolean = false,
showDeveloperSettings: Boolean = false,
showLabsItem: Boolean = false,
showLocationSettings: Boolean = true,
userStatusState: UserStatusState? = aUserStatusState(),
snackbarMessage: SnackbarMessage? = null,
eventSink: (PreferencesRootEvent) -> Unit = {},
@@ -83,6 +86,7 @@ fun aPreferencesRootState(
showAnalyticsSettings = showAnalyticsSettings,
showDeveloperSettings = showDeveloperSettings,
showLabsItem = showLabsItem,
showLocationSettings = showLocationSettings,
userStatusState = userStatusState,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
@@ -330,11 +330,13 @@ private fun AppSettingsSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = onOpenLockScreenSettings,
)
ListItem(
content = { Text(stringResource(id = CommonStrings.common_location_sharing)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
onClick = onOpenLocationSettings,
)
if (state.showLocationSettings) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_location_sharing)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
onClick = onOpenLocationSettings,
)
}
}
}
@@ -13,6 +13,8 @@ package io.element.android.features.preferences.impl.root
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.compound.theme.Theme
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.preferences.impl.account.aPreferencesAccountState
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
@@ -78,10 +80,21 @@ class PreferencesRootPresenterTest {
assertThat(loadedState.showAnalyticsSettings).isFalse()
assertThat(loadedState.showDeveloperSettings).isTrue()
assertThat(loadedState.showLabsItem).isFalse()
assertThat(loadedState.showLocationSettings).isTrue()
assertThat(loadedState.snackbarMessage).isNull()
}
}
@Test
fun `present - location settings are hidden when the location service is not available`() = runTest {
createPreferencesRootPresenter(
locationService = FakeLocationService(isServiceAvailable = false),
).test {
assertThat(awaitItem().showLocationSettings).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - user status state is null when the server does not support it`() = runTest {
createPreferencesRootPresenter(
@@ -291,6 +304,7 @@ class PreferencesRootPresenterTest {
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
sessionStore: SessionStore = InMemorySessionStore(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
locationService: LocationService = FakeLocationService(isServiceAvailable = true),
) = PreferencesRootPresenter(
matrixClient = matrixClient,
analyticsService = FakeAnalyticsService(),
@@ -298,6 +312,7 @@ class PreferencesRootPresenterTest {
snackbarDispatcher = SnackbarDispatcher(),
showDeveloperSettingsProvider = showDeveloperSettingsProvider,
featureFlagService = featureFlagService,
locationService = locationService,
sessionStore = sessionStore,
appPreferencesStore = appPreferencesStore,
userStatusPresenter = { aUserStatusState() },