Merge branch 'develop' into feature-oled-black

This commit is contained in:
Benoit Marty 2026-04-17 14:47:15 +02:00 committed by GitHub
commit 4e5542396f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
319 changed files with 8286 additions and 2172 deletions

View file

@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsNode
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
@ -28,6 +29,17 @@ class DefaultPreferencesEntryPoint : PreferencesEntryPoint {
plugins = listOf(params, callback)
)
}
override fun createAppDeveloperSettingsNode(
parentNode: Node,
buildContext: BuildContext,
callback: PreferencesEntryPoint.DeveloperSettingsCallback,
): Node {
return parentNode.createNode<AppDeveloperSettingsNode>(
buildContext = buildContext,
plugins = listOf(callback),
)
}
}
internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) {

View file

@ -9,15 +9,8 @@
package io.element.android.features.preferences.impl.developer
import androidx.compose.ui.graphics.Color
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
sealed interface DeveloperSettingsEvents {
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents
data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents
data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : DeveloperSettingsEvents
data class SetShowColorPicker(val show: Boolean) : DeveloperSettingsEvents
data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents

View file

@ -11,61 +11,37 @@ package io.element.android.features.preferences.impl.developer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
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
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.graphics.toArgb
import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
import io.element.android.features.preferences.impl.model.EnabledFeature
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
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.runCatchingUpdatingState
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.net.URL
@Inject
class DeveloperSettingsPresenter(
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
private val sessionId: SessionId,
private val featureFlagService: FeatureFlagService,
private val computeCacheSizeUseCase: ComputeCacheSizeUseCase,
private val clearCacheUseCase: ClearCacheUseCase,
private val rageshakePresenter: Presenter<RageshakePreferencesState>,
private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta,
private val enterpriseService: EnterpriseService,
private val vacuumStoresUseCase: VacuumStoresUseCase,
private val databaseSizesUseCase: GetDatabaseSizesUseCase,
@ -73,10 +49,6 @@ class DeveloperSettingsPresenter(
) : Presenter<DeveloperSettingsState> {
@Composable
override fun present(): DeveloperSettingsState {
val rageshakeState = rageshakePresenter.present()
val enabledFeatures = remember {
mutableStateListOf<EnabledFeature>()
}
val cacheSize = remember {
mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized)
}
@ -89,38 +61,9 @@ class DeveloperSettingsPresenter(
var showColorPicker by remember {
mutableStateOf(false)
}
val customElementCallBaseUrl by remember {
appPreferencesStore
.getCustomElementCallBaseUrlFlow()
}.collectAsState(initial = null)
val tracingLogLevelFlow = remember {
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
}
val tracingLogLevel by tracingLogLevelFlow.collectAsState(initial = AsyncData.Uninitialized)
val tracingLogPacks by produceState(persistentListOf()) {
appPreferencesStore.getTracingLogPacksFlow()
// Sort the entries alphabetically by its title
.map { it.sortedBy { pack -> pack.title } }
.collectLatest { value = it.toImmutableList() }
}
LaunchedEffect(Unit) {
computeDatabaseSizes(databaseSizes)
featureFlagService.getAvailableFeatures()
.run {
// Never display room directory search in release builds for Play Store
if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) {
filterNot { it.key == FeatureFlags.RoomDirectorySearch.key }
} else {
this
}
}
.forEach { feature ->
enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature)))
}
}
val featureUiModels = createUiModels(enabledFeatures)
val coroutineScope = rememberCoroutineScope()
// Compute cache size each time the clear cache action value is changed
LaunchedEffect(clearCacheAction.value.isSuccess()) {
@ -129,29 +72,7 @@ class DeveloperSettingsPresenter(
fun handleEvent(event: DeveloperSettingsEvents) {
when (event) {
is DeveloperSettingsEvents.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature(
enabledFeatures = enabledFeatures,
featureKey = event.feature.key,
enabled = event.isEnabled,
triggerClearCache = { handleEvent(DeveloperSettingsEvents.ClearCache) }
)
is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch {
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() }
appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
}
DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction)
is DeveloperSettingsEvents.SetTracingLogLevel -> coroutineScope.launch {
appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel())
}
is DeveloperSettingsEvents.ToggleTracingLogPack -> coroutineScope.launch {
val currentPacks = tracingLogPacks.toMutableSet()
if (currentPacks.contains(event.logPack)) {
currentPacks.remove(event.logPack)
} else {
currentPacks.add(event.logPack)
}
appPreferencesStore.setTracingLogPacks(currentPacks)
}
is DeveloperSettingsEvents.ChangeBrandColor -> coroutineScope.launch {
showColorPicker = false
val color = event.color
@ -170,56 +91,18 @@ class DeveloperSettingsPresenter(
}
}
val appDeveloperSettingsState = appDeveloperSettingsPresenter.present()
return DeveloperSettingsState(
features = featureUiModels,
appDeveloperSettingsState = appDeveloperSettingsState,
cacheSize = cacheSize.value,
databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value,
rageshakeState = rageshakeState,
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
baseUrl = customElementCallBaseUrl,
validator = ::customElementCallUrlValidator,
),
tracingLogLevel = tracingLogLevel,
tracingLogPacks = tracingLogPacks,
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
showColorPicker = showColorPicker,
eventSink = ::handleEvent,
)
}
@Composable
private fun createUiModels(
enabledFeatures: SnapshotStateList<EnabledFeature>,
): ImmutableList<FeatureUiModel> {
return enabledFeatures.map { enabledFeature ->
key(enabledFeature.feature.key) {
remember(enabledFeature) {
FeatureUiModel(
key = enabledFeature.feature.key,
title = enabledFeature.feature.title,
description = enabledFeature.feature.description,
icon = null,
isEnabled = enabledFeature.isEnabled
)
}
}
}.toImmutableList()
}
private fun CoroutineScope.updateEnabledFeature(
enabledFeatures: SnapshotStateList<EnabledFeature>,
featureKey: String,
enabled: Boolean,
@Suppress("UNUSED_PARAMETER") triggerClearCache: () -> Unit,
) = launch {
val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == featureKey }.takeIf { it != -1 } ?: return@launch
val feature = enabledFeatures[featureIndex].feature
if (featureFlagService.setFeatureEnabled(feature, enabled)) {
enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = enabled)
}
}
private fun CoroutineScope.computeCacheSize(cacheSize: MutableState<AsyncData<String>>) = launch {
suspend {
computeCacheSizeUseCase()
@ -253,12 +136,3 @@ class DeveloperSettingsPresenter(
}.runCatchingUpdatingState(clearCacheAction)
}
}
private fun customElementCallUrlValidator(url: String?): Boolean {
return runCatchingExceptions {
if (url.isNullOrEmpty()) return@runCatchingExceptions
val parsedUrl = URL(url)
if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol")
if (parsedUrl.host.isNullOrBlank()) error("Missing host")
}.isSuccess
}

View file

@ -8,32 +8,19 @@
package io.element.android.features.preferences.impl.developer
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
data class DeveloperSettingsState(
val features: ImmutableList<FeatureUiModel>,
val appDeveloperSettingsState: AppDeveloperSettingsState,
val cacheSize: AsyncData<String>,
val databaseSizes: AsyncData<ImmutableMap<String, String>>,
val rageshakeState: RageshakePreferencesState,
val clearCacheAction: AsyncAction<Unit>,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
val tracingLogLevel: AsyncData<LogLevelItem>,
val tracingLogPacks: ImmutableList<TraceLogPack>,
val isEnterpriseBuild: Boolean,
val showColorPicker: Boolean,
val eventSink: (DeveloperSettingsEvents) -> Unit
) {
val showLoader = clearCacheAction is AsyncAction.Loading
}
data class CustomElementCallBaseUrlState(
val baseUrl: String?,
val validator: (String?) -> Boolean,
)

View file

@ -9,14 +9,11 @@
package io.element.android.features.preferences.impl.developer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
import io.element.android.features.preferences.impl.developer.appsettings.anAppDeveloperSettingsState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSettingsState> {
override val values: Sequence<DeveloperSettingsState>
@ -25,11 +22,6 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
aDeveloperSettingsState(
clearCacheAction = AsyncAction.Loading
),
aDeveloperSettingsState(
customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
baseUrl = "https://call.element.ahoy",
)
),
aDeveloperSettingsState(
isEnterpriseBuild = true,
// Disable the color picker for now, Paparazzi is failing with:
@ -43,30 +35,17 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
}
fun aDeveloperSettingsState(
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
traceLogPacks: List<TraceLogPack> = emptyList(),
isEnterpriseBuild: Boolean = false,
showColorPicker: Boolean = false,
eventSink: (DeveloperSettingsEvents) -> Unit = {},
) = DeveloperSettingsState(
features = aFeatureUiModelList(),
rageshakeState = aRageshakePreferencesState(),
appDeveloperSettingsState = appDeveloperSettingsState,
cacheSize = AsyncData.Success("1.2 MB"),
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
clearCacheAction = clearCacheAction,
customElementCallBaseUrlState = customElementCallBaseUrlState,
tracingLogLevel = AsyncData.Success(LogLevelItem.INFO),
tracingLogPacks = traceLogPacks.toImmutableList(),
isEnterpriseBuild = isEnterpriseBuild,
showColorPicker = showColorPicker,
eventSink = eventSink,
)
fun aCustomElementCallBaseUrlState(
baseUrl: String? = null,
validator: (String?) -> Boolean = { true },
) = CustomElementCallBaseUrlState(
baseUrl = baseUrl,
validator = validator,
)

