Merge pull request #7394 from element-hq/feature/account-provider-selection

Simplify choosing the account provider during sign in, with autocomplete of previously used ones
This commit is contained in:
Benoit Marty
2026-08-19 17:06:47 +02:00
committed by GitHub
78 changed files with 1035 additions and 1619 deletions
-21
View File
@@ -1,21 +0,0 @@
appId: ${MAESTRO_APP_ID}
---
- tapOn:
id: "login-change_server"
- takeScreenshot: build/maestro/200-ChangeServer
- tapOn: "matrix.org"
- tapOn:
id: "login-change_server"
- tapOn: "Other"
- tapOn:
id: "change_server-server"
- inputText: "element"
- hideKeyboard
- extendedWaitUntil:
visible: "element.io"
timeout: 10000
- tapOn: "element.io"
# Revert to matrix.org
- tapOn:
id: "login-change_server"
- tapOn: "matrix.org"
+1 -2
View File
@@ -3,8 +3,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn: "Sign in manually"
- runFlow: ../assertions/assertLoginDisplayed.yaml
- takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml
- runFlow: ../assertions/assertLoginDisplayed.yaml
# The account provider field defaults to matrix.org, so continue straight to the MAS login.
- tapOn:
id: "login-continue"
## MAS page
@@ -1,5 +1,5 @@
appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
visible: "Change account provider"
visible: "Enter your account provider"
timeout: 10000
+2
View File
@@ -69,6 +69,7 @@ dependencies {
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.qrcode)
implementation(projects.libraries.oauth.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.browser)
@@ -80,6 +81,7 @@ dependencies {
testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.preferences.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
@@ -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.push
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.Assisted
import dev.zacsweers.metro.AssistedInject
@@ -33,13 +32,11 @@ import io.element.android.features.login.api.LoginEntryPoint
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.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.classic.ClassicFlowNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
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.searchaccountprovider.SearchAccountProviderNode
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView
@@ -129,12 +126,6 @@ class LoginFlowNode(
@Parcelize
data object ChooseAccountProvider : NavTarget
@Parcelize
data object ChangeAccountProvider : NavTarget
@Parcelize
data object SearchAccountProvider : NavTarget
@Parcelize
data class LoginPassword(
val initialLogin: String = "",
@@ -261,43 +252,9 @@ class LoginFlowNode(
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword())
}
override fun navigateToChangeAccountProvider() {
backstack.push(NavTarget.ChangeAccountProvider)
}
}
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 -> {
val inputs = LoginPasswordNode.Inputs(
initialLogin = navTarget.initialLogin,
@@ -13,27 +13,64 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
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.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@SingleIn(AppScope::class)
@Inject
class AccountProviderDataSource(
enterpriseService: EnterpriseService,
private val enterpriseService: EnterpriseService,
private val appPreferencesStore: AppPreferencesStore,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
) {
private val defaultAccountProvider = createAccountProvider(
// The provider used when the user has not selected one: an enterprise/MDM-configured provider,
// else matrix.org. The most recently used provider (from history) can override it, see init.
private val configuredAccountProvider = createAccountProvider(
url = enterpriseService.homeserverAllowList()
.firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
?: AuthenticationConfig.MATRIX_ORG_URL
)
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(defaultAccountProvider)
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(configuredAccountProvider)
val flow: StateFlow<AccountProvider> = accountProvider.asStateFlow()
// The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]).
// Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a
// successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip).
var lastSelectedAccountProviderUrl: String? = null
private set
init {
// Seed the default from the last used provider, unless the user has already selected one.
coroutineScope.launch {
val default = defaultAccountProvider()
accountProvider.update { current -> if (current == configuredAccountProvider) default else current }
}
}
suspend fun reset() {
accountProvider.emit(defaultAccountProvider)
accountProvider.emit(defaultAccountProvider())
}
/**
* The provider to default to: the most recently used one from history when the user is free to
* connect to any provider, otherwise the enterprise/MDM-configured provider.
*/
private suspend fun defaultAccountProvider(): AccountProvider {
if (!enterpriseService.canConnectToAnyHomeserver()) {
return configuredAccountProvider
}
val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull()
return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider
}
suspend fun setUrl(url: String) {
@@ -41,6 +78,7 @@ class AccountProviderDataSource(
}
suspend fun setAccountProvider(data: AccountProvider) {
lastSelectedAccountProviderUrl = data.url
accountProvider.emit(data)
}
@@ -1,77 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.semantics.Role
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(role = Role.Button) { 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 = { },
)
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.accountprovider
import dev.zacsweers.metro.Inject
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
/**
* Persist the account provider the user just authenticated against, so that it can be offered as
* the default (see [AccountProviderDataSource]) and as an autocomplete suggestion on the next
* sign-in. To be called from the manual sign-in / account creation flows only, on success.
*
* See [AppPreferencesStore.addHomeserverToHistory].
*/
@Inject
class SaveAccountProviderToHistory(
private val accountProviderDataSource: AccountProviderDataSource,
private val appPreferencesStore: AppPreferencesStore,
) {
suspend operator fun invoke() {
// Persist the provider the user actually selected, not the currently-exposed default: the latter can be
// reset back to the history default while the login flow is torn down across an OAuth round-trip.
val url = accountProviderDataSource.lastSelectedAccountProviderUrl
?: accountProviderDataSource.flow.value.url
appPreferencesStore.addHomeserverToHistory(url)
}
}
@@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
@Inject
class ChangeServerPresenter(
@@ -32,7 +33,7 @@ class ChangeServerPresenter(
) : Presenter<ChangeServerState> {
@Composable
override fun present(): ChangeServerState {
val changeServerAction: MutableState<AsyncData<Unit>> = remember {
val changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
@@ -59,7 +60,7 @@ class ChangeServerPresenter(
private suspend fun changeServer(
data: AccountProvider,
changeServerAction: MutableState<AsyncData<Unit>>,
changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>>,
) {
suspend {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
@@ -72,6 +73,8 @@ class ChangeServerPresenter(
}
// Homeserver is valid, remember user choice
accountProviderDataSource.setAccountProvider(data)
// Return the resolved details so the caller can sign in without configuring the homeserver again.
details
}.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from)
}
}
@@ -9,10 +9,13 @@
package io.element.android.features.login.impl.changeserver
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class ChangeServerState(
val changeServerAction: AsyncData<Unit>,
// On success, carries the resolved homeserver details so the caller can proceed with login without
// configuring (and re-networking) the homeserver a second time.
val changeServerAction: AsyncData<MatrixHomeServerDetails>,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (ChangeServerEvents) -> Unit
)
@@ -11,6 +11,7 @@ package io.element.android.features.login.impl.changeserver
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> {
@@ -45,7 +46,7 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
}
fun aChangeServerState(
changeServerAction: AsyncData<Unit> = AsyncData.Uninitialized,
changeServerAction: AsyncData<MatrixHomeServerDetails> = AsyncData.Uninitialized,
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = ChangeServerState(
changeServerAction = changeServerAction,
@@ -7,12 +7,18 @@
package io.element.android.features.login.impl.login
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
sealed interface LoginModeEvent {
data class Submit(
val isAccountCreation: Boolean,
val homeserverUrl: String,
val resolvedHomeserverUrl: String?,
val loginHint: String?,
// When the caller has already configured the homeserver (e.g. the account provider screen validated
// it first), these are its resolved details. When set, login reuses them instead of calling
// setHomeserver again, avoiding a redundant network round-trip.
val preConfiguredDetails: MatrixHomeServerDetails? = null,
) : LoginModeEvent
data object ClearError : LoginModeEvent
@@ -13,6 +13,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
@@ -39,6 +40,7 @@ class LoginModePresenter(
private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
private val saveAccountProviderToHistory: SaveAccountProviderToHistory,
) : Presenter<LoginModeState> {
@Composable
override fun present(): LoginModeState {
@@ -73,26 +75,28 @@ class LoginModePresenter(
private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState<AsyncData<LoginMode>>) {
suspend {
authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
// Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
} else {
throw it
}
}.map { matrixHomeServerDetails ->
when {
matrixHomeServerDetails.supportsOAuthLogin -> {
val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
LoginMode.OAuth(
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
)
// Reuse the details the caller already resolved when it configured the homeserver, so we don't
// run setHomeserver (a network round-trip) again. Otherwise configure the homeserver ourselves.
val matrixHomeServerDetails = request.preConfiguredDetails
?: authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
// Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
} else {
throw it
}
request.isAccountCreation -> throw AccountCreationNotSupported()
matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
else -> error("Unsupported authentication flow")
}.getOrThrow()
when {
matrixHomeServerDetails.supportsOAuthLogin -> {
val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
LoginMode.OAuth(
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
)
}
}.getOrThrow()
request.isAccountCreation -> throw AccountCreationNotSupported()
matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
else -> error("Unsupported authentication flow")
}
}.runCatchingUpdatingState(
state = loginMode,
errorTransform = {
@@ -116,6 +120,7 @@ class LoginModePresenter(
.onSuccess { loginMode.value = AsyncData.Uninitialized }
.onFailure { loginMode.value = AsyncData.Failure(it) }
is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url)
.onSuccess { saveAccountProviderToHistory() }
.onFailure { loginMode.value = AsyncData.Failure(it) }
}
oAuthActionFlow.reset()
@@ -1,14 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.resolver
data class HomeserverData(
// The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url
val homeserverUrl: String,
)
@@ -1,89 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.resolver
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Collections
/**
* Resolve homeserver base on search terms.
*/
@Inject
class HomeserverResolver(
private val dispatchers: CoroutineDispatchers,
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
) {
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext()
val trimmedUserInput = userInput.trim()
val searchTerm = when {
MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty()
trimmedUserInput.startsWith("@") -> return@flow
else -> trimmedUserInput
}
if (searchTerm.length < 4) return@flow
val candidateBase = searchTerm.ensureProtocol().removeSuffix("/")
val list = getUrlCandidates(candidateBase)
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
// Run all the requests in parallel
withContext(dispatchers.io) {
list.parallelMap { url ->
// Skip the compatibility probe if we'd need ACCESS_LOCAL_NETWORK first —
// otherwise the probe hangs on the TCP timeout for ~30s. Emit the URL as a
// candidate directly; the actual sign-in flow triggers the permission prompt.
val shouldRequestPermissionOrIsValid = localNetworkPermissionAdvisor.shouldRequestPermissionFor(url) ||
homeServerLoginCompatibilityChecker.check(url)
.onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
.getOrNull()
?: return@parallelMap
// Emit the list as soon as possible
if (shouldRequestPermissionOrIsValid) {
currentList.add(HomeserverData(homeserverUrl = url))
withContext(flowContext) {
emit(currentList.toList())
}
}
}
}
// If list is empty, and candidateBase is a valid an URL, do not block the user.
// A unsupported homeserver / homeserver not found error will be displayed if the user continues
if (currentList.isEmpty() && candidateBase.isValidUrl()) {
emit(listOf(HomeserverData(homeserverUrl = candidateBase)))
}
}
private fun getUrlCandidates(data: String): List<String> {
return buildList {
if (data.contains(".")) {
// TLD detected?
} else {
add("$data.org")
add("$data.com")
add("$data.io")
}
// Always try what the user has entered
add(data)
}
}
}
@@ -1,51 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.changeaccountprovider
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 dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.callback
@ContributesNode(AppScope::class)
@AssistedInject
class ChangeAccountProviderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ChangeAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onDone()
fun navigateToSearchAccountProvider()
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
ChangeAccountProviderView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onLearnMoreClick = { openLearnMorePage(context) },
onSuccess = callback::onDone,
onOtherProviderClick = callback::navigateToSearchAccountProvider,
)
}
}
@@ -1,57 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.changeaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
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
import io.element.android.libraries.core.uri.ensureProtocol
import kotlinx.collections.immutable.toImmutableList
@Inject
class ChangeAccountProviderPresenter(
private val changeServerPresenter: Presenter<ChangeServerState>,
private val enterpriseService: EnterpriseService,
) : Presenter<ChangeAccountProviderState> {
@Composable
override fun present(): ChangeAccountProviderState {
val staticAccountProviderList = remember {
enterpriseService.homeserverAllowList()
.filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
.map { it.ensureProtocol() }
.ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) }
.map { url ->
AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
)
}
.toImmutableList()
}
val canSearchForAccountProviders = remember {
enterpriseService.canConnectToAnyHomeserver()
}
val changeServerState = changeServerPresenter.present()
return ChangeAccountProviderState(
accountProviders = staticAccountProviderList,
canSearchForAccountProviders = canSearchForAccountProviders,
changeServerState = changeServerState,
)
}
}
@@ -1,19 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.changeaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerState
import kotlinx.collections.immutable.ImmutableList
data class ChangeAccountProviderState(
val accountProviders: ImmutableList<AccountProvider>,
val canSearchForAccountProviders: Boolean,
val changeServerState: ChangeServerState,
)
@@ -1,37 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.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
import kotlinx.collections.immutable.toImmutableList
open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> {
override val values: Sequence<ChangeAccountProviderState>
get() = sequenceOf(
aChangeAccountProviderState(),
aChangeAccountProviderState(canSearchForAccountProviders = false),
// Add other state here
)
}
fun aChangeAccountProviderState(
accountProviders: List<AccountProvider> = listOf(
anAccountProvider()
),
canSearchForAccountProviders: Boolean = true,
changeServerState: ChangeServerState = aChangeServerState(),
) = ChangeAccountProviderState(
accountProviders = accountProviders.toImmutableList(),
canSearchForAccountProviders = canSearchForAccountProviders,
changeServerState = changeServerState,
)
@@ -1,129 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.changeaccountprovider
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.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.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.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
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
*/
@Composable
fun ChangeAccountProviderView(
state: ChangeAccountProviderState,
onBackClick: () -> Unit,
onLearnMoreClick: () -> Unit,
onSuccess: () -> Unit,
onOtherProviderClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackClick) }
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
Column(
modifier = Modifier
.verticalScroll(state = rememberScrollState())
.padding(lazyColumnContentPadding)
) {
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_change_account_provider_title),
subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle),
)
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,
onClick = {
state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem))
}
)
}
// Other
if (state.canSearchForAccountProviders) {
AccountProviderOtherView(
onClick = onOtherProviderClick
)
}
Spacer(Modifier.height(32.dp))
}
ChangeServerView(
state = state.changeServerState,
onLearnMoreClick = onLearnMoreClick,
onSuccess = onSuccess,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ChangeAccountProviderViewPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreview {
ChangeAccountProviderView(
state = state,
onBackClick = { },
onLearnMoreClick = { },
onSuccess = { },
onOtherProviderClick = { },
)
}
@@ -9,6 +9,10 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
sealed interface ConfirmAccountProviderEvents {
data object Continue : ConfirmAccountProviderEvents
data class UserInputChanged(val accountProvider: String) : ConfirmAccountProviderEvents
// Carries the account provider to submit (the current field text, or the accepted autocomplete suggestion),
// captured from the view at click time so it can't lag behind fast input (e.g. keyboard autofill).
data class Continue(val accountProvider: String) : ConfirmAccountProviderEvents
data object ClearError : ConfirmAccountProviderEvents
}
@@ -45,7 +45,6 @@ class ConfirmAccountProviderNode(
interface Callback : Plugin {
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToChangeAccountProvider()
}
private val callback: Callback = callback()
@@ -59,7 +58,6 @@ class ConfirmAccountProviderNode(
modifier = modifier,
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onChange = callback::navigateToChangeAccountProvider,
onLearnMoreClick = { openLearnMorePage(context) },
)
}
@@ -9,21 +9,37 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.AssistedFactory
import dev.zacsweers.metro.AssistedInject
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.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.LoginModeState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@AssistedInject
class ConfirmAccountProviderPresenter(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,
private val loginModePresenter: Presenter<LoginModeState>,
private val changeServerPresenter: Presenter<ChangeServerState>,
private val appPreferencesStore: AppPreferencesStore,
private val enterpriseService: EnterpriseService,
) : Presenter<ConfirmAccountProviderState> {
data class Params(
val isAccountCreation: Boolean,
@@ -38,26 +54,95 @@ class ConfirmAccountProviderPresenter(
override fun present(): ConfirmAccountProviderState {
val accountProvider by accountProviderDataSource.flow.collectAsState()
val loginModeState = loginModePresenter.present()
val changeServerState = changeServerPresenter.present()
val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList())
// The account providers offered for autocomplete: previously-used ones first, then the
// enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in).
// The "*" wildcard is a routing marker, not a real provider, so it is filtered out. Everything is
// rendered without the https:// scheme (added back at connection time).
val autocompleteCandidates = remember(homeserverHistory) {
(homeserverHistory + enterpriseService.homeserverAllowList() + AuthenticationConfig.MATRIX_ORG_URL)
.filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
.map { it.withoutScheme() }
.distinct()
}
// Editable input, seeded from the current (history-defaulted) account provider until the user edits it.
// Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org".
var userInput by rememberSaveable { mutableStateOf<String?>(null) }
val accountProviderInput = userInput ?: accountProvider.url.withoutScheme()
// Offer the first candidate that the current input is a (case-insensitive) prefix of.
val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) {
val input = accountProviderInput.trim()
input.takeIf { it.isNotEmpty() }
?.let { prefix ->
autocompleteCandidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) }
}
}
// The account provider submitted via Continue (carried by the event, so it reflects the exact field
// text at click time); used to actually sign in once validation succeeds.
var submittedAccountProvider by remember { mutableStateOf<String?>(null) }
val latestSubmittedAccountProvider by rememberUpdatedState(submittedAccountProvider)
// Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up.
// Reuse the details resolved while validating, so login does not configure (and re-network) the homeserver again.
LaunchedEffect(changeServerState.changeServerAction) {
val homeServerDetails = changeServerState.changeServerAction.dataOrNull()
if (homeServerDetails != null) {
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = params.isAccountCreation,
homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(),
resolvedHomeserverUrl = null,
loginHint = null,
preConfiguredDetails = homeServerDetails,
)
)
}
}
fun handleEvent(event: ConfirmAccountProviderEvents) {
when (event) {
ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = params.isAccountCreation,
homeserverUrl = accountProvider.url,
resolvedHomeserverUrl = null,
loginHint = null,
is ConfirmAccountProviderEvents.UserInputChanged -> {
userInput = event.accountProvider
}
// Validate (and persist) the chosen account provider before proceeding. This also enforces the
// account-provider access control, which the login submit does not run on its own.
is ConfirmAccountProviderEvents.Continue -> {
// Apply the accepted account provider (any accepted completion) back into the field so it
// renders the full server rather than the typed prefix, and survives the OAuth round-trip.
val accountProvider = event.accountProvider.trim()
userInput = accountProvider
// Default the scheme to https:// so entering a bare host (e.g. "matrix.org") works.
val accountProviderUrl = accountProvider.ensureProtocol()
submittedAccountProvider = accountProviderUrl
changeServerState.eventSink(
ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl))
)
)
ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
}
ConfirmAccountProviderEvents.ClearError -> {
loginModeState.eventSink(LoginModeEvent.ClearError)
changeServerState.eventSink(ChangeServerEvents.ClearError)
}
}
}
return ConfirmAccountProviderState(
accountProvider = accountProvider,
accountProviderInput = accountProviderInput,
accountProviderSuggestion = accountProviderSuggestion,
isAccountCreation = params.isAccountCreation,
loginModeState = loginModeState,
changeServerState = changeServerState,
eventSink = ::handleEvent,
)
}
}
/**
* The host part of an account provider URL, i.e. the value with any `https://` / `http://` scheme removed.
* Returns the input unchanged when it has no scheme (e.g. `matrix.org`).
*/
private fun String.withoutScheme(): String = substringAfter("://")
@@ -8,17 +8,29 @@
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.libraries.architecture.AsyncData
data class ConfirmAccountProviderState(
val accountProvider: AccountProvider,
val accountProviderInput: String,
// The full account provider from history that the current input is a prefix of, offered as inline autocomplete.
val accountProviderSuggestion: String?,
val isAccountCreation: Boolean,
val loginModeState: LoginModeState,
val changeServerState: ChangeServerState,
val eventSink: (ConfirmAccountProviderEvents) -> Unit
) {
val submitEnabled: Boolean
get() = accountProvider.url.isNotEmpty() &&
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
get() = accountProviderInput.isNotBlank() &&
loginModeState.loginMode is AsyncData.Uninitialized &&
changeServerState.changeServerAction is AsyncData.Uninitialized
val isLoading: Boolean
get() = loginModeState.loginMode is AsyncData.Loading ||
changeServerState.changeServerAction is AsyncData.Loading
val isShowingError: Boolean
get() = loginModeState.loginMode is AsyncData.Failure ||
changeServerState.changeServerAction is AsyncData.Failure
}
@@ -9,8 +9,8 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
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
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.screens.createaccount.AccountCreationNotSupported
@@ -20,6 +20,10 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
override val values: Sequence<ConfirmAccountProviderState>
get() = sequenceOf(
aConfirmAccountProviderState(),
aConfirmAccountProviderState(
accountProviderInput = "element",
accountProviderSuggestion = "element.io",
),
aConfirmAccountProviderState(
isAccountCreation = true,
),
@@ -31,13 +35,17 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
}
private fun aConfirmAccountProviderState(
accountProvider: AccountProvider = anAccountProvider(),
accountProviderInput: String = "matrix.org",
accountProviderSuggestion: String? = null,
isAccountCreation: Boolean = false,
loginModeState: LoginModeState = aLoginModeState(),
changeServerState: ChangeServerState = aChangeServerState(),
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
) = ConfirmAccountProviderState(
accountProvider = accountProvider,
accountProviderInput = accountProviderInput,
accountProviderSuggestion = accountProviderSuggestion,
isAccountCreation = isAccountCreation,
loginModeState = loginModeState,
changeServerState = changeServerState,
eventSink = eventSink
)
@@ -8,50 +8,109 @@
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.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
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.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.login.LoginModeEvent
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.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.form.textFieldState
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.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.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Account provider entry screen, shared by sign in and account creation.
* Figma (Compound Android Templates):
* - Sign in: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10251
* - Create account: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=41-10239
*/
@Composable
fun ConfirmAccountProviderView(
state: ConfirmAccountProviderState,
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginModeState.loginMode) {
derivedStateOf {
state.loginModeState.loginMode is AsyncData.Loading
}
}
val eventSink = state.eventSink
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
var input by textFieldState(stateValue = state.accountProviderInput)
// Inline autocomplete: the un-typed remainder of the suggested account provider (rendered in grey and
// accepted on Continue / Enter). Only valid while the suggestion still starts with the current input.
val suggestionSuffix = state.accountProviderSuggestion
?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) }
?.substring(input.length)
.orEmpty()
// The Continue button lives in a footer slot that is not recomposed while the user types, so a direct
// capture of the field text (via ::submit) goes stale and can submit an empty/prefilled value. Reading
// the value through rememberUpdatedState keeps it current regardless of which composition scope reads it.
val accountProviderToSubmit by rememberUpdatedState(input + suggestionSuffix)
fun submit() {
// Dismiss the keyboard and release focus while the account provider is being validated.
focusManager.clearFocus(force = true)
// Submit the exact field text (plus any accepted suggestion).
eventSink(ConfirmAccountProviderEvents.Continue(accountProviderToSubmit))
}
// Once a validation / login error has been dismissed, return focus and the keyboard to the field so
// the user can correct the account provider and retry.
var wasShowingError by remember { mutableStateOf(false) }
LaunchedEffect(state.isShowingError) {
if (wasShowingError && !state.isShowingError) {
focusRequester.requestFocus()
keyboardController?.show()
}
wasShowingError = state.isShowingError
}
HeaderFooterPage(
modifier = modifier,
@@ -61,54 +120,113 @@ fun ConfirmAccountProviderView(
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
title = stringResource(
id = if (state.isAccountCreation) {
R.string.screen_account_provider_signup_title
CommonStrings.screen_change_server_title_register
} else {
R.string.screen_account_provider_signin_title
},
state.accountProvider.title
CommonStrings.screen_change_server_title_login
}
),
subTitle = stringResource(
id = if (state.isAccountCreation) {
R.string.screen_account_provider_signup_subtitle
} else {
R.string.screen_account_provider_signin_subtitle
},
)
subTitle = null,
)
},
footer = {
ButtonColumnMolecule {
Button(
text = stringResource(id = CommonStrings.action_continue),
showProgress = isLoading,
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
enabled = state.submitEnabled || isLoading,
showProgress = state.isLoading,
onClick = ::submit,
enabled = state.submitEnabled && !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
TextButton(
text = stringResource(id = R.string.screen_account_provider_change),
onClick = onChange,
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginChangeServer)
)
}
}
) {
LoginModeView(
loginMode = state.loginModeState.loginMode,
onClearError = {
eventSink(ConfirmAccountProviderEvents.ClearError)
val ghostColor = ElementTheme.colors.textSecondary
val ghostTransformation = remember(suggestionSuffix, ghostColor) {
GhostSuffixVisualTransformation(suggestionSuffix, ghostColor)
}
TextField(
value = input,
onValueChange = {
input = it
eventSink(ConfirmAccountProviderEvents.UserInputChanged(it))
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 40.dp)
.focusRequester(focusRequester)
.testTag(TestTags.changeServerServer),
label = stringResource(id = CommonStrings.screen_change_server_textfield_header),
placeholder = stringResource(id = CommonStrings.screen_change_server_textfield_placeholder),
supportingText = stringResource(
id = if (state.isAccountCreation) {
CommonStrings.screen_change_server_textfield_footer_register
} else {
CommonStrings.screen_change_server_textfield_footer_login
}
),
visualTransformation = ghostTransformation,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
if (suggestionSuffix.isNotEmpty()) {
// A completion is showing: accept it into the field and drop focus, dismissing the
// keyboard. This shows the completed server (with no stray mid-text cursor, since the
// field is no longer focused) ready to sign in with Continue. When no completion is
// showing, Done submits directly.
val accepted = input + suggestionSuffix
input = accepted
eventSink(ConfirmAccountProviderEvents.UserInputChanged(accepted))
focusManager.clearFocus(force = true)
} else {
submit()
}
}
),
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,
)
}
// 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,
)
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
@@ -120,6 +238,29 @@ fun ConfirmAccountProviderView(
)
}
/**
* Appends [suffix] (in [color]) after the user's text as a non-editable inline autocomplete hint.
* The underlying field value stays limited to what the user actually typed, so the cursor can never
* enter the suggested region.
*/
private class GhostSuffixVisualTransformation(
private val suffix: String,
private val color: Color,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
if (suffix.isEmpty()) return TransformedText(text, OffsetMapping.Identity)
val transformed = buildAnnotatedString {
append(text)
withStyle(SpanStyle(color = color)) { append(suffix) }
}
val offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int) = offset
override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(text.length)
}
return TransformedText(transformed, offsetMapping)
}
}
@PreviewsDayNight
@Composable
internal fun ConfirmAccountProviderViewPreview(
@@ -130,6 +271,5 @@ internal fun ConfirmAccountProviderViewPreview(
onOAuthDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
onChange = {},
)
}
@@ -20,6 +20,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -33,6 +34,7 @@ class LoginPasswordPresenter(
private val initialLogin: String,
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
private val saveAccountProviderToHistory: SaveAccountProviderToHistory,
) : Presenter<LoginPasswordState> {
@AssistedFactory
interface Factory {
@@ -83,6 +85,7 @@ class LoginPasswordPresenter(
loggedInState.value = AsyncData.Loading()
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
saveAccountProviderToHistory()
loggedInState.value = AsyncData.Success(sessionId)
}
.onFailure { failure ->
@@ -60,7 +60,7 @@ class OnBoardingPresenter(
override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope()
val forcedAccountProvider = remember {
// If defaultHomeserverList() returns a singleton list, this is the default account provider.
// If homeserverAllowList() 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.homeserverAllowList().singleOrNull()
}
@@ -1,17 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.searchaccountprovider
sealed interface SearchAccountProviderEvents {
/**
* The user has typed something, expect to get a list of matching account provider results
* in the state.
*/
data class UserInput(val input: String) : SearchAccountProviderEvents
}
@@ -1,49 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.searchaccountprovider
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 dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.callback
@ContributesNode(AppScope::class)
@AssistedInject
class SearchAccountProviderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SearchAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onDone()
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
SearchAccountProviderView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onLearnMoreClick = { openLearnMorePage(context) },
onSuccess = callback::onDone,
)
}
}
@@ -1,77 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.searchaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.features.login.impl.resolver.HomeserverResolver
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Inject
class SearchAccountProviderPresenter(
private val homeserverResolver: HomeserverResolver,
private val changeServerPresenter: Presenter<ChangeServerState>,
) : Presenter<SearchAccountProviderState> {
@Composable
override fun present(): SearchAccountProviderState {
var userInput by rememberSaveable {
mutableStateOf("")
}
val changeServerState = changeServerPresenter.present()
val data: MutableState<AsyncData<List<HomeserverData>>> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
LaunchedEffect(userInput) {
onUserInput(userInput, data)
}
fun handleEvent(event: SearchAccountProviderEvents) {
when (event) {
is SearchAccountProviderEvents.UserInput -> {
userInput = event.input
}
}
}
return SearchAccountProviderState(
userInput = userInput,
userInputResult = data.value,
changeServerState = changeServerState,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.onUserInput(userInput: String, data: MutableState<AsyncData<List<HomeserverData>>>) = launch {
data.value = AsyncData.Uninitialized
// Debounce
delay(500)
data.value = AsyncData.Loading()
homeserverResolver.resolve(userInput).collect {
data.value = AsyncData.Success(it)
}
if (data.value !is AsyncData.Success) {
data.value = AsyncData.Uninitialized
}
}
}
@@ -1,20 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.searchaccountprovider
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.libraries.architecture.AsyncData
data class SearchAccountProviderState(
val userInput: String,
val userInputResult: AsyncData<List<HomeserverData>>,
val changeServerState: ChangeServerState,
val eventSink: (SearchAccountProviderEvents) -> Unit
)
@@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.searchaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.libraries.architecture.AsyncData
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
override val values: Sequence<SearchAccountProviderState>
get() = sequenceOf(
aSearchAccountProviderState(),
aSearchAccountProviderState(userInputResult = AsyncData.Success(aHomeserverDataList())),
// Add other state here
)
}
fun aSearchAccountProviderState(
userInput: String = "",
userInputResult: AsyncData<List<HomeserverData>> = AsyncData.Uninitialized,
) = SearchAccountProviderState(
userInput = userInput,
userInputResult = userInputResult,
changeServerState = aChangeServerState(),
eventSink = {}
)
fun aHomeserverDataList(): List<HomeserverData> {
return listOf(
aHomeserverData(homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL),
aHomeserverData(homeserverUrl = "https://no.sliding.sync"),
aHomeserverData(homeserverUrl = "https://invalid"),
)
}
fun aHomeserverData(
homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL,
): HomeserverData {
return HomeserverData(homeserverUrl = homeserverUrl)
}
@@ -1,216 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.searchaccountprovider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
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.unit.dp
import io.element.android.appconfig.AuthenticationConfig
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.AccountProviderView
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.resolver.HomeserverData
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.components.form.textFieldState
import io.element.android.libraries.designsystem.modifiers.bringIntoViewOnImeVisible
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435
*/
@Composable
fun SearchAccountProviderView(
state: SearchAccountProviderState,
onBackClick: () -> Unit,
onLearnMoreClick: () -> Unit,
onSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
val eventSink = state.eventSink
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackClick) }
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
item {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
title = stringResource(id = R.string.screen_account_provider_form_title),
subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
)
}
item {
// TextInput
var userInputState by textFieldState(stateValue = state.userInput)
val focusManager = LocalFocusManager.current
TextField(
value = userInputState,
// readOnly = isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.bringIntoViewOnImeVisible()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.changeServerServer),
onValueChange = {
userInputState = it
eventSink(SearchAccountProviderEvents.UserInput(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = {
focusManager.moveFocus(FocusDirection.Down)
}),
singleLine = true,
trailingIcon = if (userInputState.isNotEmpty()) {
{
Box(
Modifier.clickable(
onClickLabel = stringResource(CommonStrings.action_clear),
role = Role.Button,
) {
userInputState = ""
eventSink(SearchAccountProviderEvents.UserInput(""))
}
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear)
)
}
}
} else {
null
},
supportingText = stringResource(id = R.string.screen_account_provider_form_notice),
)
}
when (state.userInputResult) {
is AsyncData.Failure -> {
// Ignore errors (let the user type more chars)
}
is AsyncData.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxSize()
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
is AsyncData.Success -> {
items(state.userInputResult.data) { homeserverData ->
val item = homeserverData.toAccountProvider()
AccountProviderView(
item = item,
onClick = {
state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item))
}
)
}
}
AsyncData.Uninitialized -> Unit
}
item {
Spacer(Modifier.height(32.dp))
}
}
ChangeServerView(
state = state.changeServerState,
onLearnMoreClick = onLearnMoreClick,
onSuccess = onSuccess,
)
}
}
}
@Composable
private fun HomeserverData.toAccountProvider(): AccountProvider {
val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL
return AccountProvider(
url = homeserverUrl,
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
// There is no need to know for other servers right now
isPublic = isMatrixOrg,
isMatrixOrg = isMatrixOrg,
)
}
@PreviewsDayNight
@Composable
internal fun SearchAccountProviderViewPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreview {
SearchAccountProviderView(
state = state,
onBackClick = {},
onLearnMoreClick = {},
onSuccess = {},
)
}
@@ -12,9 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
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.anAccountProviderDataSource
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.features.preferences.test.FakePreferencesEntryPoint
import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
@@ -38,7 +37,7 @@ class DefaultLoginEntryPointTest {
LoginFlowNode(
buildContext = buildContext,
plugins = plugins,
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
accountProviderDataSource = anAccountProviderDataSource(),
oAuthActionFlow = FakeOAuthActionFlow(),
appCoroutineScope = backgroundScope,
elementClassicConnection = FakeElementClassicConnection(),
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.accountprovider
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
fun anAccountProviderDataSource(
enterpriseService: EnterpriseService = FakeEnterpriseService(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
) = AccountProviderDataSource(
enterpriseService = enterpriseService,
appPreferencesStore = appPreferencesStore,
coroutineScope = coroutineScope,
)
@@ -13,6 +13,7 @@ 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.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -24,7 +25,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - initial state`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService())
val sut = anAccountProviderDataSource()
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
@@ -41,10 +42,10 @@ class AccountProviderDataSourceTest {
@Test
fun `present - initial state - matrix org`() = runTest {
val sut = AccountProviderDataSource(
FakeEnterpriseService(
val sut = anAccountProviderDataSource(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(AuthenticationConfig.MATRIX_ORG_URL) }
)
),
)
sut.flow.test {
val initialState = awaitItem()
@@ -62,10 +63,10 @@ class AccountProviderDataSourceTest {
@Test
fun `present - ensure that default homeserver is not star char`() = runTest {
val sut = AccountProviderDataSource(
FakeEnterpriseService(
val sut = anAccountProviderDataSource(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) }
)
),
)
sut.flow.test {
val initialState = awaitItem()
@@ -83,7 +84,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - user change and reset`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService())
val sut = anAccountProviderDataSource()
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
@@ -106,7 +107,7 @@ class AccountProviderDataSourceTest {
@Test
fun `present - set url and reset`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService())
val sut = anAccountProviderDataSource()
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
@@ -126,4 +127,47 @@ class AccountProviderDataSourceTest {
assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
}
}
@Test
fun `present - defaults to the most recently used provider from history`() = runTest {
val sut = anAccountProviderDataSource(
appPreferencesStore = InMemoryAppPreferencesStore(
homeserverHistory = listOf("https://example.com", "https://matrix.org"),
),
)
sut.flow.test {
assertThat(awaitItem().url).isEqualTo("https://example.com")
}
}
@Test
fun `present - history is ignored when the account provider is enforced`() = runTest {
val sut = anAccountProviderDataSource(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf("https://enforced.org") }
),
appPreferencesStore = InMemoryAppPreferencesStore(
homeserverHistory = listOf("https://example.com"),
),
)
sut.flow.test {
assertThat(awaitItem().url).isEqualTo("https://enforced.org")
}
}
@Test
fun `present - reset returns to the most recently used provider from history`() = runTest {
val sut = anAccountProviderDataSource(
appPreferencesStore = InMemoryAppPreferencesStore(
homeserverHistory = listOf("https://example.com"),
),
)
sut.flow.test {
assertThat(awaitItem().url).isEqualTo("https://example.com")
sut.setUrl("https://other.com")
assertThat(awaitItem().url).isEqualTo("https://other.com")
sut.reset()
assertThat(awaitItem().url).isEqualTo("https://example.com")
}
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.accountprovider
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SaveAccountProviderToHistoryTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `invoke saves the current account provider url to history`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore()
val accountProviderDataSource = anAccountProviderDataSource()
accountProviderDataSource.setUrl("https://example.com")
val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore)
sut()
assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
.containsExactly("https://example.com")
}
@Test
fun `invoke saves the selected provider even after the exposed default has been reset`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore()
val accountProviderDataSource = anAccountProviderDataSource(appPreferencesStore = appPreferencesStore)
accountProviderDataSource.setUrl("https://example.com")
// Simulate the login flow being torn down (e.g. across an OAuth round-trip), which reverts the
// exposed default; the user's selection must still be the one saved to history.
accountProviderDataSource.reset()
val sut = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore)
sut()
assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
.containsExactly("https://example.com")
}
}
@@ -14,6 +14,7 @@ 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.AccountProvider
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.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.libraries.architecture.AsyncData
@@ -66,7 +67,7 @@ class ChangeServerPresenterTest {
val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(Unit))
assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true)))
}
}
@@ -244,13 +245,13 @@ class ChangeServerPresenterTest {
while (finalState.changeServerAction !is AsyncData.Success) {
finalState = awaitItem()
}
assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(Unit))
assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Success(aMatrixHomeServerDetails(supportsOAuthLogin = true)))
}
}
private fun createPresenter(
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenter: FakePermissionsPresenter = FakePermissionsPresenter(),
@@ -1,108 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.resolver
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class HomeserverResolverTest {
@Test
fun `resolve - a server name is used as is`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - an url is used as is`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("https://example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - a user id resolves the server name of the user id`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - a user id with a port resolves the server name of the user id`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org:8448") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:example.org:8448").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org:8448")),
)
}
@Test
fun `resolve - an incomplete user id does not resolve anything`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice").test {
awaitComplete()
}
sut.resolve("@alice:").test {
awaitComplete()
}
checkResult.assertions().isNeverCalled()
}
@Test
fun `resolve - a user id with a too short server name does not resolve anything`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:a.b").test {
awaitComplete()
}
checkResult.assertions().isNeverCalled()
}
private fun TestScope.createHomeserverResolver(
checkResult: (String) -> Result<Boolean>,
) = HomeserverResolver(
dispatchers = testCoroutineDispatchers(),
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
)
}
@@ -1,113 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.changeaccountprovider
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 io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ChangeAccountProviderPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = ChangeAccountProviderPresenter(
changeServerPresenter = { aChangeServerState() },
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { emptyList() }
),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.accountProviders).isEqualTo(
listOf(
AccountProvider(
url = "https://matrix.org",
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
)
)
)
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)
}
),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.accountProviders).isEqualTo(
listOf(
AccountProvider(
url = "https://matrix.org",
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
),
AccountProvider(
url = "https://element.io",
title = "element.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
)
)
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)
}
),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.accountProviders).isEqualTo(
listOf(
AccountProvider(
url = "https://matrix.org",
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
)
)
)
assertThat(initialState.canSearchForAccountProviders).isTrue()
}
}
}
@@ -8,8 +8,8 @@
package io.element.android.features.login.impl.screens.classic.loginwithclassic
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
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.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
@@ -249,7 +249,7 @@ private fun createPresenter(
navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(),
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
isEnterpriseBuild: Boolean = false,
) = LoginWithClassicPresenter(
userId = userId,
@@ -8,10 +8,16 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import app.cash.turbine.ReceiveTurbine
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.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
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.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.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
@@ -23,6 +29,11 @@ import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
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.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
@@ -40,7 +51,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem()
assertThat(initialState.isAccountCreation).isFalse()
assertThat(initialState.submitEnabled).isTrue()
assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
assertThat(initialState.accountProviderInput).isEqualTo("matrix.org")
assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -57,14 +68,10 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
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)
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
cancelAndIgnoreRemainingEvents()
}
}
@@ -80,14 +87,10 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
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)
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@@ -105,18 +108,14 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
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)
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenOAuthCancelError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.GoBack())
val cancelFailureState = awaitItem()
val cancelFailureState = awaitLoginMode { it is AsyncData.Failure }
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@@ -134,17 +133,13 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
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)
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOAuthActionFlow.post(OAuthAction.GoBack())
val cancelFinalState = awaitItem()
val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(cancelFinalState.loginModeState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@@ -162,13 +157,12 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
awaitLoginMode { it is AsyncData.Loading }
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)
cancelAndIgnoreRemainingEvents()
}
}
@@ -186,20 +180,14 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
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)
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenLoginError(AN_EXCEPTION)
defaultOAuthActionFlow.post(OAuthAction.Success("aUrl"))
val cancelLoadingState = awaitItem()
assertThat(cancelLoadingState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val cancelFailureState = awaitItem()
val cancelFailureState = awaitLoginMode { it is AsyncData.Failure }
assertThat(cancelFailureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@@ -217,17 +205,13 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
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)
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOidcActionFlow.post(OAuthAction.Success("aUrl"))
val successSuccessState = awaitItem()
val successSuccessState = awaitLoginMode { it is AsyncData.Loading }
assertThat(successSuccessState.loginModeState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@@ -243,11 +227,11 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val failureState = awaitItem()
assertThat(failureState.submitEnabled).isFalse()
assertThat(failureState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// The account provider validation fails, so the login is never attempted.
val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
cancelAndIgnoreRemainingEvents()
}
}
@@ -264,18 +248,15 @@ class ConfirmAccountProviderPresenterTest {
presenter.test {
val initialState = awaitItem()
// Submit will return an error
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
// Submit will return an error while validating the account provider
initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem()
val clearedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Uninitialized }
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -284,7 +265,7 @@ class ConfirmAccountProviderPresenterTest {
fun `present - confirm account creation without oidc generates an error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
Result.success(aMatrixHomeServerDetails())
Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
@@ -293,15 +274,15 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// Check an error was returned
val submittedState = awaitItem()
val submittedState = awaitLoginMode { it is AsyncData.Failure }
assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem()
val clearedState = awaitLoginMode { it is AsyncData.Uninitialized }
assertThat(clearedState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
cancelAndIgnoreRemainingEvents()
}
}
@@ -318,25 +299,214 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Success::class.java)
initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val submittedState = awaitLoginMode { it is AsyncData.Success }
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - offers an autocomplete suggestion from the account provider history`() = runTest {
val presenter = createConfirmAccountProviderPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(
homeserverHistory = listOf("https://randomcommunity.org"),
),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.accountProviderSuggestion).isNull()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
val suggestionState = awaitState { it.accountProviderInput == "random" }
// Completion is offered without the https:// scheme, even though history stores the full URL.
assertThat(suggestionState.accountProviderSuggestion).isEqualTo("randomcommunity.org")
}
}
@Test
fun `present - continue accepts the autocomplete suggestion rather than the typed prefix`() = runTest {
val submittedUrls = mutableListOf<String>()
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { url ->
submittedUrls.add(url)
Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
matrixAuthenticationService = authenticationService,
appPreferencesStore = InMemoryAppPreferencesStore(
homeserverHistory = listOf("https://randomcommunity.org"),
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
val suggestionState = awaitState { it.accountProviderSuggestion != null }
suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org"))
awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - continue applies the completed account provider back into the field`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) },
)
val presenter = createConfirmAccountProviderPresenter(
matrixAuthenticationService = authenticationService,
appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = listOf("https://randomcommunity.org")),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
val suggestionState = awaitState { it.accountProviderSuggestion != null }
suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org"))
// The field now renders the full accepted server rather than the typed prefix.
val appliedState = awaitState { it.accountProviderInput == "randomcommunity.org" }
assertThat(appliedState.accountProviderInput).isEqualTo("randomcommunity.org")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - offers the enterprise allow-list servers as autocomplete suggestions`() = runTest {
val presenter = createConfirmAccountProviderPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
enterpriseService = FakeEnterpriseService(
// Preferred servers plus the "*" (any) wildcard: the wildcard is filtered out, the rest complete.
defaultHomeserverListResult = { listOf("https://element.io", EnterpriseService.ANY_ACCOUNT_PROVIDER) },
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("ele"))
val suggestionState = awaitState { it.accountProviderInput == "ele" }
assertThat(suggestionState.accountProviderSuggestion).isEqualTo("element.io")
}
}
@Test
fun `present - offers matrix_org without the scheme as an autocomplete suggestion`() = runTest {
val presenter = createConfirmAccountProviderPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("matr"))
val suggestionState = awaitState { it.accountProviderInput == "matr" }
assertThat(suggestionState.accountProviderSuggestion).isEqualTo("matrix.org")
}
}
@Test
fun `present - does not autocomplete a scheme-prefixed input`() = runTest {
// Candidates are bare hosts, so a scheme-prefixed input never completes them - the user completes by
// typing the bare host. This also avoids ever suggesting an insecure http scheme.
val presenter = createConfirmAccountProviderPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://mat"))
assertThat(awaitState { it.accountProviderInput == "https://mat" }.accountProviderSuggestion).isNull()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("http://mat"))
assertThat(awaitState { it.accountProviderInput == "http://mat" }.accountProviderSuggestion).isNull()
}
}
@Test
fun `present - continue prepends https to a bare account provider`() = runTest {
val submittedUrls = mutableListOf<String>()
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { url ->
submittedUrls.add(url)
Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
matrixAuthenticationService = authenticationService,
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue("matrix.org"))
awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls.first()).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - continue configures the homeserver only once`() = runTest {
// The consolidated Continue flow validates the account provider and then signs in. Login reuses the
// details resolved during validation, so setHomeserver runs exactly once rather than once per phase.
val submittedUrls = mutableListOf<String>()
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { url ->
submittedUrls.add(url)
Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
matrixAuthenticationService = authenticationService,
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls).containsExactly(AuthenticationConfig.MATRIX_ORG_URL)
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
}
}
private fun createConfirmAccountProviderPresenter(
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
) = ConfirmAccountProviderPresenter(
params = params,
accountProviderDataSource = accountProviderDataSource,
appPreferencesStore = appPreferencesStore,
enterpriseService = enterpriseService,
loginModePresenter = createLoginModePresenter(
authenticationService = matrixAuthenticationService,
oAuthActionFlow = defaultOAuthActionFlow,
),
changeServerPresenter = ChangeServerPresenter(
authenticationService = matrixAuthenticationService,
accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
isElementProEnforcedResult = { false },
),
isEnterpriseBuild = { false },
),
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(FakePermissionsPresenter()),
),
),
)
}
@@ -10,8 +10,9 @@ package io.element.android.features.login.impl.screens.loginpassword
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -21,8 +22,11 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.A_USER_NAME_2
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -101,6 +105,32 @@ class LoginPasswordPresenterTest {
}
}
@Test
fun `present - successful login saves the account provider to history`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
Result.success(aMatrixHomeServerDetails())
},
)
val appPreferencesStore = InMemoryAppPreferencesStore()
createLoginPasswordPresenter(
authenticationService = authenticationService,
appPreferencesStore = appPreferencesStore,
).test {
val initialState = awaitItem()
initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
skipItems(1)
val loginAndPasswordState = awaitItem()
loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit)
skipItems(1)
val loggedInState = awaitItem()
assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID))
assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
.containsExactly(AuthenticationConfig.MATRIX_ORG_URL)
}
}
@Test
fun `present - submit with error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
@@ -157,10 +187,12 @@ class LoginPasswordPresenterTest {
private fun createLoginPasswordPresenter(
initialLogin: String = "",
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
): LoginPasswordPresenter = LoginPasswordPresenter(
initialLogin = initialLogin,
authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource,
saveAccountProviderToHistory = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore),
)
}
@@ -16,6 +16,8 @@ import io.element.android.features.enterprise.api.IsEnterpriseBuild
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.SaveAccountProviderToHistory
import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.libraries.architecture.AsyncData
@@ -36,6 +38,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
@@ -248,7 +251,7 @@ class OnBoardingPresenterTest {
Result.failure(AN_EXCEPTION)
},
)
val accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService())
val accountProviderDataSource = anAccountProviderDataSource()
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
@@ -299,7 +302,7 @@ private fun createPresenter(
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null },
sessionStore: SessionStore = InMemorySessionStore(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(),
) = OnBoardingPresenter(
params = params,
buildMeta = buildMeta,
@@ -322,6 +325,8 @@ fun createLoginModePresenter(
FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory: PermissionsPresenter.Factory =
FakePermissionsPresenterFactory(),
saveAccountProviderToHistory: SaveAccountProviderToHistory =
SaveAccountProviderToHistory(anAccountProviderDataSource(), InMemoryAppPreferencesStore()),
): LoginModePresenter = LoginModePresenter(
oAuthActionFlow = oAuthActionFlow,
authenticationService = authenticationService,
@@ -329,4 +334,5 @@ fun createLoginModePresenter(
advisor = localNetworkPermissionAdvisor,
permissionsPresenterFactory = permissionsPresenterFactory,
),
saveAccountProviderToHistory = saveAccountProviderToHistory,
)
@@ -1,179 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.searchaccountprovider
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverResolver
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SearchAccountProviderPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) })
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.userInput).isEmpty()
assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
}
}
@Test
fun `present - error while checking login compatibility`() = runTest {
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) })
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org"))
val withInputState = awaitItem()
assertThat(withInputState.userInput).isEqualTo("https://test.org")
assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
assertThat(awaitItem().userInputResult).isEqualTo(
AsyncData.Success(
listOf(
aHomeserverData(homeserverUrl = "https://test.org")
)
)
)
}
}
@Test
fun `present - enter text no result`() = runTest {
val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) })
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
val withInputState = awaitItem()
assertThat(withInputState.userInput).isEqualTo("test")
assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
assertThat(awaitItem().userInputResult).isEqualTo(
AsyncData.Success(
listOf(
aHomeserverData(homeserverUrl = "https://test"),
)
)
)
}
}
@Test
fun `present - enter text one result with wellknown`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> {
when (it) {
"https://test.org" -> Result.success(false)
"https://test.com" -> Result.success(false)
"https://test.io" -> Result.success(true)
"https://test" -> Result.success(false)
else -> error("should not happen")
}
}
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
val withInputState = awaitItem()
assertThat(withInputState.userInput).isEqualTo("test")
assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
assertThat(awaitItem().userInputResult).isEqualTo(
AsyncData.Success(
listOf(
aHomeserverData(homeserverUrl = "https://test.io")
)
)
)
checkResult.assertions().isCalledExactly(4)
.withSequence(
listOf(value("https://test.org")),
listOf(value("https://test.com")),
listOf(value("https://test.io")),
listOf(value("https://test")),
)
}
}
@Test
fun `present - enter text two results with wellknown`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> {
when (it) {
"https://test.org" -> Result.success(true)
"https://test.com" -> Result.success(false)
"https://test.io" -> Result.success(true)
"https://test" -> Result.success(false)
else -> error("should not happen")
}
}
val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult)
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker, FakeLocalNetworkPermissionAdvisor()),
changeServerPresenter = { aChangeServerState() }
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
val withInputState = awaitItem()
assertThat(withInputState.userInput).isEqualTo("test")
assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
assertThat(awaitItem().userInputResult).isEqualTo(
AsyncData.Success(
listOf(
aHomeserverData(homeserverUrl = "https://test.org"),
)
)
)
assertThat(awaitItem().userInputResult).isEqualTo(
AsyncData.Success(
listOf(
aHomeserverData(homeserverUrl = "https://test.org"),
aHomeserverData(homeserverUrl = "https://test.io"),
)
)
)
checkResult.assertions().isCalledExactly(4)
.withSequence(
listOf(value("https://test.org")),
listOf(value("https://test.com")),
listOf(value("https://test.io")),
listOf(value("https://test")),
)
}
}
}
@@ -20,12 +20,13 @@ fun String.isValidUrl(): Boolean {
}
/**
* Ensure string starts with "http". If it is not the case, "https://" is added, only if the String is not empty
* Ensure string starts with "http" (case-insensitively, so "HTTP://" / "HTTPS://" are respected). If it is
* not the case, "https://" is added, only if the String is not empty.
*/
fun String.ensureProtocol(): String {
return when {
isEmpty() -> this
!startsWith("http") -> "https://$this"
!startsWith("http", ignoreCase = true) -> "https://$this"
else -> this
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.core.uri
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UrlUtilsTest {
@Test
fun `ensureProtocol keeps an empty string unchanged`() {
assertThat("".ensureProtocol()).isEqualTo("")
}
@Test
fun `ensureProtocol prepends https when there is no scheme`() {
assertThat("matrix.org".ensureProtocol()).isEqualTo("https://matrix.org")
}
@Test
fun `ensureProtocol keeps an existing http or https scheme`() {
assertThat("https://matrix.org".ensureProtocol()).isEqualTo("https://matrix.org")
assertThat("http://matrix.org".ensureProtocol()).isEqualTo("http://matrix.org")
}
@Test
fun `ensureProtocol keeps an uppercase scheme rather than double-prepending`() {
assertThat("HTTPS://matrix.org".ensureProtocol()).isEqualTo("HTTPS://matrix.org")
assertThat("Http://matrix.org".ensureProtocol()).isEqualTo("Http://matrix.org")
}
}
@@ -84,6 +84,20 @@ interface AppPreferencesStore {
/** The configured log level; defaults to the one the build was compiled with. */
fun getTracingLogLevelFlow(): Flow<LogLevel>
/**
* The list of account providers (homeserver URLs) the user has previously authenticated
* against, most recent first. Used to default the account provider and to power autocomplete
* suggestions during sign-in. Stored locally only; never synced across devices.
*/
fun getHomeserverHistoryFlow(): Flow<List<String>>
/**
* Add [url] to the front of the account provider history (see [getHomeserverHistoryFlow]).
* The value is normalised (trimmed + lowercased); existing case-insensitive duplicates are
* moved to the front rather than duplicated, and the list is capped in size. No-op for blanks.
*/
suspend fun addHomeserverToHistory(url: String)
/**
* @param targets the extra log packs to enable on top of the default targets.
*/
@@ -8,6 +8,7 @@
package io.element.android.libraries.preferences.impl.store
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
@@ -36,6 +37,7 @@ private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPr
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
private val homeserverHistoryKey = stringPreferencesKey("homeserverHistory")
private val messageSoundUriKey = stringPreferencesKey("notificationMessageSoundUri")
private val messageSoundChannelVersionKey = intPreferencesKey("notificationMessageSoundChannelVersion")
private val messageSoundDisplayNameKey = stringPreferencesKey("notificationMessageSoundDisplayName")
@@ -43,6 +45,10 @@ private val callRingtoneUriKey = stringPreferencesKey("notificationCallRingtoneU
private val callRingtoneChannelVersionKey = intPreferencesKey("notificationCallRingtoneChannelVersion")
private val callRingtoneDisplayNameKey = stringPreferencesKey("notificationCallRingtoneDisplayName")
// URLs never contain a newline, so it is a safe delimiter to persist an ordered list in a single String.
private const val HOMESERVER_HISTORY_DELIMITER = "\n"
private const val MAX_HOMESERVER_HISTORY_SIZE = 20
@ContributesBinding(AppScope::class)
class DefaultAppPreferencesStore(
private val buildMeta: BuildMeta,
@@ -168,6 +174,20 @@ class DefaultAppPreferencesStore(
}
}
override fun getHomeserverHistoryFlow(): Flow<List<String>> {
return store.data.map { prefs -> prefs.readHomeserverHistory() }
}
override suspend fun addHomeserverToHistory(url: String) {
val normalized = url.trim().lowercase()
if (normalized.isEmpty()) return
store.edit { prefs ->
val updated = (listOf(normalized) + prefs.readHomeserverHistory().filter { it != normalized })
.take(MAX_HOMESERVER_HISTORY_SIZE)
prefs[homeserverHistoryKey] = updated.joinToString(HOMESERVER_HISTORY_DELIMITER)
}
}
override fun getMessageSoundFlow(): Flow<NotificationSound> {
return store.data.map { prefs -> NotificationSound.fromStored(prefs[messageSoundUriKey]) }
}
@@ -242,6 +262,13 @@ class DefaultAppPreferencesStore(
}
}
private fun Preferences.readHomeserverHistory(): List<String> {
return this[homeserverHistoryKey]
?.split(HOMESERVER_HISTORY_DELIMITER)
?.filter { it.isNotEmpty() }
?: emptyList()
}
private fun BuildMeta.defaultLogLevel(): LogLevel {
return when (buildType) {
BuildType.DEBUG -> LogLevel.TRACE
@@ -54,4 +54,74 @@ class DefaultAppPreferencesStoreTest {
assertThat(store.getLiveLocationMinimumDistanceInMetersUpdateFlow().first()).isEqualTo(25)
}
@Test
fun `homeserver history is empty by default`() = runTest {
val store = createStore()
assertThat(store.getHomeserverHistoryFlow().first()).isEmpty()
}
@Test
fun `adding homeservers keeps the most recent first`() = runTest {
val store = createStore()
store.addHomeserverToHistory("https://matrix.org")
store.addHomeserverToHistory("https://example.com")
assertThat(store.getHomeserverHistoryFlow().first())
.containsExactly("https://example.com", "https://matrix.org")
.inOrder()
}
@Test
fun `adding an existing homeserver moves it to the front without duplicating`() = runTest {
val store = createStore()
store.addHomeserverToHistory("https://matrix.org")
store.addHomeserverToHistory("https://example.com")
store.addHomeserverToHistory("https://matrix.org")
assertThat(store.getHomeserverHistoryFlow().first())
.containsExactly("https://matrix.org", "https://example.com")
.inOrder()
}
@Test
fun `homeservers are normalised and deduplicated case-insensitively`() = runTest {
val store = createStore()
store.addHomeserverToHistory(" HTTPS://Matrix.ORG ")
store.addHomeserverToHistory("https://matrix.org")
assertThat(store.getHomeserverHistoryFlow().first())
.containsExactly("https://matrix.org")
}
@Test
fun `blank homeservers are ignored`() = runTest {
val store = createStore()
store.addHomeserverToHistory(" ")
assertThat(store.getHomeserverHistoryFlow().first()).isEmpty()
}
@Test
fun `homeserver history is capped to the most recent entries`() = runTest {
val store = createStore()
repeat(25) { index ->
store.addHomeserverToHistory("https://server$index.org")
}
val history = store.getHomeserverHistoryFlow().first()
assertThat(history).hasSize(20)
assertThat(history.first()).isEqualTo("https://server24.org")
assertThat(history.last()).isEqualTo("https://server5.org")
}
private fun createStore() = DefaultAppPreferencesStore(
buildMeta = buildMeta,
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
)
}
@@ -16,6 +16,7 @@ import io.element.android.libraries.preferences.api.store.NotificationSound
import io.element.android.libraries.preferences.api.store.NotificationSoundChannelConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
class InMemoryAppPreferencesStore(
@@ -27,6 +28,7 @@ class InMemoryAppPreferencesStore(
liveLocationMinimumDistanceUpdate: Int = 10,
logLevel: LogLevel = LogLevel.INFO,
traceLogPacks: Set<TraceLogPack> = emptySet(),
homeserverHistory: List<String> = emptyList(),
messageSound: NotificationSound = NotificationSound.SystemDefault,
messageSoundChannelVersion: Int = 0,
messageSoundDisplayName: String? = null,
@@ -40,6 +42,7 @@ class InMemoryAppPreferencesStore(
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
private val logLevel = MutableStateFlow(logLevel)
private val tracingLogPacks = MutableStateFlow(traceLogPacks)
private val homeserverHistory = MutableStateFlow(homeserverHistory)
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
private val messageSound = MutableStateFlow(messageSound)
@@ -117,6 +120,18 @@ class InMemoryAppPreferencesStore(
return tracingLogPacks
}
override fun getHomeserverHistoryFlow(): Flow<List<String>> {
return homeserverHistory
}
override suspend fun addHomeserverToHistory(url: String) {
val normalized = url.trim().lowercase()
if (normalized.isEmpty()) return
homeserverHistory.update { current ->
(listOf(normalized) + current.filter { it != normalized }).take(10)
}
}
override fun getMessageSoundFlow(): Flow<NotificationSound> {
return messageSound
}