Merge pull request #6587 from element-hq/feature/bma/flagsFromOnBoarding

Split developer settings into 2 screens to be able to access global settings when no logged in.
This commit is contained in:
Benoit Marty 2026-04-15 16:16:23 +02:00 committed by GitHub
commit 9699488ae5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1035 additions and 469 deletions

View file

@ -55,6 +55,7 @@ setupDependencyInjection()
dependencies { dependencies {
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.features.preferences.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
@ -79,6 +80,7 @@ dependencies {
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.login.test) testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test) testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.preferences.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test) testImplementation(projects.libraries.oidc.test)

View file

@ -41,6 +41,7 @@ import io.element.android.features.login.impl.screens.createaccount.CreateAccoun
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
@ -67,6 +68,7 @@ class LoginFlowNode(
@AppCoroutineScope @AppCoroutineScope
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val elementClassicConnection: ElementClassicConnection, private val elementClassicConnection: ElementClassicConnection,
private val preferencesEntryPoint: PreferencesEntryPoint,
) : BaseFlowNode<LoginFlowNode.NavTarget>( ) : BaseFlowNode<LoginFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.CheckClassicFlow, initialElement = NavTarget.CheckClassicFlow,
@ -117,6 +119,9 @@ class LoginFlowNode(
@Parcelize @Parcelize
data object QrCode : NavTarget data object QrCode : NavTarget
@Parcelize
data object AppDeveloperSettings : NavTarget
@Parcelize @Parcelize
data class ConfirmAccountProvider( data class ConfirmAccountProvider(
val isAccountCreation: Boolean, val isAccountCreation: Boolean,
@ -200,6 +205,10 @@ class LoginFlowNode(
backstack.push(NavTarget.CreateAccount(url)) backstack.push(NavTarget.CreateAccount(url))
} }
override fun navigateToDeveloperSettings() {
backstack.push(NavTarget.AppDeveloperSettings)
}
override fun navigateToLoginPassword() { override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword()) backstack.push(NavTarget.LoginPassword())
} }
@ -220,6 +229,18 @@ class LoginFlowNode(
) )
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs)) createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
} }
NavTarget.AppDeveloperSettings -> {
val callback = object : PreferencesEntryPoint.DeveloperSettingsCallback {
override fun onDone() {
backstack.pop()
}
}
preferencesEntryPoint.createAppDeveloperSettingsNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
NavTarget.ChooseAccountProvider -> { NavTarget.ChooseAccountProvider -> {
val callback = object : ChooseAccountProviderNode.Callback { val callback = object : ChooseAccountProviderNode.Callback {
override fun navigateToOidc(oidcDetails: OidcDetails) { override fun navigateToOidc(oidcDetails: OidcDetails) {

View file

@ -42,6 +42,7 @@ class OnBoardingNode(
fun navigateToLoginPassword() fun navigateToLoginPassword()
fun navigateToOidc(oidcDetails: OidcDetails) fun navigateToOidc(oidcDetails: OidcDetails)
fun navigateToCreateAccount(url: String) fun navigateToCreateAccount(url: String)
fun navigateToDeveloperSettings()
fun onDone() fun onDone()
} }
@ -75,6 +76,7 @@ class OnBoardingNode(
onLearnMoreClick = { openLearnMorePage(context) }, onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount, onCreateAccountContinue = callback::navigateToCreateAccount,
onBackClick = callback::onDone, onBackClick = callback::onDone,
onDeveloperSettingsClick = callback::navigateToDeveloperSettings,
) )
} }
} }

View file

@ -29,6 +29,7 @@ import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.utils.MultipleTapToUnlock import io.element.android.libraries.ui.utils.MultipleTapToUnlock
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -125,6 +126,7 @@ class OnBoardingPresenter(
return OnBoardingState( return OnBoardingState(
isAddingAccount = isAddingAccount, isAddingAccount = isAddingAccount,
showBackButton = params.showBackButton, showBackButton = params.showBackButton,
showDeveloperSettings = buildMeta.buildType != BuildType.RELEASE,
productionApplicationName = buildMeta.productionApplicationName, productionApplicationName = buildMeta.productionApplicationName,
defaultAccountProvider = defaultAccountProvider, defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider,

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState( data class OnBoardingState(
val isAddingAccount: Boolean, val isAddingAccount: Boolean,
val showBackButton: Boolean, val showBackButton: Boolean,
val showDeveloperSettings: Boolean,
val productionApplicationName: String, val productionApplicationName: String,
val defaultAccountProvider: String?, val defaultAccountProvider: String?,
val mustChooseAccountProvider: Boolean, val mustChooseAccountProvider: Boolean,

View file

@ -31,6 +31,7 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
), ),
anOnBoardingState( anOnBoardingState(
showBackButton = true, showBackButton = true,
showDeveloperSettings = true,
), ),
) )
} }
@ -38,6 +39,7 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
fun anOnBoardingState( fun anOnBoardingState(
isAddingAccount: Boolean = false, isAddingAccount: Boolean = false,
showBackButton: Boolean = false, showBackButton: Boolean = false,
showDeveloperSettings: Boolean = false,
productionApplicationName: String = "Element", productionApplicationName: String = "Element",
defaultAccountProvider: String? = null, defaultAccountProvider: String? = null,
mustChooseAccountProvider: Boolean = false, mustChooseAccountProvider: Boolean = false,
@ -52,6 +54,7 @@ fun anOnBoardingState(
) = OnBoardingState( ) = OnBoardingState(
isAddingAccount = isAddingAccount, isAddingAccount = isAddingAccount,
showBackButton = showBackButton, showBackButton = showBackButton,
showDeveloperSettings = showDeveloperSettings,
productionApplicationName = productionApplicationName, productionApplicationName = productionApplicationName,
defaultAccountProvider = defaultAccountProvider, defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider,

View file

@ -64,6 +64,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun OnBoardingView( fun OnBoardingView(
state: OnBoardingState, state: OnBoardingState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onDeveloperSettingsClick: () -> Unit,
onSignInWithQrCode: () -> Unit, onSignInWithQrCode: () -> Unit,
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit, onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
onCreateAccount: () -> Unit, onCreateAccount: () -> Unit,
@ -110,6 +111,7 @@ fun OnBoardingView(
loginView = loginView, loginView = loginView,
buttons = buttons, buttons = buttons,
onBackClick = onBackClick, onBackClick = onBackClick,
onDeveloperSettingsClick = onDeveloperSettingsClick,
) )
} }
} }
@ -120,6 +122,7 @@ private fun AddFirstAccountScaffold(
loginView: @Composable () -> Unit, loginView: @Composable () -> Unit,
buttons: @Composable () -> Unit, buttons: @Composable () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onDeveloperSettingsClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
OnBoardingPage( OnBoardingPage(
@ -136,6 +139,18 @@ private fun AddFirstAccountScaffold(
} else { } else {
OnBoardingContent(state = state) OnBoardingContent(state = state)
} }
if (state.showDeveloperSettings) {
IconButton(
onClick = onDeveloperSettingsClick,
modifier = Modifier
.align(Alignment.TopStart),
) {
Icon(
imageVector = CompoundIcons.SettingsSolid(),
contentDescription = stringResource(CommonStrings.common_developer_options),
)
}
}
if (state.showBackButton) { if (state.showBackButton) {
// Add icon button to "navigate back" // Add icon button to "navigate back"
IconButton( IconButton(
@ -334,6 +349,7 @@ internal fun OnBoardingViewPreview(
OnBoardingView( OnBoardingView(
state = state, state = state,
onBackClick = {}, onBackClick = {},
onDeveloperSettingsClick = {},
onSignInWithQrCode = {}, onSignInWithQrCode = {},
onSignIn = {}, onSignIn = {},
onCreateAccount = {}, onCreateAccount = {},

View file

@ -16,6 +16,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.FakeElementClassicConnection import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.features.preferences.test.FakePreferencesEntryPoint
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@ -41,6 +42,7 @@ class DefaultLoginEntryPointTest {
oidcActionFlow = FakeOidcActionFlow(), oidcActionFlow = FakeOidcActionFlow(),
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
elementClassicConnection = FakeElementClassicConnection(), elementClassicConnection = FakeElementClassicConnection(),
preferencesEntryPoint = FakePreferencesEntryPoint(),
) )
} }
val callback = object : LoginEntryPoint.Callback { val callback = object : LoginEntryPoint.Callback {

View file

@ -11,6 +11,7 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import com.google.testing.junit.testparameterinjector.KotlinTestParameters.namedTestValues import com.google.testing.junit.testparameterinjector.KotlinTestParameters.namedTestValues
@ -46,11 +47,15 @@ class OnboardingViewTest {
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canCreateAccount = true, canCreateAccount = true,
showDeveloperSettings = false,
eventSink = eventSink, eventSink = eventSink,
), ),
onCreateAccount = callback, onCreateAccount = callback,
) )
rule.clickOn(R.string.screen_onboarding_sign_up) rule.clickOn(R.string.screen_onboarding_sign_up)
// Developer settings should not be shown
val developerSettingsText = rule.activity.getString(CommonStrings.common_developer_options)
rule.onNodeWithContentDescription(developerSettingsText).assertDoesNotExist()
} }
} }
@ -172,6 +177,22 @@ class OnboardingViewTest {
} }
} }
@Test
fun `clicking on settings calls the developer settings callback`() {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(
showDeveloperSettings = true,
eventSink = eventSink,
),
onDeveloperSettingsClick = callback,
)
val text = rule.activity.getString(CommonStrings.common_developer_options)
rule.onNodeWithContentDescription(text).performClick()
}
}
@Test @Test
fun `cannot report a problem when the feature is disabled`() { fun `cannot report a problem when the feature is disabled`() {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false) val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
@ -235,6 +256,7 @@ class OnboardingViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
state: OnBoardingState, state: OnBoardingState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onDeveloperSettingsClick: () -> Unit = EnsureNeverCalled(),
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(), onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(), onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
onCreateAccount: () -> Unit = EnsureNeverCalled(), onCreateAccount: () -> Unit = EnsureNeverCalled(),
@ -248,6 +270,7 @@ class OnboardingViewTest {
OnBoardingView( OnBoardingView(
state = state, state = state,
onBackClick = onBackClick, onBackClick = onBackClick,
onDeveloperSettingsClick = onDeveloperSettingsClick,
onSignInWithQrCode = onSignInWithQrCode, onSignInWithQrCode = onSignInWithQrCode,
onSignIn = onSignIn, onSignIn = onSignIn,
onCreateAccount = onCreateAccount, onCreateAccount = onCreateAccount,

View file

@ -50,4 +50,14 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
fun navigateToRoomNotificationSettings(roomId: RoomId) fun navigateToRoomNotificationSettings(roomId: RoomId)
fun navigateToEvent(roomId: RoomId, eventId: EventId) fun navigateToEvent(roomId: RoomId, eventId: EventId)
} }
fun createAppDeveloperSettingsNode(
parentNode: Node,
buildContext: BuildContext,
callback: DeveloperSettingsCallback,
): Node
interface DeveloperSettingsCallback : Plugin {
fun onDone()
}
} }

View file

@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.preferences.api.PreferencesEntryPoint 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 import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@ -28,6 +29,17 @@ class DefaultPreferencesEntryPoint : PreferencesEntryPoint {
plugins = listOf(params, callback) 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) { internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) {

View file

@ -9,15 +9,8 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import androidx.compose.ui.graphics.Color 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 { 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 SetShowColorPicker(val show: Boolean) : DeveloperSettingsEvents
data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents

View file

@ -11,61 +11,37 @@ package io.element.android.features.preferences.impl.developer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.EnterpriseService 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.appsettings.AppDeveloperSettingsState
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.tasks.ClearCacheUseCase 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.ComputeCacheSizeUseCase
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase 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.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.data.ByteUnit 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.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.SessionId 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.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URL
@Inject @Inject
class DeveloperSettingsPresenter( class DeveloperSettingsPresenter(
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
private val sessionId: SessionId, private val sessionId: SessionId,
private val featureFlagService: FeatureFlagService,
private val computeCacheSizeUseCase: ComputeCacheSizeUseCase, private val computeCacheSizeUseCase: ComputeCacheSizeUseCase,
private val clearCacheUseCase: ClearCacheUseCase, private val clearCacheUseCase: ClearCacheUseCase,
private val rageshakePresenter: Presenter<RageshakePreferencesState>,
private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta,
private val enterpriseService: EnterpriseService, private val enterpriseService: EnterpriseService,
private val vacuumStoresUseCase: VacuumStoresUseCase, private val vacuumStoresUseCase: VacuumStoresUseCase,
private val databaseSizesUseCase: GetDatabaseSizesUseCase, private val databaseSizesUseCase: GetDatabaseSizesUseCase,
@ -73,10 +49,6 @@ class DeveloperSettingsPresenter(
) : Presenter<DeveloperSettingsState> { ) : Presenter<DeveloperSettingsState> {
@Composable @Composable
override fun present(): DeveloperSettingsState { override fun present(): DeveloperSettingsState {
val rageshakeState = rageshakePresenter.present()
val enabledFeatures = remember {
mutableStateListOf<EnabledFeature>()
}
val cacheSize = remember { val cacheSize = remember {
mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized) mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized)
} }
@ -89,38 +61,9 @@ class DeveloperSettingsPresenter(
var showColorPicker by remember { var showColorPicker by remember {
mutableStateOf(false) 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) { LaunchedEffect(Unit) {
computeDatabaseSizes(databaseSizes) 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() val coroutineScope = rememberCoroutineScope()
// Compute cache size each time the clear cache action value is changed // Compute cache size each time the clear cache action value is changed
LaunchedEffect(clearCacheAction.value.isSuccess()) { LaunchedEffect(clearCacheAction.value.isSuccess()) {
@ -129,29 +72,7 @@ class DeveloperSettingsPresenter(
fun handleEvent(event: DeveloperSettingsEvents) { fun handleEvent(event: DeveloperSettingsEvents) {
when (event) { 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) 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 { is DeveloperSettingsEvents.ChangeBrandColor -> coroutineScope.launch {
showColorPicker = false showColorPicker = false
val color = event.color val color = event.color
@ -170,56 +91,18 @@ class DeveloperSettingsPresenter(
} }
} }
val appDeveloperSettingsState = appDeveloperSettingsPresenter.present()
return DeveloperSettingsState( return DeveloperSettingsState(
features = featureUiModels, appDeveloperSettingsState = appDeveloperSettingsState,
cacheSize = cacheSize.value, cacheSize = cacheSize.value,
databaseSizes = databaseSizes.value, databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value, clearCacheAction = clearCacheAction.value,
rageshakeState = rageshakeState,
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
baseUrl = customElementCallBaseUrl,
validator = ::customElementCallUrlValidator,
),
tracingLogLevel = tracingLogLevel,
tracingLogPacks = tracingLogPacks,
isEnterpriseBuild = enterpriseService.isEnterpriseBuild, isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
showColorPicker = showColorPicker, showColorPicker = showColorPicker,
eventSink = ::handleEvent, 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 { private fun CoroutineScope.computeCacheSize(cacheSize: MutableState<AsyncData<String>>) = launch {
suspend { suspend {
computeCacheSizeUseCase() computeCacheSizeUseCase()
@ -253,12 +136,3 @@ class DeveloperSettingsPresenter(
}.runCatchingUpdatingState(clearCacheAction) }.runCatchingUpdatingState(clearCacheAction)
} }
} }
private fun customElementCallUrlValidator(url: String?): Boolean {
return runCatchingExceptions {
if (url.isNullOrEmpty()) return@runCatchingExceptions
val parsedUrl = URL(url)
if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol")
if (parsedUrl.host.isNullOrBlank()) error("Missing host")
}.isSuccess
}

View file

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

View file

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

View file

@ -10,40 +10,28 @@ package io.element.android.features.preferences.impl.developer
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent 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.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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text 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.element.android.libraries.ui.strings.CommonStrings
import io.mhssn.colorpicker.ColorPickerDialog import io.mhssn.colorpicker.ColorPickerDialog
import io.mhssn.colorpicker.ColorPickerType import io.mhssn.colorpicker.ColorPickerType
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
@ -71,52 +59,12 @@ fun DeveloperSettingsView(
title = stringResource(id = CommonStrings.common_developer_options) title = stringResource(id = CommonStrings.common_developer_options)
) { ) {
// Note: this is OK to hardcode strings in this debug screen. // Note: this is OK to hardcode strings in this debug screen.
PreferenceCategory( AppDeveloperSettingsView(
title = "Feature flags", state = state.appDeveloperSettingsState,
) { onOpenShowkase = onOpenShowkase,
FeatureListContent(state) )
}
NotificationCategory(onPushHistoryClick) 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,
)
if (state.isEnterpriseBuild) { if (state.isEnterpriseBuild) {
PreferenceCategory(title = "Theme") { PreferenceCategory(title = "Theme") {
ListItem( 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 val cache = state.cacheSize
PreferenceCategory(title = "Cache") { PreferenceCategory(title = "Cache") {
ListItem( 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 @Composable
private fun NotificationCategory(onPushHistoryClick: () -> Unit) { private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) { 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 @PreviewsDayNight
@Composable @Composable
internal fun DeveloperSettingsViewPreview( internal fun DeveloperSettingsViewPreview(
@ -273,6 +173,6 @@ internal fun DeveloperSettingsViewPreview(
state = state, state = state,
onOpenShowkase = {}, onOpenShowkase = {},
onPushHistoryClick = {}, onPushHistoryClick = {},
onBackClick = {} onBackClick = {},
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,27 +14,18 @@ import androidx.compose.ui.graphics.Color
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService 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.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase 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.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.data.megaBytes 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.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.SessionId 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.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.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@ -51,17 +42,7 @@ class DeveloperSettingsPresenterTest {
@Test @Test
fun `present - ensures initial states are correct`() = runTest { 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( val presenter = createDeveloperSettingsPresenter(
featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult),
databaseSizesUseCase = GetDatabaseSizesUseCase { databaseSizesUseCase = GetDatabaseSizesUseCase {
Result.success( Result.success(
SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 10.megaBytes, mediaStore = 10.megaBytes, cryptoStore = 10.megaBytes) SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 10.megaBytes, mediaStore = 10.megaBytes, cryptoStore = 10.megaBytes)
@ -70,22 +51,14 @@ class DeveloperSettingsPresenterTest {
) )
presenter.test { presenter.test {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.features).isEmpty() assertThat(state.appDeveloperSettingsState.features).isNotEmpty()
assertThat(state.clearCacheAction).isEqualTo(AsyncAction.Uninitialized) assertThat(state.clearCacheAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(state.cacheSize).isEqualTo(AsyncData.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.isEnterpriseBuild).isFalse()
assertThat(state.showColorPicker).isFalse() assertThat(state.showColorPicker).isFalse()
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.features).isNotEmpty() assertThat(state.cacheSize.isLoading()).isTrue()
assertThat(state.features).hasSize(1)
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java) 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 @Test
fun `present - enterprise build can change the brand color`() = runTest { fun `present - enterprise build can change the brand color`() = runTest {
val overrideBrandColorResult = lambdaRecorder<SessionId?, String?, Unit> { _, _ -> } val overrideBrandColorResult = lambdaRecorder<SessionId?, String?, Unit> { _, _ -> }
@ -250,33 +146,17 @@ class DeveloperSettingsPresenterTest {
private fun createDeveloperSettingsPresenter( private fun createDeveloperSettingsPresenter(
sessionId: SessionId = A_SESSION_ID, sessionId: SessionId = A_SESSION_ID,
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
getAvailableFeaturesResult = { _, _ ->
listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = false,
)
)
}
),
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(), cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(),
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
buildMeta: BuildMeta = aBuildMeta(),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {}, vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {},
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
): DeveloperSettingsPresenter { ): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter( return DeveloperSettingsPresenter(
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
sessionId = sessionId, sessionId = sessionId,
featureFlagService = featureFlagService,
computeCacheSizeUseCase = cacheSizeUseCase, computeCacheSizeUseCase = cacheSizeUseCase,
clearCacheUseCase = clearCacheUseCase, clearCacheUseCase = clearCacheUseCase,
rageshakePresenter = { aRageshakePreferencesState() },
appPreferencesStore = preferencesStore,
buildMeta = buildMeta,
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
vacuumStoresUseCase = vacuumStoresUseCase, vacuumStoresUseCase = vacuumStoresUseCase,
databaseSizesUseCase = databaseSizesUseCase, databaseSizesUseCase = databaseSizesUseCase,

View file

@ -9,20 +9,12 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import androidx.activity.ComponentActivity 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.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R 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.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
@ -53,7 +45,7 @@ class DeveloperSettingsViewTest {
} }
} }
@Config(qualifiers = "h1500dp") @Config(qualifiers = "h2000dp")
@Test @Test
fun `clicking on push history notification invokes the expected callback`() { fun `clicking on push history notification invokes the expected callback`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false) 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") @Config(qualifiers = "h2000dp")
@Test @Test
fun `clicking on open showkase invokes the expected callback`() { 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") @Config(qualifiers = "h2200dp")
@Test @Test
fun `clicking on clear cache emits the expected event`() { fun `clicking on clear cache emits the expected event`() {

View file

@ -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,
)
}
}

View file

@ -0,0 +1,168 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@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,
)
}
}

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.preferences.test"
}
dependencies {
implementation(projects.features.preferences.api)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,32 @@
/*
* 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.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakePreferencesEntryPoint : PreferencesEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: PreferencesEntryPoint.Params,
callback: PreferencesEntryPoint.Callback,
): Node {
lambdaError()
}
override fun createAppDeveloperSettingsNode(
parentNode: Node,
buildContext: BuildContext,
callback: PreferencesEntryPoint.DeveloperSettingsCallback,
): Node {
lambdaError()
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5015f504040a0141d40bc14bf8a3a3be43c9c95a3702a6dc53bb253746e5a3aa oid sha256:01fa1c9b917b65afc2d1464fad177f7420dea1625eeb7c8335d8105664134e67
size 311553 size 312145

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8972af96ba5624f92c826ef0e20595b6193a44b6b0a0ea03cb133c516a93a90e oid sha256:7e622d9b43664c5a31b83b41801ed07769384ab9ab84aad57605cdb67b16c58d
size 391678 size 392254

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e24299f25ffd7095887bb52214c25de8de2cf0380b374d11f65d78c86f49dae1 oid sha256:d18a2e9ba19401f958483bf06ecd7b311ed7383de0df3d89f3bb4d3a57f8e9e7
size 45507 size 54191

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d84781b107e2f25bdc88cbfe84a1933dd20bf4c1dd372cb69f136f36df2607c0 oid sha256:aafbc1f791f067fd0084db3bf293511e3cf7557329e7599c3f3e3c66f01435c4
size 41951 size 45472

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:577c00e6e45e1da5ac1b1deee380d7a087b1f32e077f8e5b9430497bf6f7012e oid sha256:d18a2e9ba19401f958483bf06ecd7b311ed7383de0df3d89f3bb4d3a57f8e9e7
size 44083 size 54191

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9750c0ae52dce1ba63c1cff7a22a3e3c75c15b5a556ba7fff49815b55836372b oid sha256:dcca87aa6eee7e45bedb8f58c3b776e3932d94843573eea70aa686c3de82e03f
size 44198 size 52290

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:31a3e5f9abaed21c87052ef7642dc8456d75580b79988ebe271f09d1381e9a03 oid sha256:25e311c9bd46defd9659004d2d36088985c0578189a41906b51bfe683ddb6488
size 40820 size 43889

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a7aab145e8ca2cd9de64a145c7966420a474b3500016a46100dad798f33acba9 oid sha256:dcca87aa6eee7e45bedb8f58c3b776e3932d94843573eea70aa686c3de82e03f
size 42792 size 52290

View file

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