View file

@ -10,40 +10,28 @@ package io.element.android.features.preferences.impl.developer
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.featureflag.ui.FeatureListView
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import io.element.android.libraries.ui.strings.CommonStrings
import io.mhssn.colorpicker.ColorPickerDialog
import io.mhssn.colorpicker.ColorPickerType
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@ -71,52 +59,12 @@ fun DeveloperSettingsView(
title = stringResource(id = CommonStrings.common_developer_options)
) {
// Note: this is OK to hardcode strings in this debug screen.
PreferenceCategory(
title = "Feature flags",
) {
FeatureListContent(state)
}
NotificationCategory(onPushHistoryClick)
ElementCallCategory(state = state)
PreferenceCategory(title = "Rust SDK") {
PreferenceDropdown(
title = "Tracing log level",
supportingText = "Requires app reboot",
selectedOption = state.tracingLogLevel.dataOrNull(),
options = LogLevelItem.entries.toImmutableList(),
onSelectOption = { logLevel ->
state.eventSink(DeveloperSettingsEvents.SetTracingLogLevel(logLevel))
}
)
}
PreferenceCategory(title = "Enable trace logs per SDK feature") {
Text(
text = "Requires app reboot",
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
)
for (logPack in TraceLogPack.entries) {
PreferenceSwitch(
title = logPack.title,
isChecked = state.tracingLogPacks.contains(logPack),
onCheckedChange = { isChecked -> state.eventSink(DeveloperSettingsEvents.ToggleTracingLogPack(logPack, isChecked)) }
)
}
}
PreferenceCategory(title = "Showkase") {
ListItem(
headlineContent = {
Text("Open Showkase browser")
},
onClick = onOpenShowkase
)
}
RageshakePreferencesView(
state = state.rageshakeState,
AppDeveloperSettingsView(
state = state.appDeveloperSettingsState,
onOpenShowkase = onOpenShowkase,
)
NotificationCategory(onPushHistoryClick)
if (state.isEnterpriseBuild) {
PreferenceCategory(title = "Theme") {
ListItem(
@ -137,14 +85,6 @@ fun DeveloperSettingsView(
)
}
}
PreferenceCategory(title = "Crash") {
ListItem(
headlineContent = {
Text("Crash the app 💥")
},
onClick = { error("This crash is a test.") }
)
}
val cache = state.cacheSize
PreferenceCategory(title = "Cache") {
ListItem(
@ -212,32 +152,6 @@ fun DeveloperSettingsView(
)
}
@Composable
private fun ElementCallCategory(
state: DeveloperSettingsState,
) {
PreferenceCategory(title = "Element Call") {
val callUrlState = state.customElementCallBaseUrlState
val supportingText = if (callUrlState.baseUrl.isNullOrEmpty()) {
stringResource(R.string.screen_advanced_settings_element_call_base_url_description)
} else {
callUrlState.baseUrl
}
PreferenceTextField(
headline = stringResource(R.string.screen_advanced_settings_element_call_base_url),
value = callUrlState.baseUrl,
placeholder = "https://.../room",
supportingText = supportingText,
validation = callUrlState.validator,
onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error),
displayValue = { value -> !value.isNullOrEmpty() },
keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri),
onChange = { state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl(it)) }
)
}
}
@Composable
private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) {
@ -250,20 +164,6 @@ private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
}
}
@Composable
private fun FeatureListContent(
state: DeveloperSettingsState,
) {
fun onFeatureEnabled(feature: FeatureUiModel, isEnabled: Boolean) {
state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, isEnabled))
}
FeatureListView(
features = state.features,
onCheckedChange = ::onFeatureEnabled,
)
}
@PreviewsDayNight
@Composable
internal fun DeveloperSettingsViewPreview(
@ -273,6 +173,6 @@ internal fun DeveloperSettingsViewPreview(
state = state,
onOpenShowkase = {},
onPushHistoryClick = {},
onBackClick = {}
onBackClick = {},
)
}

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer.appsettings
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
sealed interface AppDeveloperSettingsEvent {
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : AppDeveloperSettingsEvent
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent
data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent
data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer.appsettings
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.airbnb.android.showkase.models.Showkase
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.designsystem.showkase.getBrowserIntent
@ContributesNode(AppScope::class)
@AssistedInject
class AppDeveloperSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: AppDeveloperSettingsPresenter,
) : Node(buildContext, plugins = plugins) {
private val callback: PreferencesEntryPoint.DeveloperSettingsCallback = callback()
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
fun openShowkase() {
val intent = Showkase.getBrowserIntent(activity)
activity.startActivity(intent)
}
val state = presenter.present()
AppDeveloperSettingsPage(
state = state,
modifier = modifier,
onOpenShowkase = ::openShowkase,
onBackClick = callback::onDone,
)
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer.appsettings
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AppDeveloperSettingsPage(
state: AppDeveloperSettingsState,
onOpenShowkase: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(
onBack = onBackClick,
)
PreferencePage(
modifier = modifier,
onBackClick = {
onBackClick()
},
title = "Application developer options",
) {
AppDeveloperSettingsView(
state = state,
onOpenShowkase = onOpenShowkase,
modifier = Modifier.padding(top = 8.dp)
)
}
}
@PreviewsDayNight
@Composable
internal fun AppDeveloperSettingsPagePreview() = ElementPreview {
AppDeveloperSettingsPage(
state = anAppDeveloperSettingsState(),
onOpenShowkase = {},
onBackClick = {},
)
}

View file

@ -0,0 +1,168 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer.appsettings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateList
import dev.zacsweers.metro.Inject
import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
import io.element.android.features.preferences.impl.model.EnabledFeature
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.net.URL
@Inject
class AppDeveloperSettingsPresenter(
private val featureFlagService: FeatureFlagService,
private val rageshakePresenter: Presenter<RageshakePreferencesState>,
private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta,
) : Presenter<AppDeveloperSettingsState> {
@Composable
override fun present(): AppDeveloperSettingsState {
val rageshakeState = rageshakePresenter.present()
val enabledFeatures = remember {
mutableStateListOf<EnabledFeature>()
}
val customElementCallBaseUrl by remember {
appPreferencesStore
.getCustomElementCallBaseUrlFlow()
}.collectAsState(initial = null)
val tracingLogLevelFlow = remember {
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
}
val tracingLogLevel by tracingLogLevelFlow.collectAsState(initial = AsyncData.Uninitialized)
val tracingLogPacks by produceState(persistentListOf()) {
appPreferencesStore.getTracingLogPacksFlow()
// Sort the entries alphabetically by its title
.map { it.sortedBy { pack -> pack.title } }
.collectLatest { value = it.toImmutableList() }
}
LaunchedEffect(Unit) {
featureFlagService.getAvailableFeatures()
.run {
// Never display room directory search in release builds for Play Store
if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) {
filterNot { it.key == FeatureFlags.RoomDirectorySearch.key }
} else {
this
}
}
.forEach { feature ->
enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature)))
}
}
val featureUiModels = createUiModels(enabledFeatures)
val coroutineScope = rememberCoroutineScope()
// Compute cache size each time the clear cache action value is changed
fun handleEvent(event: AppDeveloperSettingsEvent) {
when (event) {
is AppDeveloperSettingsEvent.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature(
enabledFeatures = enabledFeatures,
featureKey = event.feature.key,
enabled = event.isEnabled,
)
is AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl -> coroutineScope.launch {
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() }
appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
}
is AppDeveloperSettingsEvent.SetTracingLogLevel -> coroutineScope.launch {
appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel())
}
is AppDeveloperSettingsEvent.ToggleTracingLogPack -> coroutineScope.launch {
val currentPacks = tracingLogPacks.toMutableSet()
if (currentPacks.contains(event.logPack)) {
currentPacks.remove(event.logPack)
} else {
currentPacks.add(event.logPack)
}
appPreferencesStore.setTracingLogPacks(currentPacks)
}
}
}
return AppDeveloperSettingsState(
features = featureUiModels,
rageshakeState = rageshakeState,
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
baseUrl = customElementCallBaseUrl,
validator = ::customElementCallUrlValidator,
),
tracingLogLevel = tracingLogLevel,
tracingLogPacks = tracingLogPacks,
eventSink = ::handleEvent,
)
}
@Composable
private fun createUiModels(
enabledFeatures: SnapshotStateList<EnabledFeature>,
): ImmutableList<FeatureUiModel> {
return enabledFeatures.map { enabledFeature ->
key(enabledFeature.feature.key) {
remember(enabledFeature) {
FeatureUiModel(
key = enabledFeature.feature.key,
title = enabledFeature.feature.title,
description = enabledFeature.feature.description,
icon = null,
isEnabled = enabledFeature.isEnabled
)
}
}
}.toImmutableList()
}
private fun CoroutineScope.updateEnabledFeature(
enabledFeatures: SnapshotStateList<EnabledFeature>,
featureKey: String,
enabled: Boolean,
) = launch {
val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == featureKey }.takeIf { it != -1 } ?: return@launch
val feature = enabledFeatures[featureIndex].feature
if (featureFlagService.setFeatureEnabled(feature, enabled)) {
enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = enabled)
}
}
}
private fun customElementCallUrlValidator(url: String?): Boolean {
return runCatchingExceptions {
if (url.isNullOrEmpty()) return@runCatchingExceptions
val parsedUrl = URL(url)
if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol")
if (parsedUrl.host.isNullOrBlank()) error("Missing host")
}.isSuccess
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer.appsettings
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.collections.immutable.ImmutableList
data class AppDeveloperSettingsState(
val features: ImmutableList<FeatureUiModel>,
val rageshakeState: RageshakePreferencesState,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
val tracingLogLevel: AsyncData<LogLevelItem>,
val tracingLogPacks: ImmutableList<TraceLogPack>,
val eventSink: (AppDeveloperSettingsEvent) -> Unit
)
data class CustomElementCallBaseUrlState(
val baseUrl: String?,
val validator: (String?) -> Boolean,
)

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer.appsettings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.collections.immutable.toImmutableList
open class AppDeveloperSettingsStateProvider : PreviewParameterProvider<AppDeveloperSettingsState> {
override val values: Sequence<AppDeveloperSettingsState>
get() = sequenceOf(
anAppDeveloperSettingsState(),
anAppDeveloperSettingsState(
customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
baseUrl = "https://call.element.ahoy",
)
),
)
}
fun anAppDeveloperSettingsState(
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
traceLogPacks: List<TraceLogPack> = emptyList(),
eventSink: (AppDeveloperSettingsEvent) -> Unit = {},
) = AppDeveloperSettingsState(
features = aFeatureUiModelList(),
rageshakeState = aRageshakePreferencesState(),
customElementCallBaseUrlState = customElementCallBaseUrlState,
tracingLogLevel = AsyncData.Success(LogLevelItem.INFO),
tracingLogPacks = traceLogPacks.toImmutableList(),
eventSink = eventSink,
)
fun aCustomElementCallBaseUrlState(
baseUrl: String? = null,
validator: (String?) -> Boolean = { true },
) = CustomElementCallBaseUrlState(
baseUrl = baseUrl,
validator = validator,
)

View file

@ -0,0 +1,161 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer.appsettings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.featureflag.ui.FeatureListView
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AppDeveloperSettingsView(
state: AppDeveloperSettingsState,
onOpenShowkase: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
) {
// Note: this is OK to hardcode strings in this debug screen.
PreferenceCategory(
title = "Feature flags",
showTopDivider = false,
) {
FeatureListContent(state)
}
ElementCallCategory(state = state)
PreferenceCategory(title = "Rust SDK") {
PreferenceDropdown(
title = "Tracing log level",
supportingText = "Requires app reboot",
selectedOption = state.tracingLogLevel.dataOrNull(),
options = LogLevelItem.entries.toImmutableList(),
onSelectOption = { logLevel ->
state.eventSink(AppDeveloperSettingsEvent.SetTracingLogLevel(logLevel))
}
)
}
PreferenceCategory(title = "Enable trace logs per SDK feature") {
Text(
text = "Requires app reboot",
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
)
for (logPack in TraceLogPack.entries) {
PreferenceSwitch(
title = logPack.title,
isChecked = state.tracingLogPacks.contains(logPack),
onCheckedChange = { isChecked -> state.eventSink(AppDeveloperSettingsEvent.ToggleTracingLogPack(logPack, isChecked)) }
)
}
}
PreferenceCategory(title = "Showkase") {
ListItem(
headlineContent = {
Text("Open Showkase browser")
},
onClick = onOpenShowkase
)
}
PreferenceCategory(title = "Crash") {
ListItem(
headlineContent = {
Text("Crash the app 💥")
},
onClick = { error("This crash is a test.") }
)
}
RageshakePreferencesView(
state = state.rageshakeState,
)
PreferenceCategory(title = "Crash") {
ListItem(
headlineContent = {
Text("Crash the app 💥")
},
onClick = { error("This crash is a test.") }
)
}
}
}
@Composable
private fun ElementCallCategory(
state: AppDeveloperSettingsState,
) {
PreferenceCategory(title = "Element Call") {
val callUrlState = state.customElementCallBaseUrlState
val supportingText = if (callUrlState.baseUrl.isNullOrEmpty()) {
stringResource(R.string.screen_advanced_settings_element_call_base_url_description)
} else {
callUrlState.baseUrl
}
PreferenceTextField(
headline = stringResource(R.string.screen_advanced_settings_element_call_base_url),
value = callUrlState.baseUrl,
placeholder = "https://.../room",
supportingText = supportingText,
validation = callUrlState.validator,
onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error),
displayValue = { value -> !value.isNullOrEmpty() },
keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri),
onChange = { state.eventSink(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl(it)) }
)
}
}
@Composable
private fun FeatureListContent(
state: AppDeveloperSettingsState,
) {
fun onFeatureEnabled(feature: FeatureUiModel, isEnabled: Boolean) {
state.eventSink(AppDeveloperSettingsEvent.UpdateEnabledFeature(feature, isEnabled))
}
FeatureListView(
features = state.features,
onCheckedChange = ::onFeatureEnabled,
)
}
@PreviewsDayNight
@Composable
internal fun AppDeveloperSettingsViewPreview(
@PreviewParameter(AppDeveloperSettingsStateProvider::class) state: AppDeveloperSettingsState
) = ElementPreview {
AppDeveloperSettingsView(
state = state,
onOpenShowkase = {},
)
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsPresenter
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@BindingContainer
interface DeveloperSettingsModule {
@Binds
fun bindAppDeveloperSettingsPresenter(presenter: AppDeveloperSettingsPresenter): Presenter<AppDeveloperSettingsState>
}

View file

@ -10,7 +10,7 @@ package io.element.android.features.preferences.impl.root
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PreferencesRootEvents {
data object OnVersionInfoClick : PreferencesRootEvents
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvents
sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
}

View file

@ -30,7 +30,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
@ -99,9 +98,6 @@ class PreferencesRootPresenter(
val accountManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
val devicesManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
var canDeactivateAccount by remember {
mutableStateOf(false)
}
@ -110,9 +106,9 @@ class PreferencesRootPresenter(
canDeactivateAccount = matrixClient.canDeactivateAccount()
}
val showBlockedUsersItem by produceState(initialValue = false) {
val nbOfBlockedUsers by produceState(initialValue = 0) {
matrixClient.ignoredUsersFlow
.onEach { value = it.isNotEmpty() }
.onEach { value = it.size }
.launchIn(this)
}
@ -121,17 +117,17 @@ class PreferencesRootPresenter(
val directLogoutState = directLogoutPresenter.present()
LaunchedEffect(Unit) {
initAccountManagementUrl(accountManagementUrl, devicesManagementUrl)
initAccountManagementUrl(accountManagementUrl)
}
val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState()
fun handleEvent(event: PreferencesRootEvents) {
fun handleEvent(event: PreferencesRootEvent) {
when (event) {
is PreferencesRootEvents.OnVersionInfoClick -> {
is PreferencesRootEvent.OnVersionInfoClick -> {
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
}
is PreferencesRootEvents.SwitchToSession -> coroutineScope.launch {
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
}
@ -146,13 +142,12 @@ class PreferencesRootPresenter(
showSecureBackup = !canVerifyUserSession,
showSecureBackupBadge = showSecureBackupIndicator,
accountManagementUrl = accountManagementUrl.value,
devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
showBlockedUsersItem = showBlockedUsersItem,
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
@ -162,9 +157,7 @@ class PreferencesRootPresenter(
private fun CoroutineScope.initAccountManagementUrl(
accountManagementUrl: MutableState<String?>,
devicesManagementUrl: MutableState<String?>,
) = launch {
accountManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.Profile).getOrNull()
devicesManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList).getOrNull()
accountManagementUrl.value = matrixClient.getAccountManagementUrl(null).getOrNull()
}
}

View file

@ -23,15 +23,16 @@ data class PreferencesRootState(
val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?,
val devicesManagementUrl: String?,
val canReportBug: Boolean,
val showLinkNewDevice: Boolean,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean,
val showBlockedUsersItem: Boolean,
val nbOfBlockedUsers: Int,
val showLabsItem: Boolean,
val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?,
val eventSink: (PreferencesRootEvents) -> Unit,
)
val eventSink: (PreferencesRootEvent) -> Unit,
) {
val showBlockedUsersItem = nbOfBlockedUsers > 0
}

View file

@ -8,36 +8,103 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
open class PreferencesRootStateProvider : PreviewParameterProvider<PreferencesRootState> {
override val values: Sequence<PreferencesRootState>
get() = sequenceOf(
// Nominal state, that a regular user will see if multi account is enabled
aPreferencesRootState(
myUser = aMatrixUser(avatarUrl = "anAvatarUrl"),
version = "Version 1.1 (1)",
deviceId = DeviceId("ILAKNDNASDLK"),
isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(1),
showSecureBackup = true,
accountManagementUrl = "aUrl",
canReportBug = true,
showLinkNewDevice = true,
showAnalyticsSettings = true,
canDeactivateAccount = false,
nbOfBlockedUsers = 3,
showLabsItem = true,
),
aPreferencesRootState(
myUser = aMatrixUser(displayName = null),
isMultiAccountEnabled = true,
showSecureBackup = true,
canDeactivateAccount = true,
),
aPreferencesRootState(
isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(3),
accountManagementUrl = "aUrl",
showSecureBackup = true,
showSecureBackupBadge = true,
),
aPreferencesRootState(
deviceId = DeviceId("ILAKNDNASDLK"),
showLabsItem = true,
canReportBug = true,
nbOfBlockedUsers = 3,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
),
aPreferencesRootState(
showLinkNewDevice = true,
showAnalyticsSettings = true,
showDeveloperSettings = true,
canDeactivateAccount = true,
),
// Minimal state
aPreferencesRootState(),
)
}
fun aPreferencesRootState(
myUser: MatrixUser = aMatrixUser(),
version: String = "Version 1.1 (1)",
deviceId: DeviceId? = null,
isMultiAccountEnabled: Boolean = false,
otherSessions: List<MatrixUser> = emptyList(),
eventSink: (PreferencesRootEvents) -> Unit = { _ -> },
showSecureBackup: Boolean = false,
showSecureBackupBadge: Boolean = false,
accountManagementUrl: String? = null,
canReportBug: Boolean = false,
showLinkNewDevice: Boolean = false,
showAnalyticsSettings: Boolean = false,
showDeveloperSettings: Boolean = false,
canDeactivateAccount: Boolean = false,
nbOfBlockedUsers: Int = 0,
showLabsItem: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
snackbarMessage: SnackbarMessage? = null,
eventSink: (PreferencesRootEvent) -> Unit = {},
) = PreferencesRootState(
myUser = myUser,
version = "Version 1.1 (1)",
deviceId = DeviceId("ILAKNDNASDLK"),
isMultiAccountEnabled = true,
version = version,
deviceId = deviceId,
isMultiAccountEnabled = isMultiAccountEnabled,
otherSessions = otherSessions.toImmutableList(),
showSecureBackup = true,
showSecureBackupBadge = true,
accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,
showLinkNewDevice = true,
canReportBug = true,
showDeveloperSettings = true,
showBlockedUsersItem = true,
showLabsItem = true,
canDeactivateAccount = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
directLogoutState = aDirectLogoutState(),
showSecureBackup = showSecureBackup,
showSecureBackupBadge = showSecureBackupBadge,
accountManagementUrl = accountManagementUrl,
canReportBug = canReportBug,
showLinkNewDevice = showLinkNewDevice,
showAnalyticsSettings = showAnalyticsSettings,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
nbOfBlockedUsers = nbOfBlockedUsers,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
)

View file

@ -9,7 +9,6 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
@ -28,23 +27,20 @@ import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ -82,22 +78,17 @@ fun PreferencesRootView(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
},
user = state.myUser,
matrixUser = state.myUser,
)
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
)
} else {
HorizontalDivider()
}
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// User status will be added here
// 'Account' section
ManageAccountSection(
state = state,
@ -105,6 +96,13 @@ fun PreferencesRootView(
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
@ -118,12 +116,12 @@ fun PreferencesRootView(
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
deviceId = state.deviceId,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvents.OnVersionInfoClick) }
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
}
@ -142,13 +140,15 @@ private fun ColumnScope.MultiAccountSection(
)
state.otherSessions.forEach { matrixUser ->
MatrixUserRow(
modifier = Modifier.clickable {
state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId))
},
modifier = Modifier
.clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId))
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser,
avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp,
)
HorizontalDivider()
}
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())),
@ -198,6 +198,14 @@ private fun ColumnScope.ManageAccountSection(
onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) {
state.accountManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
if (state.showLinkNewDevice) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) },
@ -205,33 +213,15 @@ private fun ColumnScope.ManageAccountSection(
onClick = onLinkNewDeviceClick,
)
}
state.accountManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
state.devicesManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_devices)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())),
onClick = { onManageAccountClick(url) },
)
}
if (state.showBlockedUsersItem) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()),
)
}
if (state.accountManagementUrl != null || state.devicesManagementUrl != null || state.showBlockedUsersItem) {
if (state.accountManagementUrl != null || state.showLinkNewDevice || state.showBlockedUsersItem) {
HorizontalDivider()
}
}
@ -248,6 +238,18 @@ private fun ColumnScope.GeneralSection(
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
onClick = onOpenAdvancedSettings,
)
if (state.showLabsItem) {
ListItem(
headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())),
onClick = onOpenLabs,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_about)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
@ -267,30 +269,17 @@ private fun ColumnScope.GeneralSection(
onClick = onOpenAnalytics,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
onClick = onOpenAdvancedSettings,
)
if (state.showLabsItem) {
ListItem(
headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())),
onClick = onOpenLabs,
)
}
HorizontalDivider()
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())),
style = ListItemStyle.Destructive,
onClick = onSignOutClick,
)
if (state.canDeactivateAccount) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_deactivate_account)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Warning())),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
style = ListItemStyle.Destructive,
onClick = onDeactivateClick,
)
@ -319,9 +308,8 @@ private fun ColumnScope.Footer(
Text(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp)
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 24.dp),
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp),
textAlign = TextAlign.Center,
text = text,
style = ElementTheme.typography.fontBodySmRegular,
@ -340,19 +328,23 @@ private fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) {
@PreviewWithLargeHeight
@Composable
internal fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewLight { ContentToPreview(matrixUser) }
internal fun PreferencesRootViewLightPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) =
ElementPreviewLight(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewDark { ContentToPreview(matrixUser) }
internal fun PreferencesRootViewDarkPreview(@PreviewParameter(PreferencesRootStateProvider::class) state: PreferencesRootState) =
ElementPreviewDark(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(matrixUser: MatrixUser) {
private fun ContentToPreview(state: PreferencesRootState) {
PreferencesRootView(
state = aPreferencesRootState(myUser = matrixUser),
state = state,
onBackClick = {},
onAddAccountClick = {},
onOpenAnalytics = {},
@ -372,16 +364,3 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onDeactivateClick = {},
)
}
@PreviewsDayNight
@Composable
internal fun MultiAccountSectionPreview() = ElementPreview {
Column {
MultiAccountSection(
state = aPreferencesRootState(
otherSessions = aMatrixUserList(),
),
onAddAccountClick = {},
)
}
}

View file

@ -15,21 +15,21 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserHeader
import io.element.android.libraries.matrix.ui.components.MatrixUserWithNullProvider
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
@Composable
fun UserPreferences(
user: MatrixUser?,
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
) {
MatrixUserHeader(
modifier = modifier,
matrixUser = user
matrixUser = matrixUser,
)
}
@PreviewsDayNight
@Composable
internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserWithNullProvider::class) matrixUser: MatrixUser?) = ElementPreview {
internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
UserPreferences(matrixUser)
}

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.MutableState
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.saveable.rememberSaveable
@ -103,6 +104,14 @@ class EditUserProfilePresenter(
}
}
val homeserverCapabilities = matrixClient.homeserverCapabilities()
val canChangeDisplayName = produceState(true) {
value = homeserverCapabilities.canChangeDisplayName().getOrDefault(true)
}
val canChangeAvatar = produceState(true) {
value = homeserverCapabilities.canChangeAvatarUrl().getOrDefault(true)
}
val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope()
@ -169,6 +178,8 @@ class EditUserProfilePresenter(
saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading,
saveAction = saveAction.value,
cameraPermissionState = cameraPermissionState,
canChangeDisplayName = canChangeDisplayName.value,
canChangeAvatarUrl = canChangeAvatar.value,
eventSink = ::handleEvent,
)
}

