Merge pull request #7911 from element-hq/feature/bma/disableLocationSharingWithoutMapTiler

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