On boarding flow: add a screen to select account provider among a fixed list (#4769)

* Hide login with QrCode when the app is opened by a link

* Fix UI on ChangeAccountProviderView.

* Add flow to choose between a fixed list of account provider

* Update screenshots

* Fix licence header

* Rename preview.

* Ensure that the default account provider cannot be "*"
This should not happen IRL, but better be robust against issue in application configuration.

* Create const of any account provider value

* Fix typo

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2025-05-23 18:36:48 +02:00
committed by GitHub
parent 0d5b8709b8
commit dbe75cd4ea
46 changed files with 1164 additions and 64 deletions

View File

@@ -21,4 +21,14 @@ interface EnterpriseService {
fun firebasePushGateway(): String?
fun unifiedPushDefaultPushGateway(): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
}
fun EnterpriseService.canConnectToAnyHomeserver(): Boolean {
return defaultHomeserverList().let {
it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER)
}
}

View File

@@ -30,6 +30,7 @@ import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
@@ -107,6 +108,9 @@ class LoginFlowNode @AssistedInject constructor(
val isAccountCreation: Boolean,
) : NavTarget
@Parcelize
data object ChooseAccountProvider : NavTarget
@Parcelize
data object ChangeAccountProvider : NavTarget
@@ -133,9 +137,13 @@ class LoginFlowNode @AssistedInject constructor(
)
}
override fun onSignIn() {
override fun onSignIn(mustChooseAccountProvider: Boolean) {
backstack.push(
NavTarget.ConfirmAccountProvider(isAccountCreation = false)
if (mustChooseAccountProvider) {
NavTarget.ChooseAccountProvider
} else {
NavTarget.ConfirmAccountProvider(isAccountCreation = false)
}
)
}
@@ -166,6 +174,22 @@ class LoginFlowNode @AssistedInject constructor(
)
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
}
NavTarget.ChooseAccountProvider -> {
val callback = object : ChooseAccountProviderNode.Callback {
override fun onOidcDetails(oidcDetails: OidcDetails) {
navigateToMas(oidcDetails)
}
override fun onCreateAccountContinue(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
override fun onLoginPasswordNeeded() {
backstack.push(NavTarget.LoginPassword)
}
}
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
}
NavTarget.QrCode -> {
createNode<QrCodeLoginFlowNode>(buildContext)
}

View File

@@ -20,15 +20,16 @@ import javax.inject.Inject
class AccountProviderDataSource @Inject constructor(
enterpriseService: EnterpriseService,
) {
private val defaultAccountProvider = (enterpriseService.defaultHomeserverList().firstOrNull() ?: AuthenticationConfig.MATRIX_ORG_URL)
.let { url ->
AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
)
}
private val defaultAccountProvider =
(enterpriseService.defaultHomeserverList().firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } ?: AuthenticationConfig.MATRIX_ORG_URL)
.let { url ->
AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
)
}
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(
defaultAccountProvider

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 New Vector 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.login.impl.accountprovider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
*/
@Composable
fun AccountProviderOtherView(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 44.dp)
.padding(vertical = 4.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.Search(),
tint = ElementTheme.colors.iconPrimary,
)
Text(
modifier = Modifier
.padding(start = 16.dp)
.weight(1f),
text = stringResource(R.string.screen_change_account_provider_other),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AccountProviderOtherViewPreview() = ElementPreview {
AccountProviderOtherView(
onClick = { },
)
}

View File

@@ -23,10 +23,14 @@ open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
fun anAccountProvider(
url: String = AuthenticationConfig.MATRIX_ORG_URL,
subtitle: String? = "Matrix.org is an open network for secure, decentralized communication.",
isPublic: Boolean = true,
isMatrixOrg: Boolean = true,
isValid: Boolean = true,
) = AccountProvider(
url = url,
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true,
isMatrixOrg = true,
isValid = true,
subtitle = subtitle,
isPublic = isPublic,
isMatrixOrg = isMatrixOrg,
isValid = isValid,
)

View File

@@ -39,6 +39,7 @@ fun AccountProviderView(
item: AccountProvider,
onClick: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = false,
) {
Column(
modifier = modifier
@@ -66,7 +67,7 @@ fun AccountProviderView(
} else {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.Search(),
imageVector = CompoundIcons.Host(),
tint = ElementTheme.colors.iconPrimary,
)
}
@@ -88,6 +89,15 @@ fun AccountProviderView(
tint = ElementTheme.colors.iconSecondary,
)
}
if (selected) {
Icon(
modifier = Modifier
.padding(start = 10.dp),
imageVector = CompoundIcons.Check(),
contentDescription = null,
tint = ElementTheme.colors.iconAccentPrimary,
)
}
}
if (item.subtitle != null) {
Text(

View File

@@ -14,6 +14,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
@@ -31,7 +32,8 @@ import javax.inject.Inject
/**
* This class is responsible for managing the login flow, including handling OIDC actions and
* submitting login requests.
* It's an helper to avoid code duplication. It is used by [OnBoardingPresenter] and [ConfirmAccountProviderPresenter].
* It's an helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter]
* and [ChooseAccountProviderPresenter].
*/
class LoginHelper @Inject constructor(
private val oidcActionFlow: OidcActionFlow,

View File

@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.libraries.architecture.Presenter
@@ -25,6 +26,7 @@ class ChangeAccountProviderPresenter @Inject constructor(
override fun present(): ChangeAccountProviderState {
val staticAccountProviderList = remember {
enterpriseService.defaultHomeserverList()
.filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
.map { it.ensureProtocol() }
.ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) }
.map { url ->
@@ -38,9 +40,14 @@ class ChangeAccountProviderPresenter @Inject constructor(
}
}
val canSearchForAccountProviders = remember {
enterpriseService.canConnectToAnyHomeserver()
}
val changeServerState = changeServerPresenter.present()
return ChangeAccountProviderState(
accountProviders = staticAccountProviderList,
canSearchForAccountProviders = canSearchForAccountProviders,
changeServerState = changeServerState,
)
}

View File

@@ -13,5 +13,6 @@ import io.element.android.features.login.impl.changeserver.ChangeServerState
// Do not use default value, so no member get forgotten in the presenters.
data class ChangeAccountProviderState(
val accountProviders: List<AccountProvider>,
val canSearchForAccountProviders: Boolean,
val changeServerState: ChangeServerState,
)

View File

@@ -8,20 +8,28 @@
package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.changeserver.aChangeServerState
open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> {
override val values: Sequence<ChangeAccountProviderState>
get() = sequenceOf(
aChangeAccountProviderState(),
aChangeAccountProviderState(canSearchForAccountProviders = false),
// Add other state here
)
}
fun aChangeAccountProviderState() = ChangeAccountProviderState(
accountProviders = listOf(
fun aChangeAccountProviderState(
accountProviders: List<AccountProvider> = listOf(
anAccountProvider()
),
changeServerState = aChangeServerState(),
canSearchForAccountProviders: Boolean = true,
changeServerState: ChangeServerState = aChangeServerState(),
) = ChangeAccountProviderState(
accountProviders = accountProviders,
canSearchForAccountProviders = canSearchForAccountProviders,
changeServerState = changeServerState,
)

View File

@@ -27,7 +27,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderOtherView
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
@@ -95,13 +95,11 @@ fun ChangeAccountProviderView(
)
}
// Other
AccountProviderView(
item = AccountProvider(
url = "",
title = stringResource(id = R.string.screen_change_account_provider_other),
),
onClick = onOtherProviderClick
)
if (state.canSearchForAccountProviders) {
AccountProviderOtherView(
onClick = onOtherProviderClick
)
}
Spacer(Modifier.height(32.dp))
}
ChangeServerView(

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector 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.login.impl.screens.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
sealed interface ChooseAccountProviderEvents {
data class SelectAccountProvider(val accountProvider: AccountProvider) : ChooseAccountProviderEvents
data object Continue : ChooseAccountProviderEvents
data object ClearError : ChooseAccountProviderEvents
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2025 New Vector 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.login.impl.screens.chooseaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
class ChooseAccountProviderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ChooseAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onLoginPasswordNeeded()
fun onOidcDetails(oidcDetails: OidcDetails)
fun onCreateAccountContinue(url: String)
}
private fun onOidcDetails(oidcDetails: OidcDetails) {
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
}
private fun onLoginPasswordNeeded() {
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
}
private fun onCreateAccountContinue(url: String) {
plugins<Callback>().forEach { it.onCreateAccountContinue(url) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
ChooseAccountProviderView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onOidcDetails = ::onOidcDetails,
onNeedLoginPassword = ::onLoginPasswordNeeded,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = ::onCreateAccountContinue,
)
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2025 New Vector 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.login.impl.screens.chooseaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol
import javax.inject.Inject
class ChooseAccountProviderPresenter @Inject constructor(
private val enterpriseService: EnterpriseService,
private val loginHelper: LoginHelper,
) : Presenter<ChooseAccountProviderState> {
@Composable
override fun present(): ChooseAccountProviderState {
val localCoroutineScope = rememberCoroutineScope()
val loginMode by loginHelper.collectLoginMode()
var selectedAccountProvider: AccountProvider? by remember { mutableStateOf(null) }
fun handleEvent(event: ChooseAccountProviderEvents) {
when (event) {
ChooseAccountProviderEvents.Continue -> {
selectedAccountProvider?.let {
loginHelper.submit(
coroutineScope = localCoroutineScope,
isAccountCreation = false,
homeserverUrl = it.url,
loginHint = null,
)
}
}
is ChooseAccountProviderEvents.SelectAccountProvider -> {
// Ensure that the user do not change the server during processing
if (loginMode is AsyncData.Uninitialized) {
selectedAccountProvider = event.accountProvider
}
}
ChooseAccountProviderEvents.ClearError -> loginHelper.clearError()
}
}
val staticAccountProviderList = remember {
// The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point
enterpriseService.defaultHomeserverList()
.map { it.ensureProtocol() }
.map { url ->
AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
isValid = true,
)
}
}
return ChooseAccountProviderState(
accountProviders = staticAccountProviderList,
selectedAccountProvider = selectedAccountProvider,
loginMode = loginMode,
eventSink = ::handleEvent,
)
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector 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.login.impl.screens.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
// Do not use default value, so no member get forgotten in the presenters.
data class ChooseAccountProviderState(
val accountProviders: List<AccountProvider>,
val selectedAccountProvider: AccountProvider?,
val loginMode: AsyncData<LoginMode>,
val eventSink: (ChooseAccountProviderEvents) -> Unit,
) {
val submitEnabled: Boolean
get() = selectedAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2025 New Vector 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.login.impl.screens.chooseaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
open class ChooseAccountProviderStateProvider : PreviewParameterProvider<ChooseAccountProviderState> {
private val server1 = anAccountProvider(
url = "https://server1.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
private val server2 = anAccountProvider(
url = "https://server2.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
private val server3 = anAccountProvider(
url = "https://server3.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
override val values: Sequence<ChooseAccountProviderState>
get() = sequenceOf(
aChooseAccountProviderState(
accountProviders = listOf(
server1,
server2,
server3,
)
),
aChooseAccountProviderState(
accountProviders = listOf(
server1,
server2,
server3,
),
selectedAccountProvider = server2,
),
aChooseAccountProviderState(
accountProviders = listOf(
server1,
server2,
server3,
),
selectedAccountProvider = server2,
loginMode = AsyncData.Loading(),
),
// Add other state here
)
}
fun aChooseAccountProviderState(
accountProviders: List<AccountProvider> = listOf(
anAccountProvider()
),
selectedAccountProvider: AccountProvider? = null,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (ChooseAccountProviderEvents) -> Unit = {},
) = ChooseAccountProviderState(
accountProviders = accountProviders,
selectedAccountProvider = selectedAccountProvider,
loginMode = loginMode,
eventSink = eventSink,
)

View File

@@ -0,0 +1,150 @@
/*
* Copyright 2025 New Vector 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(ExperimentalMaterial3Api::class)
package io.element.android.features.login.impl.screens.chooseaccountprovider
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ChooseAccountProviderView(
state: ChooseAccountProviderState,
onBackClick: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
}
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackClick) }
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(state = rememberScrollState())
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()),
title = stringResource(id = R.string.screen_server_confirmation_title_picker_mode),
subTitle = null,
)
state.accountProviders.forEach { item ->
val alteredItem = if (item.isMatrixOrg) {
// Set the subtitle from the resource
item.copy(
subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
)
} else {
item
}
AccountProviderView(
item = alteredItem,
selected = item == state.selectedAccountProvider,
onClick = {
state.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(item))
}
)
}
Spacer(Modifier.height(32.dp))
// Flexible spacing to keep the submit button at the bottom
Spacer(modifier = Modifier.weight(1f))
Button(
text = stringResource(id = CommonStrings.action_continue),
showProgress = isLoading,
onClick = {
state.eventSink(ChooseAccountProviderEvents.Continue)
},
enabled = state.submitEnabled || isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(48.dp))
}
LoginModeView(
loginMode = state.loginMode,
onClearError = {
state.eventSink(ChooseAccountProviderEvents.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ChooseAccountProviderViewPreview(@PreviewParameter(ChooseAccountProviderStateProvider::class) state: ChooseAccountProviderState) = ElementPreview {
ChooseAccountProviderView(
state = state,
onBackClick = { },
onLearnMoreClick = { },
onOidcDetails = { },
onNeedLoginPassword = { },
onCreateAccountContinue = { },
)
}

View File

@@ -34,7 +34,7 @@ class OnBoardingNode @AssistedInject constructor(
) {
interface Callback : Plugin {
fun onSignUp()
fun onSignIn()
fun onSignIn(mustChooseAccountProvider: Boolean)
fun onSignInWithQrCode()
fun onReportProblem()
fun onLoginPasswordNeeded()
@@ -53,8 +53,8 @@ class OnBoardingNode @AssistedInject constructor(
params = params,
)
private fun onSignIn() {
plugins<Callback>().forEach { it.onSignIn() }
private fun onSignIn(mustChooseAccountProvider: Boolean) {
plugins<Callback>().forEach { it.onSignIn(mustChooseAccountProvider) }
}
private fun onSignUp() {

View File

@@ -16,6 +16,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
@@ -27,6 +29,7 @@ class OnBoardingPresenter @AssistedInject constructor(
@Assisted private val params: OnBoardingNode.Params,
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
private val enterpriseService: EnterpriseService,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val loginHelper: LoginHelper,
) : Presenter<OnBoardingState> {
@@ -37,15 +40,33 @@ class OnBoardingPresenter @AssistedInject constructor(
): OnBoardingPresenter
}
private val defaultAccountProvider = params.accountProvider
private val loginHint = params.loginHint
@Composable
override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope()
val canLoginWithQrCode by produceState(initialValue = false) {
value = defaultAccountProvider == null &&
val forcedAccountProvider = remember {
// If defaultHomeserverList() returns a singleton list, this is the default account provider.
// In this case, the user can sign in using this homeserver, or use QrCode login
enterpriseService.defaultHomeserverList().singleOrNull()
}
val canConnectToAnyHomeserver = remember {
enterpriseService.canConnectToAnyHomeserver()
}
val mustChooseAccountProvider = remember {
!canConnectToAnyHomeserver && enterpriseService.defaultHomeserverList().size > 1
}
val linkAccountProvider by produceState<String?>(initialValue = null) {
// Account provider from the link, if allowed by the enterprise service
value = params.accountProvider?.takeIf {
enterpriseService.isAllowedToConnectToHomeserver(it)
}
}
val defaultAccountProvider = remember(linkAccountProvider) {
// If there is a forced account provider, this is the default account provider
// Else use the account provider passed in the params if any and if allowed
forcedAccountProvider ?: linkAccountProvider
}
val canLoginWithQrCode by produceState(initialValue = false, linkAccountProvider) {
value = linkAccountProvider == null &&
featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
}
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
@@ -58,7 +79,7 @@ class OnBoardingPresenter @AssistedInject constructor(
coroutineScope = localCoroutineScope,
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
loginHint = loginHint,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
)
OnBoardingEvents.ClearError -> loginHelper.clearError()
}
@@ -67,8 +88,9 @@ class OnBoardingPresenter @AssistedInject constructor(
return OnBoardingState(
productionApplicationName = buildMeta.productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug,
loginMode = loginMode,
eventSink = ::handleEvent,

View File

@@ -13,6 +13,7 @@ import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState(
val productionApplicationName: String,
val defaultAccountProvider: String?,
val mustChooseAccountProvider: Boolean,
val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean,
val canReportBug: Boolean,

View File

@@ -26,6 +26,7 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
fun anOnBoardingState(
productionApplicationName: String = "Element",
defaultAccountProvider: String? = null,
mustChooseAccountProvider: Boolean = false,
canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false,
canReportBug: Boolean = false,
@@ -34,6 +35,7 @@ fun anOnBoardingState(
) = OnBoardingState(
productionApplicationName = productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = canCreateAccount,
canReportBug = canReportBug,

View File

@@ -56,7 +56,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun OnBoardingView(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit,
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
onCreateAccount: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
@@ -143,7 +143,7 @@ private fun OnBoardingContent(state: OnBoardingState) {
private fun OnBoardingButtons(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit,
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
onCreateAccount: () -> Unit,
onReportProblem: () -> Unit,
) {
@@ -171,7 +171,9 @@ private fun OnBoardingButtons(
if (defaultAccountProvider == null) {
Button(
text = stringResource(id = signInButtonStringRes),
onClick = onSignIn,
onClick = {
onSignIn(state.mustChooseAccountProvider)
},
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.onBoardingSignIn)

View File

@@ -89,5 +89,6 @@ Try signing in manually, or scan the QR code with another device."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix is an open network for secure, decentralised communication."</string>
<string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_server_confirmation_title_login">"Youre about to sign in to %1$s"</string>
<string name="screen_server_confirmation_title_picker_mode">"Choose account provider"</string>
<string name="screen_server_confirmation_title_register">"Youre about to create an account on %1$s"</string>
</resources>

View File

@@ -10,6 +10,7 @@ package io.element.android.features.login.impl.accountprovider
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
@@ -60,6 +61,28 @@ class AccountProviderDataSourceTest {
}
}
@Test
fun `present - ensure that default homeserver is not star char`() = runTest {
val sut = AccountProviderDataSource(
FakeEnterpriseService(
defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) }
)
)
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
AccountProvider(
url = AuthenticationConfig.MATRIX_ORG_URL,
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
isValid = false,
)
)
}
}
@Test
fun `present - user change and reset`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService())

View File

@@ -11,9 +11,12 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -27,7 +30,9 @@ class ChangeAccountProviderPresenterTest {
fun `present - initial state`() = runTest {
val presenter = ChangeAccountProviderPresenter(
changeServerPresenter = { aChangeServerState() },
enterpriseService = FakeEnterpriseService(),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { emptyList() }
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -45,6 +50,75 @@ class ChangeAccountProviderPresenterTest {
)
)
)
assertThat(initialState.canSearchForAccountProviders).isTrue()
}
}
@Test
fun `present - fixed list of account providers`() = runTest {
val presenter = ChangeAccountProviderPresenter(
changeServerPresenter = { aChangeServerState() },
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = {
listOf(AN_ACCOUNT_PROVIDER, AN_ACCOUNT_PROVIDER_2)
}
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.accountProviders).isEqualTo(
listOf(
AccountProvider(
url = "https://matrix.org",
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
isValid = true,
),
AccountProvider(
url = "https://element.io",
title = "element.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
isValid = true,
)
)
)
assertThat(initialState.canSearchForAccountProviders).isFalse()
}
}
@Test
fun `present - opened list of account providers`() = runTest {
val presenter = ChangeAccountProviderPresenter(
changeServerPresenter = { aChangeServerState() },
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = {
listOf(AN_ACCOUNT_PROVIDER, EnterpriseService.ANY_ACCOUNT_PROVIDER)
}
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.accountProviders).isEqualTo(
listOf(
AccountProvider(
url = "https://matrix.org",
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
isValid = true,
)
)
)
assertThat(initialState.canSearchForAccountProviders).isTrue()
}
}
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright 2025 New Vector 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.login.impl.screens.chooseaccountprovider
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ChooseAccountProviderPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
companion object {
private const val ACCOUNT_PROVIDER_FROM_CONFIG_1 = AN_ACCOUNT_PROVIDER_2
private const val ACCOUNT_PROVIDER_FROM_CONFIG_2 = AN_ACCOUNT_PROVIDER_3
val accountProvider1 = AccountProvider(
url = ACCOUNT_PROVIDER_FROM_CONFIG_1.ensureProtocol(),
subtitle = null,
isPublic = false,
isMatrixOrg = false,
isValid = true,
)
val accountProvider2 = AccountProvider(
url = ACCOUNT_PROVIDER_FROM_CONFIG_2.ensureProtocol(),
subtitle = null,
isPublic = false,
isMatrixOrg = false,
isValid = true,
)
}
@Test
fun `present - ensure initial conditions`() {
assertThat(
setOf(
ACCOUNT_PROVIDER_FROM_CONFIG_1,
ACCOUNT_PROVIDER_FROM_CONFIG_2,
).size
).isEqualTo(2)
}
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.accountProviders).containsExactly(
accountProvider1,
accountProvider2,
)
assertThat(initialState.selectedAccountProvider).isNull()
}
}
@Test
fun `present - Continue when no account provider is selected has no effect`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
awaitItem().also {
assertThat(it.selectedAccountProvider).isNull()
it.eventSink(ChooseAccountProviderEvents.Continue)
expectNoEvents()
}
}
}
@Test
fun `present - select account provider and continue - error then clear error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
awaitItem().also {
assertThat(it.selectedAccountProvider).isNull()
it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider1))
}
awaitItem().also {
assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1)
authenticationService.givenChangeServerError(A_THROWABLE)
it.eventSink(ChooseAccountProviderEvents.Continue)
skipItems(1) // Loading
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared
submittedState.eventSink(ChooseAccountProviderEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
}
@Test
fun `present - default account provider - select account provider during login has no effect`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
awaitItem().also {
assertThat(it.selectedAccountProvider).isNull()
it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider1))
}
awaitItem().also {
assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1)
it.eventSink(ChooseAccountProviderEvents.Continue)
}
awaitItem().also {
assertThat(it.loginMode.isLoading()).isTrue()
it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider2))
}
expectNoEvents()
}
}
}
private fun createPresenter(
enterpriseService: EnterpriseService = FakeEnterpriseService(),
loginHelper: LoginHelper = createLoginHelper(),
) = ChooseAccountProviderPresenter(
enterpriseService = enterpriseService,
loginHelper = loginHelper,
)

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2025 New Vector 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.login.impl.screens.chooseaccountprovider
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.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 ChooseAccountProviderViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>(expectEvents = false)
ensureCalledOnce {
rule.setChooseAccountProviderView(
state = aChooseAccountProviderState(
eventSink = eventSink,
),
onBackClick = it,
)
rule.pressBack()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `selecting an account provider emits the the expected event`() {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>()
rule.setChooseAccountProviderView(
state = aChooseAccountProviderState(
accountProviders = listOf(
ChooseAccountProviderPresenterTest.accountProvider1,
ChooseAccountProviderPresenterTest.accountProvider2,
),
selectedAccountProvider = anAccountProvider(),
eventSink = eventSink,
),
)
rule.onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.title).performClick()
eventSink.assertSingle(ChooseAccountProviderEvents.SelectAccountProvider(ChooseAccountProviderPresenterTest.accountProvider1))
}
@Test
fun `when error is displayed - closing the dialog emits the expected event`() {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>()
rule.setChooseAccountProviderView(
state = aChooseAccountProviderState(
loginMode = AsyncData.Failure(AN_EXCEPTION),
eventSink = eventSink,
),
)
rule.clickOn(CommonStrings.action_ok)
eventSink.assertSingle(ChooseAccountProviderEvents.ClearError)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChooseAccountProviderView(
state: ChooseAccountProviderState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
ChooseAccountProviderView(
state = state,
onBackClick = onBackClick,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
onCreateAccountContinue = onCreateAccountContinue,
)
}
}
}

View File

@@ -9,6 +9,8 @@ package io.element.android.features.login.impl.screens.onboarding
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
@@ -19,6 +21,9 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.A_THROWABLE
@@ -36,6 +41,23 @@ class OnBoardingPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
companion object {
private const val ACCOUNT_PROVIDER_FROM_LINK = AN_ACCOUNT_PROVIDER
private const val ACCOUNT_PROVIDER_FROM_CONFIG = AN_ACCOUNT_PROVIDER_2
private const val ACCOUNT_PROVIDER_FROM_CONFIG_2 = AN_ACCOUNT_PROVIDER_3
}
@Test
fun `present - ensure initial conditions`() {
assertThat(
setOf(
ACCOUNT_PROVIDER_FROM_LINK,
ACCOUNT_PROVIDER_FROM_CONFIG,
ACCOUNT_PROVIDER_FROM_CONFIG_2,
).size
).isEqualTo(3)
}
@Test
fun `present - initial state`() = runTest {
val buildMeta = aBuildMeta(
@@ -50,10 +72,14 @@ class OnBoardingPresenterTest {
val presenter = createPresenter(
buildMeta = buildMeta,
featureFlagService = featureFlagService,
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
),
rageshakeFeatureAvailability = { true },
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.defaultAccountProvider).isNull()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
@@ -74,22 +100,79 @@ class OnBoardingPresenterTest {
}
@Test
fun `present - default account provider`() = runTest {
fun `present - opening the app using link with allowed account provider, and the app does not force account provider`() = runTest {
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
loginHint = null,
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
isAllowedToConnectToHomeserverResult = { true },
),
)
presenter.test {
skipItems(3)
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_LINK)
assertThat(it.canLoginWithQrCode).isFalse()
assertThat(it.canCreateAccount).isFalse()
}
}
}
@Test
fun `present - opening the app using link with not allowed account provider, and the app does not force account provider`() = runTest {
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
loginHint = null,
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
isAllowedToConnectToHomeserverResult = { false },
),
)
presenter.test {
skipItems(1)
awaitItem().also {
assertThat(it.defaultAccountProvider).isNull()
assertThat(it.canLoginWithQrCode).isTrue()
assertThat(it.canCreateAccount).isFalse()
}
}
}
@Test
fun `present - opening the app using link, and the app forces account provider`() = runTest {
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
loginHint = null,
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) },
)
)
presenter.test {
skipItems(1)
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_CONFIG)
assertThat(it.canLoginWithQrCode).isTrue()
assertThat(it.canCreateAccount).isFalse()
}
}
}
@Test
fun `present - default account provider - login and clear error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
@@ -98,11 +181,15 @@ class OnBoardingPresenterTest {
accountProvider = A_HOMESERVER_URL,
loginHint = A_LOGIN_HINT,
),
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
skipItems(3)
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
authenticationService.givenChangeServerError(A_THROWABLE)
@@ -126,12 +213,14 @@ private fun createPresenter(
params: OnBoardingNode.Params = OnBoardingNode.Params(null, null),
buildMeta: BuildMeta = aBuildMeta(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
rageshakeFeatureAvailability: () -> Boolean = { true },
loginHelper: LoginHelper = createLoginHelper(),
) = OnBoardingPresenter(
params = params,
buildMeta = buildMeta,
featureFlagService = featureFlagService,
enterpriseService = enterpriseService,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginHelper = loginHelper,
)

View File

@@ -23,6 +23,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@@ -56,10 +57,28 @@ class OnboardingViewTest {
}
@Test
fun `when can login with QR code - clicking on sign in manually calls the expected callback`() {
ensureCalledOnce { callback ->
fun `when can login with QR code - clicking on sign in manually calls the expected callback - can search account provider`() {
`when can login with QR code - clicking on sign in manually calls the expected callback`(
mustChooseAccountProvider = false,
)
}
@Test
fun `when can login with QR code - clicking on sign in manually calls the expected callback - cannot search account provider`() {
`when can login with QR code - clicking on sign in manually calls the expected callback`(
mustChooseAccountProvider = true,
)
}
private fun `when can login with QR code - clicking on sign in manually calls the expected callback`(
mustChooseAccountProvider: Boolean,
) {
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView(
state = anOnBoardingState(canLoginWithQrCode = true),
state = anOnBoardingState(
canLoginWithQrCode = true,
mustChooseAccountProvider = mustChooseAccountProvider,
),
onSignIn = callback,
)
rule.clickOn(R.string.screen_onboarding_sign_in_manually)
@@ -67,12 +86,28 @@ class OnboardingViewTest {
}
@Test
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`() {
ensureCalledOnce { callback ->
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - can search account provider`() {
`when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
mustChooseAccountProvider = false,
)
}
@Test
fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - cannot search account provider`() {
`when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
mustChooseAccountProvider = true,
)
}
private fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`(
mustChooseAccountProvider: Boolean,
) {
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView(
state = anOnBoardingState(
canLoginWithQrCode = false,
canCreateAccount = false,
mustChooseAccountProvider = mustChooseAccountProvider,
),
onSignIn = callback,
)
@@ -137,7 +172,7 @@ class OnboardingViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),
onSignIn: () -> Unit = EnsureNeverCalled(),
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
onCreateAccount: () -> Unit = EnsureNeverCalled(),
onReportProblem: () -> Unit = EnsureNeverCalled(),
onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(),

View File

@@ -69,6 +69,10 @@ const val A_REDACTION_REASON = "A redaction reason"
const val A_HOMESERVER_URL = "matrix.org"
const val A_HOMESERVER_URL_2 = "matrix-client.org"
const val AN_ACCOUNT_PROVIDER = "matrix.org"
const val AN_ACCOUNT_PROVIDER_2 = "element.io"
const val AN_ACCOUNT_PROVIDER_3 = "other.io"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false)
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE