Merge branch 'develop' into feature-oled-black
This commit is contained in:
commit
4e5542396f
319 changed files with 8286 additions and 2172 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,27 +14,18 @@ import androidx.compose.ui.graphics.Color
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||
import io.element.android.features.preferences.impl.developer.appsettings.anAppDeveloperSettingsState
|
||||
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
|
||||
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.data.megaBytes
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeature
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
|
||||
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
|
|
@ -51,17 +42,7 @@ class DeveloperSettingsPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - ensures initial states are correct`() = runTest {
|
||||
val getAvailableFeaturesResult = lambdaRecorder<Boolean, Boolean, List<Feature>> { _, _ ->
|
||||
listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
val presenter = createDeveloperSettingsPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult),
|
||||
databaseSizesUseCase = GetDatabaseSizesUseCase {
|
||||
Result.success(
|
||||
SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 10.megaBytes, mediaStore = 10.megaBytes, cryptoStore = 10.megaBytes)
|
||||
|
|
@ -70,22 +51,14 @@ class DeveloperSettingsPresenterTest {
|
|||
)
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).isEmpty()
|
||||
assertThat(state.appDeveloperSettingsState.features).isNotEmpty()
|
||||
assertThat(state.clearCacheAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.customElementCallBaseUrlState).isNotNull()
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
||||
assertThat(state.rageshakeState.isEnabled).isFalse()
|
||||
assertThat(state.rageshakeState.isSupported).isTrue()
|
||||
assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f)
|
||||
assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.isEnterpriseBuild).isFalse()
|
||||
assertThat(state.showColorPicker).isFalse()
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).isNotEmpty()
|
||||
assertThat(state.features).hasSize(1)
|
||||
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO)
|
||||
assertThat(state.cacheSize.isLoading()).isTrue()
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
|
||||
|
|
@ -98,37 +71,6 @@ class DeveloperSettingsPresenterTest {
|
|||
)
|
||||
)
|
||||
}
|
||||
getAvailableFeaturesResult.assertions().isCalledOnce()
|
||||
.with(value(false), value(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest {
|
||||
val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay")
|
||||
val presenter = createDeveloperSettingsPresenter(buildMeta = buildMeta)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
|
||||
val presenter = createDeveloperSettingsPresenter()
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
val feature = state.features.first { !it.isEnabled }
|
||||
state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, !feature.isEnabled))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
val feature = state.features.first()
|
||||
assertThat(feature.isEnabled).isTrue()
|
||||
assertThat(feature.key).isEqualTo(feature.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,52 +100,6 @@ class DeveloperSettingsPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - custom element call base url`() = runTest {
|
||||
val preferencesStore = InMemoryAppPreferencesStore()
|
||||
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferencesStore)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
||||
state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest {
|
||||
val presenter = createDeveloperSettingsPresenter()
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
val urlValidator = awaitItem().customElementCallBaseUrlState.validator
|
||||
assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one
|
||||
assertThat(urlValidator("test")).isFalse()
|
||||
assertThat(urlValidator("http://")).isFalse()
|
||||
assertThat(urlValidator("geo://test")).isFalse()
|
||||
assertThat(urlValidator("https://call.element.io")).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - changing tracing log level`() = runTest {
|
||||
val preferences = InMemoryAppPreferencesStore()
|
||||
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO)
|
||||
state.eventSink(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.TRACE))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.TRACE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - enterprise build can change the brand color`() = runTest {
|
||||
val overrideBrandColorResult = lambdaRecorder<SessionId?, String?, Unit> { _, _ -> }
|
||||
|
|
@ -250,33 +146,17 @@ class DeveloperSettingsPresenterTest {
|
|||
|
||||
private fun createDeveloperSettingsPresenter(
|
||||
sessionId: SessionId = A_SESSION_ID,
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
|
||||
getAvailableFeaturesResult = { _, _ ->
|
||||
listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
|
||||
clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(),
|
||||
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||
vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {},
|
||||
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
|
||||
): DeveloperSettingsPresenter {
|
||||
return DeveloperSettingsPresenter(
|
||||
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
|
||||
sessionId = sessionId,
|
||||
featureFlagService = featureFlagService,
|
||||
computeCacheSizeUseCase = cacheSizeUseCase,
|
||||
clearCacheUseCase = clearCacheUseCase,
|
||||
rageshakePresenter = { aRageshakePreferencesState() },
|
||||
appPreferencesStore = preferencesStore,
|
||||
buildMeta = buildMeta,
|
||||
enterpriseService = enterpriseService,
|
||||
vacuumStoresUseCase = vacuumStoresUseCase,
|
||||
databaseSizesUseCase = databaseSizesUseCase,
|
||||
|
|
|
|||
|
|
@ -9,20 +9,12 @@
|
|||
package io.element.android.features.preferences.impl.developer
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.isEditable
|
||||
import androidx.compose.ui.test.isFocusable
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
|
|
@ -53,7 +45,7 @@ class DeveloperSettingsViewTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1500dp")
|
||||
@Config(qualifiers = "h2000dp")
|
||||
@Test
|
||||
fun `clicking on push history notification invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false)
|
||||
|
|
@ -68,22 +60,6 @@ class DeveloperSettingsViewTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1500dp")
|
||||
@Test
|
||||
fun `clicking on element call url open the dialogs and submit emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
|
||||
rule.setDeveloperSettingsView(
|
||||
state = aDeveloperSettingsState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_advanced_settings_element_call_base_url)
|
||||
val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog()))
|
||||
textInputNode.performTextInput("https://call.element.dev")
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev"))
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h2000dp")
|
||||
@Test
|
||||
fun `clicking on open showkase invokes the expected callback`() {
|
||||
|
|
@ -99,20 +75,6 @@ class DeveloperSettingsViewTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on log level emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
|
||||
rule.setDeveloperSettingsView(
|
||||
state = aDeveloperSettingsState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Tracing log level").performClick()
|
||||
rule.onNodeWithText("Debug").performClick()
|
||||
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.DEBUG))
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h2200dp")
|
||||
@Test
|
||||
fun `clicking on clear cache emits the expected event`() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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.ComponentActivity
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.isEditable
|
||||
import androidx.compose.ui.test.isFocusable
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AppDeveloperSettingsPageTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on back invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setAppDeveloperSettingsView(
|
||||
state = anAppDeveloperSettingsState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onBackClick = it
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1500dp")
|
||||
@Test
|
||||
fun `clicking on element call url open the dialogs and submit emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>()
|
||||
rule.setAppDeveloperSettingsView(
|
||||
state = anAppDeveloperSettingsState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(R.string.screen_advanced_settings_element_call_base_url)
|
||||
val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog()))
|
||||
textInputNode.performTextInput("https://call.element.dev")
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.dev"))
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h2000dp")
|
||||
@Test
|
||||
fun `clicking on open showkase invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setAppDeveloperSettingsView(
|
||||
state = anAppDeveloperSettingsState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onOpenShowkase = it
|
||||
)
|
||||
rule.onNodeWithText("Open Showkase browser").performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on log level emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>()
|
||||
rule.setAppDeveloperSettingsView(
|
||||
state = anAppDeveloperSettingsState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Tracing log level").performClick()
|
||||
rule.onNodeWithText("Debug").performClick()
|
||||
eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetTracingLogLevel(LogLevelItem.DEBUG))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAppDeveloperSettingsView(
|
||||
state: AppDeveloperSettingsState,
|
||||
onOpenShowkase: () -> Unit = EnsureNeverCalled(),
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
AppDeveloperSettingsPage(
|
||||
state = state,
|
||||
onOpenShowkase = onOpenShowkase,
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.preferences.impl.developer.appsettings
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeature
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AppDeveloperSettingsPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - ensures initial states are correct`() = runTest {
|
||||
val getAvailableFeaturesResult = lambdaRecorder<Boolean, Boolean, List<Feature>> { _, _ ->
|
||||
listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
val presenter = createAppDeveloperSettingsPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult),
|
||||
)
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).isEmpty()
|
||||
assertThat(state.customElementCallBaseUrlState).isNotNull()
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
||||
assertThat(state.rageshakeState.isEnabled).isFalse()
|
||||
assertThat(state.rageshakeState.isSupported).isTrue()
|
||||
assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f)
|
||||
assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).isNotEmpty()
|
||||
assertThat(state.features).hasSize(1)
|
||||
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO)
|
||||
}
|
||||
getAvailableFeaturesResult.assertions().isCalledOnce()
|
||||
.with(value(false), value(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest {
|
||||
val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay")
|
||||
val presenter = createAppDeveloperSettingsPresenter(buildMeta = buildMeta)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
|
||||
val presenter = createAppDeveloperSettingsPresenter()
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
val feature = state.features.first { !it.isEnabled }
|
||||
state.eventSink(AppDeveloperSettingsEvent.UpdateEnabledFeature(feature, !feature.isEnabled))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
val feature = state.features.first()
|
||||
assertThat(feature.isEnabled).isTrue()
|
||||
assertThat(feature.key).isEqualTo(feature.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - custom element call base url`() = runTest {
|
||||
val preferencesStore = InMemoryAppPreferencesStore()
|
||||
val presenter = createAppDeveloperSettingsPresenter(preferencesStore = preferencesStore)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
||||
state.eventSink(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest {
|
||||
val presenter = createAppDeveloperSettingsPresenter()
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
val urlValidator = awaitItem().customElementCallBaseUrlState.validator
|
||||
assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one
|
||||
assertThat(urlValidator("test")).isFalse()
|
||||
assertThat(urlValidator("http://")).isFalse()
|
||||
assertThat(urlValidator("geo://test")).isFalse()
|
||||
assertThat(urlValidator("https://call.element.io")).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - changing tracing log level`() = runTest {
|
||||
val preferences = InMemoryAppPreferencesStore()
|
||||
val presenter = createAppDeveloperSettingsPresenter(preferencesStore = preferences)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO)
|
||||
state.eventSink(AppDeveloperSettingsEvent.SetTracingLogLevel(LogLevelItem.TRACE))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.TRACE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAppDeveloperSettingsPresenter(
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
|
||||
getAvailableFeaturesResult = { _, _ ->
|
||||
listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
): AppDeveloperSettingsPresenter {
|
||||
return AppDeveloperSettingsPresenter(
|
||||
featureFlagService = featureFlagService,
|
||||
rageshakePresenter = { aRageshakePreferencesState() },
|
||||
appPreferencesStore = preferencesStore,
|
||||
buildMeta = buildMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
|
|||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
|
|
@ -40,7 +42,9 @@ import io.element.android.tests.testutils.WarmUpRule
|
|||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
|
|
@ -73,6 +77,7 @@ class PreferencesRootPresenterTest {
|
|||
assertThat(initialState.version).isEqualTo("A Version")
|
||||
assertThat(initialState.isMultiAccountEnabled).isFalse()
|
||||
assertThat(initialState.otherSessions).isEmpty()
|
||||
assertThat(initialState.version).isEqualTo("A Version")
|
||||
val loadedState = awaitItem()
|
||||
assertThat(loadedState.myUser).isEqualTo(
|
||||
MatrixUser(
|
||||
|
|
@ -81,27 +86,21 @@ class PreferencesRootPresenterTest {
|
|||
avatarUrl = AN_AVATAR_URL
|
||||
)
|
||||
)
|
||||
assertThat(initialState.version).isEqualTo("A Version")
|
||||
assertThat(loadedState.showSecureBackup).isFalse()
|
||||
assertThat(loadedState.showSecureBackupBadge).isFalse()
|
||||
assertThat(loadedState.accountManagementUrl).isNull()
|
||||
assertThat(loadedState.devicesManagementUrl).isNull()
|
||||
assertThat(loadedState.showAnalyticsSettings).isFalse()
|
||||
assertThat(loadedState.showLinkNewDevice).isFalse()
|
||||
assertThat(loadedState.showDeveloperSettings).isTrue()
|
||||
assertThat(loadedState.canDeactivateAccount).isTrue()
|
||||
assertThat(loadedState.canReportBug).isTrue()
|
||||
assertThat(loadedState.nbOfBlockedUsers).isEqualTo(0)
|
||||
assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState())
|
||||
assertThat(loadedState.snackbarMessage).isNull()
|
||||
skipItems(1)
|
||||
val finalState = awaitItem()
|
||||
accountManagementUrlResult.assertions().isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value(AccountManagementAction.Profile)),
|
||||
listOf(value(AccountManagementAction.DevicesList)),
|
||||
)
|
||||
assertThat(finalState.accountManagementUrl).isEqualTo("Profile url")
|
||||
assertThat(finalState.devicesManagementUrl).isEqualTo("DevicesList url")
|
||||
accountManagementUrlResult.assertions().isCalledOnce()
|
||||
.with(value(null))
|
||||
assertThat(finalState.accountManagementUrl).isEqualTo("null url")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +120,22 @@ class PreferencesRootPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - number of blocked users`() = runTest {
|
||||
val matrixClient = FakeMatrixClient(
|
||||
canDeactivateAccountResult = { true },
|
||||
accountManagementUrlResult = { Result.success("") },
|
||||
ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)),
|
||||
)
|
||||
createPresenter(
|
||||
matrixClient = matrixClient,
|
||||
).test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.nbOfBlockedUsers).isEqualTo(2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - secure backup badge`() = runTest {
|
||||
val matrixClient = FakeMatrixClient(
|
||||
|
|
@ -181,12 +196,36 @@ class PreferencesRootPresenterTest {
|
|||
val loadedState = awaitFirstItem()
|
||||
repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) {
|
||||
assertThat(loadedState.showDeveloperSettings).isFalse()
|
||||
loadedState.eventSink(PreferencesRootEvents.OnVersionInfoClick)
|
||||
loadedState.eventSink(PreferencesRootEvent.OnVersionInfoClick)
|
||||
}
|
||||
assertThat(awaitItem().showDeveloperSettings).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - switch session invoke method on the session store`() = runTest {
|
||||
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
|
||||
val sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData(sessionId = A_SESSION_ID.value),
|
||||
aSessionData(sessionId = A_SESSION_ID_2.value),
|
||||
),
|
||||
setLatestSessionResult = setLatestSessionResult,
|
||||
)
|
||||
createPresenter(
|
||||
matrixClient = FakeMatrixClient(
|
||||
canDeactivateAccountResult = { true },
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
sessionStore = sessionStore,
|
||||
).test {
|
||||
val loadedState = awaitFirstItem()
|
||||
loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2))
|
||||
setLatestSessionResult.assertions().isCalledOnce()
|
||||
.with(value(A_SESSION_ID_2.value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
|
||||
createPresenter(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,483 @@
|
|||
/*
|
||||
* 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.root
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PreferencesRootViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on back invokes back callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onBackClick = callback,
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on User profile invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
val user = aMatrixUser()
|
||||
ensureCalledOnceWithParam(user) { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
myUser = user,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenUserProfile = callback,
|
||||
)
|
||||
rule.onNodeWithText("Alice").performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on other session sends a SwitchToSession`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
isMultiAccountEnabled = true,
|
||||
otherSessions = listOf(
|
||||
aMatrixUser(
|
||||
id = A_USER_ID_2.value,
|
||||
displayName = "Bob",
|
||||
)
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Bob").performClick()
|
||||
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Add account invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
isMultiAccountEnabled = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onAddAccountClick = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_add_another_account)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when multi account is not enabled, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
isMultiAccountEnabled = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_add_another_account)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Encryption invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showSecureBackup = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onSecureBackupClick = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_encryption)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when showSecureBackup is false, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showSecureBackup = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_encryption)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Manage account invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnceWithParam("aUrl") { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
accountManagementUrl = "aUrl",
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onManageAccountClick = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_manage_account_and_devices)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when accountManagementUrl is null, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
accountManagementUrl = null,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Link new devices invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showLinkNewDevice = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onLinkNewDeviceClick = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_link_new_device)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when showLinkNewDevice is false, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showLinkNewDevice = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_link_new_device)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Analytics invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showAnalyticsSettings = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenAnalytics = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_analytics)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when showAnalyticsSettings is false, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showAnalyticsSettings = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_analytics)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Report a problem invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
canReportBug = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenRageShake = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_report_a_problem)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when canReportBug is false, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
canReportBug = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Screen lock invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenLockScreenSettings = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_screen_lock)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on About invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenAbout = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_about)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Developer settings invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showDeveloperSettings = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenDeveloperSettings = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_developer_options)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when showDeveloperSettings is false, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showDeveloperSettings = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_developer_options)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Advanced settings invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenAdvancedSettings = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_advanced_settings)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Labs invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showLabsItem = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenLabs = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_labs_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when showLabsItem is false, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
showLabsItem = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(R.string.screen_labs_title)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Notification invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenNotificationSettings = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_notification_settings_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Blocked users invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
nbOfBlockedUsers = 1,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onOpenBlockedUsers = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.common_blocked_users)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when nbOfBlockedUsers is 0, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
nbOfBlockedUsers = 0,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_blocked_users)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Remove this device invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onSignOutClick = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_signout)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on Deactivate invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
canDeactivateAccount = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onDeactivateClick = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_deactivate_account)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when canDeactivateAccount is false, item is not shown`() {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
canDeactivateAccount = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_deactivate_account)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on version sends a PreferencesRootEvents`() {
|
||||
val version = "VERSION"
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
|
||||
rule.setView(
|
||||
aPreferencesRootState(
|
||||
version = version,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(version).performClick()
|
||||
eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView(
|
||||
state: PreferencesRootState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
|
||||
onSecureBackupClick: () -> Unit = EnsureNeverCalled(),
|
||||
onManageAccountClick: (url: String) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkNewDeviceClick: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenAnalytics: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenRageShake: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenAbout: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenDeveloperSettings: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenAdvancedSettings: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenLabs: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenNotificationSettings: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenUserProfile: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onOpenBlockedUsers: () -> Unit = EnsureNeverCalled(),
|
||||
onSignOutClick: () -> Unit = EnsureNeverCalled(),
|
||||
onDeactivateClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
PreferencesRootView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
onSecureBackupClick = onSecureBackupClick,
|
||||
onManageAccountClick = onManageAccountClick,
|
||||
onLinkNewDeviceClick = onLinkNewDeviceClick,
|
||||
onOpenAnalytics = onOpenAnalytics,
|
||||
onOpenRageShake = onOpenRageShake,
|
||||
onOpenLockScreenSettings = onOpenLockScreenSettings,
|
||||
onOpenAbout = onOpenAbout,
|
||||
onOpenDeveloperSettings = onOpenDeveloperSettings,
|
||||
onOpenAdvancedSettings = onOpenAdvancedSettings,
|
||||
onOpenLabs = onOpenLabs,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onOpenUserProfile = onOpenUserProfile,
|
||||
onOpenBlockedUsers = onOpenBlockedUsers,
|
||||
onSignOutClick = onSignOutClick,
|
||||
onDeactivateClick = onDeactivateClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue