Remove the account provider search screen and homeserver resolver

This commit is contained in:
Jenna Vassar
2026-08-06 11:31:32 -07:00
parent df6ccd9dc3
commit c2b3b0fe0f
16 changed files with 0 additions and 1183 deletions
@@ -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 = { },
)
}
@@ -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,82 +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.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()
if (trimmedUserInput.length < 4) return@flow
val candidateBase = trimmedUserInput.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.defaultHomeserverList()
.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 = { },
)
}
@@ -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,214 +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.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)
.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 = {},
)
}
@@ -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()
}
}
}
@@ -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")),
)
}
}
}