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

@ -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>