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:
commit
9699488ae5
46 changed files with 1035 additions and 469 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
NotificationCategory(onPushHistoryClick)
|
||||||
|
|
||||||
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 = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.appsettings
|
||||||
|
|
||||||
|
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||||
|
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||||
|
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
|
||||||
|
|
||||||
|
sealed interface AppDeveloperSettingsEvent {
|
||||||
|
data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : AppDeveloperSettingsEvent
|
||||||
|
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent
|
||||||
|
data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent
|
||||||
|
data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.appsettings
|
||||||
|
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.airbnb.android.showkase.models.Showkase
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||||
|
import io.element.android.libraries.architecture.callback
|
||||||
|
import io.element.android.libraries.designsystem.showkase.getBrowserIntent
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class AppDeveloperSettingsNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: AppDeveloperSettingsPresenter,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
private val callback: PreferencesEntryPoint.DeveloperSettingsCallback = callback()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val activity = requireNotNull(LocalActivity.current)
|
||||||
|
fun openShowkase() {
|
||||||
|
val intent = Showkase.getBrowserIntent(activity)
|
||||||
|
activity.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
val state = presenter.present()
|
||||||
|
AppDeveloperSettingsPage(
|
||||||
|
state = state,
|
||||||
|
modifier = modifier,
|
||||||
|
onOpenShowkase = ::openShowkase,
|
||||||
|
onBackClick = callback::onDone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.appsettings
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AppDeveloperSettingsPage(
|
||||||
|
state: AppDeveloperSettingsState,
|
||||||
|
onOpenShowkase: () -> Unit,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
BackHandler(
|
||||||
|
onBack = onBackClick,
|
||||||
|
)
|
||||||
|
PreferencePage(
|
||||||
|
modifier = modifier,
|
||||||
|
onBackClick = {
|
||||||
|
onBackClick()
|
||||||
|
},
|
||||||
|
title = "Application developer options",
|
||||||
|
) {
|
||||||
|
AppDeveloperSettingsView(
|
||||||
|
state = state,
|
||||||
|
onOpenShowkase = onOpenShowkase,
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun AppDeveloperSettingsPagePreview() = ElementPreview {
|
||||||
|
AppDeveloperSettingsPage(
|
||||||
|
state = anAppDeveloperSettingsState(),
|
||||||
|
onOpenShowkase = {},
|
||||||
|
onBackClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.appsettings
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
|
||||||
|
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
|
||||||
|
import io.element.android.features.preferences.impl.model.EnabledFeature
|
||||||
|
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class AppDeveloperSettingsPresenter(
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val rageshakePresenter: Presenter<RageshakePreferencesState>,
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) : Presenter<AppDeveloperSettingsState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): AppDeveloperSettingsState {
|
||||||
|
val rageshakeState = rageshakePresenter.present()
|
||||||
|
val enabledFeatures = remember {
|
||||||
|
mutableStateListOf<EnabledFeature>()
|
||||||
|
}
|
||||||
|
val customElementCallBaseUrl by remember {
|
||||||
|
appPreferencesStore
|
||||||
|
.getCustomElementCallBaseUrlFlow()
|
||||||
|
}.collectAsState(initial = null)
|
||||||
|
|
||||||
|
val tracingLogLevelFlow = remember {
|
||||||
|
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
|
||||||
|
}
|
||||||
|
val tracingLogLevel by tracingLogLevelFlow.collectAsState(initial = AsyncData.Uninitialized)
|
||||||
|
val tracingLogPacks by produceState(persistentListOf()) {
|
||||||
|
appPreferencesStore.getTracingLogPacksFlow()
|
||||||
|
// Sort the entries alphabetically by its title
|
||||||
|
.map { it.sortedBy { pack -> pack.title } }
|
||||||
|
.collectLatest { value = it.toImmutableList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
featureFlagService.getAvailableFeatures()
|
||||||
|
.run {
|
||||||
|
// Never display room directory search in release builds for Play Store
|
||||||
|
if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) {
|
||||||
|
filterNot { it.key == FeatureFlags.RoomDirectorySearch.key }
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.forEach { feature ->
|
||||||
|
enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val featureUiModels = createUiModels(enabledFeatures)
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
// Compute cache size each time the clear cache action value is changed
|
||||||
|
|
||||||
|
fun handleEvent(event: AppDeveloperSettingsEvent) {
|
||||||
|
when (event) {
|
||||||
|
is AppDeveloperSettingsEvent.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature(
|
||||||
|
enabledFeatures = enabledFeatures,
|
||||||
|
featureKey = event.feature.key,
|
||||||
|
enabled = event.isEnabled,
|
||||||
|
)
|
||||||
|
is AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl -> coroutineScope.launch {
|
||||||
|
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() }
|
||||||
|
appPreferencesStore.setCustomElementCallBaseUrl(urlToSave)
|
||||||
|
}
|
||||||
|
is AppDeveloperSettingsEvent.SetTracingLogLevel -> coroutineScope.launch {
|
||||||
|
appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel())
|
||||||
|
}
|
||||||
|
is AppDeveloperSettingsEvent.ToggleTracingLogPack -> coroutineScope.launch {
|
||||||
|
val currentPacks = tracingLogPacks.toMutableSet()
|
||||||
|
if (currentPacks.contains(event.logPack)) {
|
||||||
|
currentPacks.remove(event.logPack)
|
||||||
|
} else {
|
||||||
|
currentPacks.add(event.logPack)
|
||||||
|
}
|
||||||
|
appPreferencesStore.setTracingLogPacks(currentPacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppDeveloperSettingsState(
|
||||||
|
features = featureUiModels,
|
||||||
|
rageshakeState = rageshakeState,
|
||||||
|
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
|
||||||
|
baseUrl = customElementCallBaseUrl,
|
||||||
|
validator = ::customElementCallUrlValidator,
|
||||||
|
),
|
||||||
|
tracingLogLevel = tracingLogLevel,
|
||||||
|
tracingLogPacks = tracingLogPacks,
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun createUiModels(
|
||||||
|
enabledFeatures: SnapshotStateList<EnabledFeature>,
|
||||||
|
): ImmutableList<FeatureUiModel> {
|
||||||
|
return enabledFeatures.map { enabledFeature ->
|
||||||
|
key(enabledFeature.feature.key) {
|
||||||
|
remember(enabledFeature) {
|
||||||
|
FeatureUiModel(
|
||||||
|
key = enabledFeature.feature.key,
|
||||||
|
title = enabledFeature.feature.title,
|
||||||
|
description = enabledFeature.feature.description,
|
||||||
|
icon = null,
|
||||||
|
isEnabled = enabledFeature.isEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.updateEnabledFeature(
|
||||||
|
enabledFeatures: SnapshotStateList<EnabledFeature>,
|
||||||
|
featureKey: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
) = launch {
|
||||||
|
val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == featureKey }.takeIf { it != -1 } ?: return@launch
|
||||||
|
val feature = enabledFeatures[featureIndex].feature
|
||||||
|
if (featureFlagService.setFeatureEnabled(feature, enabled)) {
|
||||||
|
enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun customElementCallUrlValidator(url: String?): Boolean {
|
||||||
|
return runCatchingExceptions {
|
||||||
|
if (url.isNullOrEmpty()) return@runCatchingExceptions
|
||||||
|
val parsedUrl = URL(url)
|
||||||
|
if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol")
|
||||||
|
if (parsedUrl.host.isNullOrBlank()) error("Missing host")
|
||||||
|
}.isSuccess
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.appsettings
|
||||||
|
|
||||||
|
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||||
|
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||||
|
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
data class AppDeveloperSettingsState(
|
||||||
|
val features: ImmutableList<FeatureUiModel>,
|
||||||
|
val rageshakeState: RageshakePreferencesState,
|
||||||
|
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
|
||||||
|
val tracingLogLevel: AsyncData<LogLevelItem>,
|
||||||
|
val tracingLogPacks: ImmutableList<TraceLogPack>,
|
||||||
|
val eventSink: (AppDeveloperSettingsEvent) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CustomElementCallBaseUrlState(
|
||||||
|
val baseUrl: String?,
|
||||||
|
val validator: (String?) -> Boolean,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.appsettings
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||||
|
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
|
||||||
|
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
open class AppDeveloperSettingsStateProvider : PreviewParameterProvider<AppDeveloperSettingsState> {
|
||||||
|
override val values: Sequence<AppDeveloperSettingsState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
anAppDeveloperSettingsState(),
|
||||||
|
anAppDeveloperSettingsState(
|
||||||
|
customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
|
||||||
|
baseUrl = "https://call.element.ahoy",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun anAppDeveloperSettingsState(
|
||||||
|
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
|
||||||
|
traceLogPacks: List<TraceLogPack> = emptyList(),
|
||||||
|
eventSink: (AppDeveloperSettingsEvent) -> Unit = {},
|
||||||
|
) = AppDeveloperSettingsState(
|
||||||
|
features = aFeatureUiModelList(),
|
||||||
|
rageshakeState = aRageshakePreferencesState(),
|
||||||
|
customElementCallBaseUrlState = customElementCallBaseUrlState,
|
||||||
|
tracingLogLevel = AsyncData.Success(LogLevelItem.INFO),
|
||||||
|
tracingLogPacks = traceLogPacks.toImmutableList(),
|
||||||
|
eventSink = eventSink,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aCustomElementCallBaseUrlState(
|
||||||
|
baseUrl: String? = null,
|
||||||
|
validator: (String?) -> Boolean = { true },
|
||||||
|
) = CustomElementCallBaseUrlState(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
validator = validator,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.appsettings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.features.preferences.impl.R
|
||||||
|
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||||
|
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
|
||||||
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||||
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
|
||||||
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||||
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.featureflag.ui.FeatureListView
|
||||||
|
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||||
|
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AppDeveloperSettingsView(
|
||||||
|
state: AppDeveloperSettingsState,
|
||||||
|
onOpenShowkase: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
// Note: this is OK to hardcode strings in this debug screen.
|
||||||
|
PreferenceCategory(
|
||||||
|
title = "Feature flags",
|
||||||
|
showTopDivider = false,
|
||||||
|
) {
|
||||||
|
FeatureListContent(state)
|
||||||
|
}
|
||||||
|
ElementCallCategory(state = state)
|
||||||
|
PreferenceCategory(title = "Rust SDK") {
|
||||||
|
PreferenceDropdown(
|
||||||
|
title = "Tracing log level",
|
||||||
|
supportingText = "Requires app reboot",
|
||||||
|
selectedOption = state.tracingLogLevel.dataOrNull(),
|
||||||
|
options = LogLevelItem.entries.toImmutableList(),
|
||||||
|
onSelectOption = { logLevel ->
|
||||||
|
state.eventSink(AppDeveloperSettingsEvent.SetTracingLogLevel(logLevel))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PreferenceCategory(title = "Enable trace logs per SDK feature") {
|
||||||
|
Text(
|
||||||
|
text = "Requires app reboot",
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
for (logPack in TraceLogPack.entries) {
|
||||||
|
PreferenceSwitch(
|
||||||
|
title = logPack.title,
|
||||||
|
isChecked = state.tracingLogPacks.contains(logPack),
|
||||||
|
onCheckedChange = { isChecked -> state.eventSink(AppDeveloperSettingsEvent.ToggleTracingLogPack(logPack, isChecked)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PreferenceCategory(title = "Showkase") {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text("Open Showkase browser")
|
||||||
|
},
|
||||||
|
onClick = onOpenShowkase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PreferenceCategory(title = "Crash") {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text("Crash the app 💥")
|
||||||
|
},
|
||||||
|
onClick = { error("This crash is a test.") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RageshakePreferencesView(
|
||||||
|
state = state.rageshakeState,
|
||||||
|
)
|
||||||
|
PreferenceCategory(title = "Crash") {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text("Crash the app 💥")
|
||||||
|
},
|
||||||
|
onClick = { error("This crash is a test.") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ElementCallCategory(
|
||||||
|
state: AppDeveloperSettingsState,
|
||||||
|
) {
|
||||||
|
PreferenceCategory(title = "Element Call") {
|
||||||
|
val callUrlState = state.customElementCallBaseUrlState
|
||||||
|
|
||||||
|
val supportingText = if (callUrlState.baseUrl.isNullOrEmpty()) {
|
||||||
|
stringResource(R.string.screen_advanced_settings_element_call_base_url_description)
|
||||||
|
} else {
|
||||||
|
callUrlState.baseUrl
|
||||||
|
}
|
||||||
|
PreferenceTextField(
|
||||||
|
headline = stringResource(R.string.screen_advanced_settings_element_call_base_url),
|
||||||
|
value = callUrlState.baseUrl,
|
||||||
|
placeholder = "https://.../room",
|
||||||
|
supportingText = supportingText,
|
||||||
|
validation = callUrlState.validator,
|
||||||
|
onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error),
|
||||||
|
displayValue = { value -> !value.isNullOrEmpty() },
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri),
|
||||||
|
onChange = { state.eventSink(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl(it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeatureListContent(
|
||||||
|
state: AppDeveloperSettingsState,
|
||||||
|
) {
|
||||||
|
fun onFeatureEnabled(feature: FeatureUiModel, isEnabled: Boolean) {
|
||||||
|
state.eventSink(AppDeveloperSettingsEvent.UpdateEnabledFeature(feature, isEnabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
FeatureListView(
|
||||||
|
features = state.features,
|
||||||
|
onCheckedChange = ::onFeatureEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun AppDeveloperSettingsViewPreview(
|
||||||
|
@PreviewParameter(AppDeveloperSettingsStateProvider::class) state: AppDeveloperSettingsState
|
||||||
|
) = ElementPreview {
|
||||||
|
AppDeveloperSettingsView(
|
||||||
|
state = state,
|
||||||
|
onOpenShowkase = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.di
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.BindingContainer
|
||||||
|
import dev.zacsweers.metro.Binds
|
||||||
|
import dev.zacsweers.metro.ContributesTo
|
||||||
|
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsPresenter
|
||||||
|
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
||||||
|
@ContributesTo(AppScope::class)
|
||||||
|
@BindingContainer
|
||||||
|
interface DeveloperSettingsModule {
|
||||||
|
@Binds
|
||||||
|
fun bindAppDeveloperSettingsPresenter(presenter: AppDeveloperSettingsPresenter): Presenter<AppDeveloperSettingsState>
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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`() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.appsettings
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.filterToOne
|
||||||
|
import androidx.compose.ui.test.hasAnyAncestor
|
||||||
|
import androidx.compose.ui.test.isDialog
|
||||||
|
import androidx.compose.ui.test.isEditable
|
||||||
|
import androidx.compose.ui.test.isFocusable
|
||||||
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import io.element.android.features.preferences.impl.R
|
||||||
|
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
import io.element.android.tests.testutils.clickOn
|
||||||
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
|
import io.element.android.tests.testutils.pressBack
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AppDeveloperSettingsPageTest {
|
||||||
|
@get:Rule
|
||||||
|
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking on back invokes the expected callback`() {
|
||||||
|
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>(expectEvents = false)
|
||||||
|
ensureCalledOnce {
|
||||||
|
rule.setAppDeveloperSettingsView(
|
||||||
|
state = anAppDeveloperSettingsState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
onBackClick = it
|
||||||
|
)
|
||||||
|
rule.pressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Config(qualifiers = "h1500dp")
|
||||||
|
@Test
|
||||||
|
fun `clicking on element call url open the dialogs and submit emits the expected event`() {
|
||||||
|
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>()
|
||||||
|
rule.setAppDeveloperSettingsView(
|
||||||
|
state = anAppDeveloperSettingsState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_advanced_settings_element_call_base_url)
|
||||||
|
val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog()))
|
||||||
|
textInputNode.performTextInput("https://call.element.dev")
|
||||||
|
rule.clickOn(CommonStrings.action_ok)
|
||||||
|
eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.dev"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Config(qualifiers = "h2000dp")
|
||||||
|
@Test
|
||||||
|
fun `clicking on open showkase invokes the expected callback`() {
|
||||||
|
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>(expectEvents = false)
|
||||||
|
ensureCalledOnce {
|
||||||
|
rule.setAppDeveloperSettingsView(
|
||||||
|
state = anAppDeveloperSettingsState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
onOpenShowkase = it
|
||||||
|
)
|
||||||
|
rule.onNodeWithText("Open Showkase browser").performClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Config(qualifiers = "h1024dp")
|
||||||
|
@Test
|
||||||
|
fun `clicking on log level emits the expected event`() {
|
||||||
|
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>()
|
||||||
|
rule.setAppDeveloperSettingsView(
|
||||||
|
state = anAppDeveloperSettingsState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.onNodeWithText("Tracing log level").performClick()
|
||||||
|
rule.onNodeWithText("Debug").performClick()
|
||||||
|
eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetTracingLogLevel(LogLevelItem.DEBUG))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAppDeveloperSettingsView(
|
||||||
|
state: AppDeveloperSettingsState,
|
||||||
|
onOpenShowkase: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
AppDeveloperSettingsPage(
|
||||||
|
state = state,
|
||||||
|
onOpenShowkase = onOpenShowkase,
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.developer.appsettings
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||||
|
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
|
import io.element.android.libraries.featureflag.api.Feature
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeature
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AppDeveloperSettingsPresenterTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - ensures initial states are correct`() = runTest {
|
||||||
|
val getAvailableFeaturesResult = lambdaRecorder<Boolean, Boolean, List<Feature>> { _, _ ->
|
||||||
|
listOf(
|
||||||
|
FakeFeature(
|
||||||
|
key = "feature_1",
|
||||||
|
title = "Feature 1",
|
||||||
|
isInLabs = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val presenter = createAppDeveloperSettingsPresenter(
|
||||||
|
featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.features).isEmpty()
|
||||||
|
assertThat(state.customElementCallBaseUrlState).isNotNull()
|
||||||
|
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
||||||
|
assertThat(state.rageshakeState.isEnabled).isFalse()
|
||||||
|
assertThat(state.rageshakeState.isSupported).isTrue()
|
||||||
|
assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f)
|
||||||
|
assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized)
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.features).isNotEmpty()
|
||||||
|
assertThat(state.features).hasSize(1)
|
||||||
|
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO)
|
||||||
|
}
|
||||||
|
getAvailableFeaturesResult.assertions().isCalledOnce()
|
||||||
|
.with(value(false), value(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest {
|
||||||
|
val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay")
|
||||||
|
val presenter = createAppDeveloperSettingsPresenter(buildMeta = buildMeta)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
|
||||||
|
val presenter = createAppDeveloperSettingsPresenter()
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
val feature = state.features.first { !it.isEnabled }
|
||||||
|
state.eventSink(AppDeveloperSettingsEvent.UpdateEnabledFeature(feature, !feature.isEnabled))
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
val feature = state.features.first()
|
||||||
|
assertThat(feature.isEnabled).isTrue()
|
||||||
|
assertThat(feature.key).isEqualTo(feature.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - custom element call base url`() = runTest {
|
||||||
|
val preferencesStore = InMemoryAppPreferencesStore()
|
||||||
|
val presenter = createAppDeveloperSettingsPresenter(preferencesStore = preferencesStore)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
|
||||||
|
state.eventSink(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest {
|
||||||
|
val presenter = createAppDeveloperSettingsPresenter()
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val urlValidator = awaitItem().customElementCallBaseUrlState.validator
|
||||||
|
assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one
|
||||||
|
assertThat(urlValidator("test")).isFalse()
|
||||||
|
assertThat(urlValidator("http://")).isFalse()
|
||||||
|
assertThat(urlValidator("geo://test")).isFalse()
|
||||||
|
assertThat(urlValidator("https://call.element.io")).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - changing tracing log level`() = runTest {
|
||||||
|
val preferences = InMemoryAppPreferencesStore()
|
||||||
|
val presenter = createAppDeveloperSettingsPresenter(preferencesStore = preferences)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO)
|
||||||
|
state.eventSink(AppDeveloperSettingsEvent.SetTracingLogLevel(LogLevelItem.TRACE))
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.TRACE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAppDeveloperSettingsPresenter(
|
||||||
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
|
||||||
|
getAvailableFeaturesResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
FakeFeature(
|
||||||
|
key = "feature_1",
|
||||||
|
title = "Feature 1",
|
||||||
|
isInLabs = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
|
): AppDeveloperSettingsPresenter {
|
||||||
|
return AppDeveloperSettingsPresenter(
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
|
rageshakePresenter = { aRageshakePreferencesState() },
|
||||||
|
appPreferencesStore = preferencesStore,
|
||||||
|
buildMeta = buildMeta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
features/preferences/test/build.gradle.kts
Normal file
19
features/preferences/test/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:62068492969ad00e1a8e4a44189f93d83c98fee40612e4eecb68d3076d00ed07
|
||||||
|
size 56425
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6d92e220d675097c37b60312ca4c4e821eb6ff6475bcd004437dde0d2e964cce
|
||||||
|
size 54756
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cf121c0ea1fb3cc7a47a292877535c9c0a1ae1ac11bf7f5ce169d0cd79844246
|
||||||
|
size 53775
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:37ae25b9f9c659164c006c9bdb9f94379398ee8215e8a2dd9167620ef994b6d1
|
||||||
|
size 52347
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:db439869d2b8843cd58251bbca638e0dad7ed2b1ae4b22507e052e3d5521214d
|
||||||
|
size 52090
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cae56bb14ae7bc1f6c73884c2efb333b7da22c82d69026854a59c380657a5bbe
|
||||||
|
size 50705
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:e24299f25ffd7095887bb52214c25de8de2cf0380b374d11f65d78c86f49dae1
|
|
||||||
size 45507
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:9750c0ae52dce1ba63c1cff7a22a3e3c75c15b5a556ba7fff49815b55836372b
|
|
||||||
size 44198
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue