mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
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:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
-43
@@ -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,
|
||||
|
||||
+42
-4
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
-77
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
)
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+23
-18
@@ -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()
|
||||
|
||||
-14
@@ -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,
|
||||
)
|
||||
-89
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-51
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-57
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-19
@@ -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,
|
||||
)
|
||||
-37
@@ -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,
|
||||
)
|
||||
-129
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
|
||||
-2
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
+94
-9
@@ -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("://")
|
||||
|
||||
+16
-4
@@ -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
|
||||
}
|
||||
|
||||
+12
-4
@@ -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
|
||||
)
|
||||
|
||||
+180
-40
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -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 ->
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
-17
@@ -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
|
||||
}
|
||||
-49
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-77
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-20
@@ -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
|
||||
)
|
||||
-48
@@ -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)
|
||||
}
|
||||
-216
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+2
-3
@@ -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(),
|
||||
|
||||
+25
@@ -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,
|
||||
)
|
||||
+53
-9
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -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")
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -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(),
|
||||
|
||||
-108
@@ -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(),
|
||||
)
|
||||
}
|
||||
-113
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+246
-76
@@ -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()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+34
-2
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+8
-2
@@ -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,
|
||||
)
|
||||
|
||||
-179
@@ -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")
|
||||
}
|
||||
}
|
||||
+14
@@ -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.
|
||||
*/
|
||||
|
||||
+27
@@ -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
|
||||
|
||||
+70
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+15
@@ -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
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user