Skip to content

Commit 1c94e89

Browse files
authored
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
2 parents 92595e1 + f2114d9 commit 1c94e89

78 files changed

Lines changed: 1031 additions & 1615 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.maestro/tests/account/changeServer.yaml

Lines changed: 0 additions & 21 deletions
This file was deleted.

.maestro/tests/account/login.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ appId: ${MAESTRO_APP_ID}
33
- tapOn: "Sign in manually"
44
- runFlow: ../assertions/assertLoginDisplayed.yaml
55
- takeScreenshot: build/maestro/100-SignIn
6-
- runFlow: changeServer.yaml
7-
- runFlow: ../assertions/assertLoginDisplayed.yaml
6+
# The account provider field defaults to matrix.org, so continue straight to the MAS login.
87
- tapOn:
98
id: "login-continue"
109
## MAS page
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
appId: ${MAESTRO_APP_ID}
22
---
33
- extendedWaitUntil:
4-
visible: "Change account provider"
4+
visible: "Enter your account provider"
55
timeout: 10000

features/login/impl/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ dependencies {
6969
implementation(projects.libraries.sessionStorage.api)
7070
implementation(projects.libraries.qrcode)
7171
implementation(projects.libraries.oauth.api)
72+
implementation(projects.libraries.preferences.api)
7273
implementation(projects.libraries.uiUtils)
7374
implementation(projects.libraries.wellknown.api)
7475
implementation(libs.androidx.browser)
@@ -80,6 +81,7 @@ dependencies {
8081
testImplementation(projects.features.login.test)
8182
testImplementation(projects.features.enterprise.test)
8283
testImplementation(projects.features.preferences.test)
84+
testImplementation(projects.libraries.preferences.test)
8385
testImplementation(projects.libraries.matrix.test)
8486
testImplementation(projects.libraries.oauth.test)
8587
testImplementation(projects.libraries.permissions.test)

features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import com.bumble.appyx.navmodel.backstack.BackStack
2323
import com.bumble.appyx.navmodel.backstack.operation.pop
2424
import com.bumble.appyx.navmodel.backstack.operation.push
2525
import com.bumble.appyx.navmodel.backstack.operation.replace
26-
import com.bumble.appyx.navmodel.backstack.operation.singleTop
2726
import dev.zacsweers.metro.AppScope
2827
import dev.zacsweers.metro.Assisted
2928
import dev.zacsweers.metro.AssistedInject
@@ -33,13 +32,11 @@ import io.element.android.features.login.api.LoginEntryPoint
3332
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
3433
import io.element.android.features.login.impl.classic.ElementClassicConnection
3534
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
36-
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
3735
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode
3836
import io.element.android.features.login.impl.screens.classic.ClassicFlowNode
3937
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
4038
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
4139
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
42-
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
4340
import io.element.android.features.preferences.api.PreferencesEntryPoint
4441
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
4542
import io.element.android.libraries.architecture.BackstackView
@@ -129,12 +126,6 @@ class LoginFlowNode(
129126
@Parcelize
130127
data object ChooseAccountProvider : NavTarget
131128

132-
@Parcelize
133-
data object ChangeAccountProvider : NavTarget
134-
135-
@Parcelize
136-
data object SearchAccountProvider : NavTarget
137-
138129
@Parcelize
139130
data class LoginPassword(
140131
val initialLogin: String = "",
@@ -261,43 +252,9 @@ class LoginFlowNode(
261252
override fun navigateToLoginPassword() {
262253
backstack.push(NavTarget.LoginPassword())
263254
}
264-
265-
override fun navigateToChangeAccountProvider() {
266-
backstack.push(NavTarget.ChangeAccountProvider)
267-
}
268255
}
269256
createNode<ConfirmAccountProviderNode>(buildContext, plugins = listOf(inputs, callback))
270257
}
271-
NavTarget.ChangeAccountProvider -> {
272-
val callback = object : ChangeAccountProviderNode.Callback {
273-
override fun onDone() {
274-
// Go back to the Account Provider screen
275-
val confirmAccountProvider = backstack.elements.value.firstOrNull {
276-
it.key.navTarget is NavTarget.ConfirmAccountProvider
277-
}?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
278-
backstack.singleTop(confirmAccountProvider)
279-
}
280-
281-
override fun navigateToSearchAccountProvider() {
282-
backstack.push(NavTarget.SearchAccountProvider)
283-
}
284-
}
285-
286-
createNode<ChangeAccountProviderNode>(buildContext, plugins = listOf(callback))
287-
}
288-
NavTarget.SearchAccountProvider -> {
289-
val callback = object : SearchAccountProviderNode.Callback {
290-
override fun onDone() {
291-
// Go back to the Account Provider screen
292-
val confirmAccountProvider = backstack.elements.value.firstOrNull {
293-
it.key.navTarget is NavTarget.ConfirmAccountProvider
294-
}?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false)
295-
backstack.singleTop(confirmAccountProvider)
296-
}
297-
}
298-
299-
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
300-
}
301258
is NavTarget.LoginPassword -> {
302259
val inputs = LoginPasswordNode.Inputs(
303260
initialLogin = navTarget.initialLogin,

features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,34 +13,72 @@ import dev.zacsweers.metro.Inject
1313
import dev.zacsweers.metro.SingleIn
1414
import io.element.android.appconfig.AuthenticationConfig
1515
import io.element.android.features.enterprise.api.EnterpriseService
16+
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
17+
import io.element.android.libraries.di.annotations.AppCoroutineScope
18+
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
19+
import kotlinx.coroutines.CoroutineScope
1620
import kotlinx.coroutines.flow.MutableStateFlow
1721
import kotlinx.coroutines.flow.StateFlow
1822
import kotlinx.coroutines.flow.asStateFlow
23+
import kotlinx.coroutines.flow.first
24+
import kotlinx.coroutines.flow.update
25+
import kotlinx.coroutines.launch
1926

2027
@SingleIn(AppScope::class)
2128
@Inject
2229
class AccountProviderDataSource(
23-
enterpriseService: EnterpriseService,
30+
private val enterpriseService: EnterpriseService,
31+
private val appPreferencesStore: AppPreferencesStore,
32+
@AppCoroutineScope private val coroutineScope: CoroutineScope,
2433
) {
25-
private val defaultAccountProvider = createAccountProvider(
34+
// The provider used when the user has not selected one: an enterprise/MDM-configured provider,
35+
// else matrix.org. The most recently used provider (from history) can override it, see init.
36+
private val configuredAccountProvider = createAccountProvider(
2637
url = enterpriseService.homeserverAllowList()
2738
.firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
2839
?: AuthenticationConfig.MATRIX_ORG_URL
2940
)
3041

31-
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(defaultAccountProvider)
42+
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(configuredAccountProvider)
3243

3344
val flow: StateFlow<AccountProvider> = accountProvider.asStateFlow()
3445

46+
// The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]).
47+
// Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a
48+
// successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip).
49+
var lastSelectedAccountProviderUrl: String? = null
50+
private set
51+
52+
init {
53+
// Seed the default from the last used provider, unless the user has already selected one.
54+
coroutineScope.launch {
55+
val default = defaultAccountProvider()
56+
accountProvider.update { current -> if (current == configuredAccountProvider) default else current }
57+
}
58+
}
59+
3560
suspend fun reset() {
36-
accountProvider.emit(defaultAccountProvider)
61+
accountProvider.emit(defaultAccountProvider())
62+
}
63+
64+
/**
65+
* The provider to default to: the most recently used one from history when the user is free to
66+
* connect to any provider, otherwise the enterprise/MDM-configured provider.
67+
*/
68+
private suspend fun defaultAccountProvider(): AccountProvider {
69+
if (!enterpriseService.canConnectToAnyHomeserver()) {
70+
return configuredAccountProvider
71+
}
72+
val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull()
73+
return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider
3774
}
3875

3976
suspend fun setUrl(url: String) {
4077
setAccountProvider(createAccountProvider(url))
4178
}
4279

4380
suspend fun setAccountProvider(data: AccountProvider) {
81+
lastSelectedAccountProviderUrl = data.url
4482
accountProvider.emit(data)
4583
}
4684

features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt

Lines changed: 0 additions & 77 deletions
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (c) 2026 Element Creations Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.login.impl.accountprovider
9+
10+
import dev.zacsweers.metro.Inject
11+
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
12+
13+
/**
14+
* Persist the account provider the user just authenticated against, so that it can be offered as
15+
* the default (see [AccountProviderDataSource]) and as an autocomplete suggestion on the next
16+
* sign-in. To be called from the manual sign-in / account creation flows only, on success.
17+
*
18+
* See [AppPreferencesStore.addHomeserverToHistory].
19+
*/
20+
@Inject
21+
class SaveAccountProviderToHistory(
22+
private val accountProviderDataSource: AccountProviderDataSource,
23+
private val appPreferencesStore: AppPreferencesStore,
24+
) {
25+
suspend operator fun invoke() {
26+
// Persist the provider the user actually selected, not the currently-exposed default: the latter can be
27+
// reset back to the history default while the login flow is torn down across an OAuth round-trip.
28+
val url = accountProviderDataSource.lastSelectedAccountProviderUrl
29+
?: accountProviderDataSource.flow.value.url
30+
appPreferencesStore.addHomeserverToHistory(url)
31+
}
32+
}

features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.AsyncData
2222
import io.element.android.libraries.architecture.Presenter
2323
import io.element.android.libraries.architecture.runCatchingUpdatingState
2424
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
25+
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
2526

2627
@Inject
2728
class ChangeServerPresenter(
@@ -32,7 +33,7 @@ class ChangeServerPresenter(
3233
) : Presenter<ChangeServerState> {
3334
@Composable
3435
override fun present(): ChangeServerState {
35-
val changeServerAction: MutableState<AsyncData<Unit>> = remember {
36+
val changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>> = remember {
3637
mutableStateOf(AsyncData.Uninitialized)
3738
}
3839

@@ -59,7 +60,7 @@ class ChangeServerPresenter(
5960

6061
private suspend fun changeServer(
6162
data: AccountProvider,
62-
changeServerAction: MutableState<AsyncData<Unit>>,
63+
changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>>,
6364
) {
6465
suspend {
6566
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
@@ -72,6 +73,8 @@ class ChangeServerPresenter(
7273
}
7374
// Homeserver is valid, remember user choice
7475
accountProviderDataSource.setAccountProvider(data)
76+
// Return the resolved details so the caller can sign in without configuring the homeserver again.
77+
details
7578
}.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from)
7679
}
7780
}

features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
package io.element.android.features.login.impl.changeserver
1010

1111
import io.element.android.libraries.architecture.AsyncData
12+
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
1213
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
1314

1415
data class ChangeServerState(
15-
val changeServerAction: AsyncData<Unit>,
16+
// On success, carries the resolved homeserver details so the caller can proceed with login without
17+
// configuring (and re-networking) the homeserver a second time.
18+
val changeServerAction: AsyncData<MatrixHomeServerDetails>,
1619
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
1720
val eventSink: (ChangeServerEvents) -> Unit
1821
)

0 commit comments

Comments
 (0)