View file

@ -22,5 +22,7 @@ data class EditUserProfileState(
val saveButtonEnabled: Boolean,
val saveAction: AsyncAction<Unit>,
val cameraPermissionState: PermissionsState,
val canChangeDisplayName: Boolean,
val canChangeAvatarUrl: Boolean,
val eventSink: (EditUserProfileEvent) -> Unit
)

View file

@ -22,6 +22,7 @@ open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfi
aEditUserProfileState(),
aEditUserProfileState(userAvatarUrl = "example://uri"),
aEditUserProfileState(saveAction = AsyncAction.ConfirmingCancellation),
aEditUserProfileState(canChangeAvatarUrl = false, canChangeDisplayName = false),
)
}
@ -33,6 +34,8 @@ fun aEditUserProfileState(
saveButtonEnabled: Boolean = true,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
canChangeDisplayName: Boolean = true,
canChangeAvatarUrl: Boolean = true,
eventSink: (EditUserProfileEvent) -> Unit = {},
) = EditUserProfileState(
userId = userId,
@ -42,5 +45,7 @@ fun aEditUserProfileState(
saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction,
cameraPermissionState = cameraPermissionState,
canChangeDisplayName = canChangeDisplayName,
canChangeAvatarUrl = canChangeAvatarUrl,
eventSink = eventSink,
)

View file

@ -120,6 +120,7 @@ fun EditUserProfileView(
state = avatarPickerState,
onClick = ::onAvatarClick,
modifier = Modifier.align(Alignment.CenterHorizontally),
enabled = state.canChangeAvatarUrl,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
@ -134,6 +135,7 @@ fun EditUserProfileView(
value = state.displayName,
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true,
enabled = state.canChangeDisplayName,
onValueChange = { state.eventSink(EditUserProfileEvent.UpdateDisplayName(it)) },
)
}