Consolidate account provider selection into a single editable screen

This commit is contained in:
Jenna Vassar
2026-08-06 11:31:25 -07:00
parent 3c09fb37b9
commit df6ccd9dc3
9 changed files with 226 additions and 171 deletions
@@ -23,7 +23,6 @@ import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
@@ -33,14 +32,12 @@ 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.ElementClassicConnection import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode 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.chooseaccountprovider.ChooseAccountProviderNode
import io.element.android.features.login.impl.screens.classic.ClassicFlowNode import io.element.android.features.login.impl.screens.classic.ClassicFlowNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode 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.createaccount.CreateAccountNode
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.preferences.api.PreferencesEntryPoint 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
@@ -130,12 +127,6 @@ class LoginFlowNode(
@Parcelize @Parcelize
data object ChooseAccountProvider : NavTarget data object ChooseAccountProvider : NavTarget
@Parcelize
data object ChangeAccountProvider : NavTarget
@Parcelize
data object SearchAccountProvider : NavTarget
@Parcelize @Parcelize
data class LoginPassword( data class LoginPassword(
val initialLogin: String = "", val initialLogin: String = "",
@@ -281,43 +272,9 @@ class LoginFlowNode(
override fun navigateToLoginPassword() { override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword()) backstack.push(NavTarget.LoginPassword())
} }
override fun navigateToChangeAccountProvider() {
backstack.push(NavTarget.ChangeAccountProvider)
}
} }
createNode<ConfirmAccountProviderNode>(buildContext, plugins = listOf(inputs, callback)) createNode<ConfirmAccountProviderNode>(buildContext, plugins = listOf(inputs, callback))
} }
NavTarget.ChangeAccountProvider -> {
val callback = object : ChangeAccountProviderNode.Callback {
override fun onDone() {
// Go back to the Account Provider screen
val confirmAccountProvider = backstack.elements.value.firstOrNull {
it.key.navTarget is NavTarget.ConfirmAccountProvider
}?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
backstack.singleTop(confirmAccountProvider)
}
override fun navigateToSearchAccountProvider() {
backstack.push(NavTarget.SearchAccountProvider)
}
}
createNode<ChangeAccountProviderNode>(buildContext, plugins = listOf(callback))
}
NavTarget.SearchAccountProvider -> {
val callback = object : SearchAccountProviderNode.Callback {
override fun onDone() {
// Go back to the Account Provider screen
val confirmAccountProvider = backstack.elements.value.firstOrNull {
it.key.navTarget is NavTarget.ConfirmAccountProvider
}?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
backstack.singleTop(confirmAccountProvider)
}
}
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.LoginPassword -> { is NavTarget.LoginPassword -> {
val inputs = LoginPasswordNode.Inputs( val inputs = LoginPasswordNode.Inputs(
initialLogin = navTarget.initialLogin, initialLogin = navTarget.initialLogin,
@@ -9,6 +9,7 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
sealed interface ConfirmAccountProviderEvents { sealed interface ConfirmAccountProviderEvents {
data class UserInputChanged(val accountProvider: String) : ConfirmAccountProviderEvents
data object Continue : ConfirmAccountProviderEvents data object Continue : ConfirmAccountProviderEvents
data object ClearError : ConfirmAccountProviderEvents data object ClearError : ConfirmAccountProviderEvents
} }
@@ -46,7 +46,6 @@ class ConfirmAccountProviderNode(
fun navigateToLoginPassword() fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails) fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String) fun navigateToCreateAccount(url: String)
fun navigateToChangeAccountProvider()
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -61,7 +60,6 @@ class ConfirmAccountProviderNode(
onOAuthDetails = callback::navigateToOAuth, onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword, onNeedLoginPassword = callback::navigateToLoginPassword,
onCreateAccountContinue = callback::navigateToCreateAccount, onCreateAccountContinue = callback::navigateToCreateAccount,
onChange = callback::navigateToChangeAccountProvider,
onLearnMoreClick = { openLearnMorePage(context) }, onLearnMoreClick = { openLearnMorePage(context) },
) )
} }
@@ -9,14 +9,23 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProvider
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.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@AssistedInject @AssistedInject
@@ -24,6 +33,7 @@ class ConfirmAccountProviderPresenter(
@Assisted private val params: Params, @Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource, private val accountProviderDataSource: AccountProviderDataSource,
private val loginModePresenter: Presenter<LoginModeState>, private val loginModePresenter: Presenter<LoginModeState>,
private val changeServerPresenter: Presenter<ChangeServerState>,
) : Presenter<ConfirmAccountProviderState> { ) : Presenter<ConfirmAccountProviderState> {
data class Params( data class Params(
val isAccountCreation: Boolean, val isAccountCreation: Boolean,
@@ -38,25 +48,49 @@ class ConfirmAccountProviderPresenter(
override fun present(): ConfirmAccountProviderState { override fun present(): ConfirmAccountProviderState {
val accountProvider by accountProviderDataSource.flow.collectAsState() val accountProvider by accountProviderDataSource.flow.collectAsState()
val loginModeState = loginModePresenter.present() val loginModeState = loginModePresenter.present()
val changeServerState = changeServerPresenter.present()
fun handleEvent(event: ConfirmAccountProviderEvents) { // Editable input, seeded from the current (history-defaulted) account provider until the user edits it.
when (event) { var userInput by rememberSaveable { mutableStateOf<String?>(null) }
ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink( val accountProviderInput = userInput ?: accountProvider.url
val latestAccountProviderInput by rememberUpdatedState(accountProviderInput)
// Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up.
LaunchedEffect(changeServerState.changeServerAction) {
if (changeServerState.changeServerAction is AsyncData.Success) {
loginModeState.eventSink(
LoginModeEvent.Submit( LoginModeEvent.Submit(
isAccountCreation = params.isAccountCreation, isAccountCreation = params.isAccountCreation,
homeserverUrl = accountProvider.url, homeserverUrl = latestAccountProviderInput.trim(),
resolvedHomeserverUrl = null, resolvedHomeserverUrl = null,
loginHint = null, loginHint = null,
) )
) )
ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError) }
}
fun handleEvent(event: ConfirmAccountProviderEvents) {
when (event) {
is ConfirmAccountProviderEvents.UserInputChanged -> {
userInput = event.accountProvider
}
// Validate (and persist) the chosen account provider before proceeding. This also enforces the
// enterprise account-provider access control, which the login submit does not do on its own.
ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink(
ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderInput.trim()))
)
ConfirmAccountProviderEvents.ClearError -> {
loginModeState.eventSink(LoginModeEvent.ClearError)
changeServerState.eventSink(ChangeServerEvents.ClearError)
}
} }
} }
return ConfirmAccountProviderState( return ConfirmAccountProviderState(
accountProvider = accountProvider, accountProviderInput = accountProviderInput,
isAccountCreation = params.isAccountCreation, isAccountCreation = params.isAccountCreation,
loginModeState = loginModeState, loginModeState = loginModeState,
changeServerState = changeServerState,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -8,17 +8,23 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
data class ConfirmAccountProviderState( data class ConfirmAccountProviderState(
val accountProvider: AccountProvider, val accountProviderInput: String,
val isAccountCreation: Boolean, val isAccountCreation: Boolean,
val loginModeState: LoginModeState, val loginModeState: LoginModeState,
val changeServerState: ChangeServerState,
val eventSink: (ConfirmAccountProviderEvents) -> Unit val eventSink: (ConfirmAccountProviderEvents) -> Unit
) { ) {
val submitEnabled: Boolean val submitEnabled: Boolean
get() = accountProvider.url.isNotEmpty() && get() = accountProviderInput.isNotBlank() &&
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading) loginModeState.loginMode is AsyncData.Uninitialized &&
changeServerState.changeServerAction is AsyncData.Uninitialized
val isLoading: Boolean
get() = loginModeState.loginMode is AsyncData.Loading ||
changeServerState.changeServerAction is AsyncData.Loading
} }
@@ -9,8 +9,8 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.accountprovider.anAccountProvider import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
@@ -31,13 +31,15 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
} }
private fun aConfirmAccountProviderState( private fun aConfirmAccountProviderState(
accountProvider: AccountProvider = anAccountProvider(), accountProviderInput: String = "matrix.org",
isAccountCreation: Boolean = false, isAccountCreation: Boolean = false,
loginModeState: LoginModeState = aLoginModeState(), loginModeState: LoginModeState = aLoginModeState(),
changeServerState: ChangeServerState = aChangeServerState(),
eventSink: (ConfirmAccountProviderEvents) -> Unit = {}, eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
) = ConfirmAccountProviderState( ) = ConfirmAccountProviderState(
accountProvider = accountProvider, accountProviderInput = accountProviderInput,
isAccountCreation = isAccountCreation, isAccountCreation = isAccountCreation,
loginModeState = loginModeState, loginModeState = loginModeState,
changeServerState = changeServerState,
eventSink = eventSink eventSink = eventSink
) )
@@ -8,29 +8,38 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
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.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
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.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.matrix.api.auth.OAuthDetails import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
@@ -44,14 +53,8 @@ fun ConfirmAccountProviderView(
onNeedLoginPassword: () -> Unit, onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit, onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit, onCreateAccountContinue: (url: String) -> Unit,
onChange: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isLoading by remember(state.loginModeState.loginMode) {
derivedStateOf {
state.loginModeState.loginMode is AsyncData.Loading
}
}
val eventSink = state.eventSink val eventSink = state.eventSink
HeaderFooterPage( HeaderFooterPage(
@@ -62,55 +65,97 @@ fun ConfirmAccountProviderView(
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
title = stringResource( title = stringResource(
id = if (state.isAccountCreation) { id = if (state.isAccountCreation) {
R.string.screen_account_provider_signup_title CommonStrings.screen_select_server_title_register
} else { } else {
R.string.screen_account_provider_signin_title CommonStrings.screen_select_server_title_login
}, }
state.accountProvider.title
), ),
subTitle = stringResource( subTitle = null,
id = if (state.isAccountCreation) {
R.string.screen_account_provider_signup_subtitle
} else {
R.string.screen_account_provider_signin_subtitle
},
)
) )
}, },
footer = { footer = {
ButtonColumnMolecule { ButtonColumnMolecule {
Button( Button(
text = stringResource(id = CommonStrings.action_continue), text = stringResource(id = CommonStrings.action_continue),
showProgress = isLoading, showProgress = state.isLoading,
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, onClick = { eventSink(ConfirmAccountProviderEvents.Continue) },
enabled = state.submitEnabled || isLoading, enabled = state.submitEnabled && !state.isLoading,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag(TestTags.loginContinue) .testTag(TestTags.loginContinue)
) )
TextButton(
text = stringResource(id = R.string.screen_account_provider_change),
onClick = onChange,
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginChangeServer)
)
} }
} }
) { ) {
LoginModeView( var input by textFieldState(stateValue = state.accountProviderInput)
loginMode = state.loginModeState.loginMode, val focusManager = LocalFocusManager.current
onClearError = { TextField(
eventSink(ConfirmAccountProviderEvents.ClearError) value = input,
onValueChange = {
input = it
eventSink(ConfirmAccountProviderEvents.UserInputChanged(it))
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 40.dp)
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.changeServerServer),
label = stringResource(id = CommonStrings.screen_select_server_textfield_header),
supportingText = stringResource(
id = if (state.isAccountCreation) {
CommonStrings.screen_select_server_textfield_footer_register
} else {
CommonStrings.screen_select_server_textfield_footer_login
}
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = {
eventSink(ConfirmAccountProviderEvents.Continue)
}),
singleLine = true,
trailingIcon = if (input.isNotEmpty()) {
{
Box(
Modifier.clickable(
onClickLabel = stringResource(CommonStrings.action_clear),
role = Role.Button,
) {
input = ""
eventSink(ConfirmAccountProviderEvents.UserInputChanged(""))
}
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear)
)
}
}
} else {
null
}, },
onLearnMoreClick = onLearnMoreClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
) )
} }
// Renders the account-provider validation errors, progress and permission dialogs.
// The successful validation itself is observed by the presenter, which then proceeds with the login flow.
ChangeServerView(
state = state.changeServerState,
onLearnMoreClick = onLearnMoreClick,
onSuccess = {},
)
LoginModeView(
loginMode = state.loginModeState.loginMode,
onClearError = { eventSink(ConfirmAccountProviderEvents.ClearError) },
onLearnMoreClick = onLearnMoreClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
LocalNetworkPermissionDialogView( LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog, dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = { onSubmit = {
@@ -133,6 +178,5 @@ internal fun ConfirmAccountProviderViewPreview(
onNeedLoginPassword = {}, onNeedLoginPassword = {},
onCreateAccountContinue = {}, onCreateAccountContinue = {},
onLearnMoreClick = {}, onLearnMoreClick = {},
onChange = {},
) )
} }
@@ -8,15 +8,21 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
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.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.login.LoginMode import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -25,6 +31,9 @@ import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -42,7 +51,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isAccountCreation).isFalse() assertThat(initialState.isAccountCreation).isFalse()
assertThat(initialState.submitEnabled).isTrue() assertThat(initialState.submitEnabled).isTrue()
assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) assertThat(initialState.accountProviderInput).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
} }
} }
@@ -60,13 +69,9 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin) assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -83,13 +88,9 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -108,17 +109,13 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenOAuthCancelError(AN_EXCEPTION) authenticationService.givenOAuthCancelError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.GoBack()) defaultOAuthActionFlow.post(OAuthAction.GoBack())
val cancelFailureState = awaitItem() val cancelFailureState = awaitLoginMode { it is AsyncData.Failure }
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -137,16 +134,12 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOAuthActionFlow.post(OAuthAction.GoBack()) defaultOAuthActionFlow.post(OAuthAction.GoBack())
val cancelFinalState = awaitItem() val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -165,12 +158,11 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() awaitLoginMode { it is AsyncData.Loading }
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true)) defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
val cancelFinalState = awaitItem() val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -189,19 +181,13 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenLoginError(AN_EXCEPTION) authenticationService.givenLoginError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.Success("aUrl")) defaultOAuthActionFlow.post(OAuthAction.Success("aUrl"))
val cancelLoadingState = awaitItem() val cancelFailureState = awaitLoginMode { it is AsyncData.Failure }
assertThat(cancelLoadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -220,16 +206,12 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem() val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOidcActionFlow.post(OAuthAction.Success("aUrl")) defaultOidcActionFlow.post(OAuthAction.Success("aUrl"))
val successSuccessState = awaitItem() val successSuccessState = awaitLoginMode { it is AsyncData.Loading }
assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java) assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -246,10 +228,10 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading // The account provider validation fails, so the login is never attempted.
val failureState = awaitItem() val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
assertThat(failureState.submitEnabled).isFalse() assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
assertThat(failureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) cancelAndIgnoreRemainingEvents()
} }
} }
@@ -266,18 +248,15 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
// Submit will return an error // Submit will return an error while validating the account provider
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
// Check an error was returned // Check an error was returned
val submittedState = awaitItem() val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared // Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem() val clearedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Uninitialized }
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
} }
} }
@@ -286,7 +265,7 @@ class ConfirmAccountProviderPresenterTest {
fun `present - confirm account creation without oidc and without url generates an error`() = runTest { fun `present - confirm account creation without oidc and without url generates an error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService( val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { setHomeserverResult = {
Result.success(aMatrixHomeServerDetails()) Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true))
}, },
) )
val presenter = createConfirmAccountProviderPresenter( val presenter = createConfirmAccountProviderPresenter(
@@ -299,14 +278,14 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
// Check an error was returned // Check an error was returned
val submittedState = awaitItem() val submittedState = awaitLoginMode { it is AsyncData.Failure }
assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
// Assert the error is then cleared // Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem() val clearedState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -324,10 +303,9 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading val submittedState = awaitLoginMode { it is AsyncData.Success }
val submittedState = awaitItem()
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -347,10 +325,9 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading val submittedState = awaitLoginMode { it is AsyncData.Success }
val submittedState = awaitItem()
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@@ -359,7 +336,7 @@ class ConfirmAccountProviderPresenterTest {
val aUrl = "aUrl" val aUrl = "aUrl"
val authenticationService = FakeMatrixAuthenticationService( val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { setHomeserverResult = {
Result.success(aMatrixHomeServerDetails()) Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true))
}, },
) )
val presenter = createConfirmAccountProviderPresenter( val presenter = createConfirmAccountProviderPresenter(
@@ -370,9 +347,26 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading val submittedState = awaitLoginMode { it is AsyncData.Success }
val submittedState = awaitItem()
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl)) assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
cancelAndIgnoreRemainingEvents()
}
}
/**
* Awaits until the emitted state's login mode matches [predicate], skipping the intermediate
* account-provider validation states, and returns that state.
*/
private suspend fun ReceiveTurbine<ConfirmAccountProviderState>.awaitLoginMode(
predicate: (AsyncData<LoginMode>) -> Boolean,
): ConfirmAccountProviderState = awaitState { predicate(it.loginModeState.loginMode) }
private suspend fun ReceiveTurbine<ConfirmAccountProviderState>.awaitState(
predicate: (ConfirmAccountProviderState) -> Boolean,
): ConfirmAccountProviderState {
while (true) {
val item = awaitItem()
if (predicate(item)) return item
} }
} }
@@ -390,5 +384,19 @@ class ConfirmAccountProviderPresenterTest {
oAuthActionFlow = defaultOAuthActionFlow, oAuthActionFlow = defaultOAuthActionFlow,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever, webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
), ),
changeServerPresenter = ChangeServerPresenter(
authenticationService = matrixAuthenticationService,
accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
),
wellknownRetriever = FakeWellknownRetriever(),
),
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(FakePermissionsPresenter()),
),
),
) )
} }
@@ -10,4 +10,9 @@
<string name="a11y_state_collapsed">"Collapsed"</string> <string name="a11y_state_collapsed">"Collapsed"</string>
<string name="a11y_active">Active</string> <string name="a11y_active">Active</string>
<string name="a11y_inactive">Inactive</string> <string name="a11y_inactive">Inactive</string>
<string name="screen_select_server_title_login">"Enter your account provider"</string>
<string name="screen_select_server_title_register">"Choose an account provider"</string>
<string name="screen_select_server_textfield_header">"Account provider"</string>
<string name="screen_select_server_textfield_footer_login">"If your account is provided by your company or a community, then change the account provider (example: companyname.com)."</string>
<string name="screen_select_server_textfield_footer_register">"Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)."</string>
</resources> </resources>