Complete account providers without the https scheme and include the enterprise allow-list

This commit is contained in:
Jenna Vassar
2026-08-18 17:03:39 -07:00
parent 7c48e737c4
commit 1b2d5b9c92
3 changed files with 105 additions and 28 deletions
@@ -21,6 +21,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.appconfig.AuthenticationConfig 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.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.changeserver.ChangeServerEvents import io.element.android.features.login.impl.changeserver.ChangeServerEvents
@@ -38,6 +39,7 @@ class ConfirmAccountProviderPresenter(
private val loginModePresenter: Presenter<LoginModeState>, private val loginModePresenter: Presenter<LoginModeState>,
private val changeServerPresenter: Presenter<ChangeServerState>, private val changeServerPresenter: Presenter<ChangeServerState>,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val enterpriseService: EnterpriseService,
) : Presenter<ConfirmAccountProviderState> { ) : Presenter<ConfirmAccountProviderState> {
data class Params( data class Params(
val isAccountCreation: Boolean, val isAccountCreation: Boolean,
@@ -55,22 +57,28 @@ class ConfirmAccountProviderPresenter(
val changeServerState = changeServerPresenter.present() val changeServerState = changeServerPresenter.present()
val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList()) val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList())
// Editable input, seeded from the current (history-defaulted) account provider until the user edits it. // The account providers offered for autocomplete: previously-used ones first, then the
var userInput by rememberSaveable { mutableStateOf<String?>(null) } // enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in).
val accountProviderInput = userInput ?: accountProvider.url // 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()
}
// Offer an account provider that the current input is a prefix of: the most recent previously-used // Editable input, seeded from the current (history-defaulted) account provider until the user edits it.
// one first, then matrix.org. matrix.org is always available for autocomplete even before any // Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org".
// sign-in, offered both with and without the https:// scheme so it completes whether the user starts var userInput by rememberSaveable { mutableStateOf<String?>(null) }
// typing "matrix.org" or "https://matrix.org". val accountProviderInput = userInput ?: accountProvider.url.withoutScheme()
val accountProviderSuggestion = remember(accountProviderInput, homeserverHistory) {
// Offer the first candidate that the current input is a (case-insensitive) prefix of.
val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) {
val input = accountProviderInput.trim() val input = accountProviderInput.trim()
val candidates = homeserverHistory +
AuthenticationConfig.MATRIX_ORG_URL +
AuthenticationConfig.MATRIX_ORG_URL.removePrefix("https://")
input.takeIf { it.isNotEmpty() } input.takeIf { it.isNotEmpty() }
?.let { prefix -> ?.let { prefix ->
candidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) } autocompleteCandidates.firstOrNull { it.length > prefix.length && it.startsWith(prefix, ignoreCase = true) }
} }
} }
@@ -104,8 +112,12 @@ class ConfirmAccountProviderPresenter(
// Validate (and persist) the chosen account provider before proceeding. This also enforces the // 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. // account-provider access control, which the login submit does not run on its own.
is ConfirmAccountProviderEvents.Continue -> { 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. // Default the scheme to https:// so entering a bare host (e.g. "matrix.org") works.
val accountProviderUrl = event.accountProvider.trim().ensureProtocol() val accountProviderUrl = accountProvider.ensureProtocol()
submittedAccountProvider = accountProviderUrl submittedAccountProvider = accountProviderUrl
changeServerState.eventSink( changeServerState.eventSink(
ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl)) ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderUrl))
@@ -128,3 +140,9 @@ class ConfirmAccountProviderPresenter(
) )
} }
} }
/**
* 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("://")
@@ -50,7 +50,6 @@ import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubti
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
@@ -82,8 +81,9 @@ fun ConfirmAccountProviderView(
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
var input by textFieldState(stateValue = state.accountProviderInput) var input by textFieldState(stateValue = state.accountProviderInput)
// Inline autocomplete: the un-typed remainder of the suggested account provider (rendered in grey and // Inline autocomplete: the un-typed remainder of the suggested account provider (rendered in grey and
// accepted on Continue). Only valid while the suggestion still starts with the current input. // accepted on Continue / Enter). Only valid while the suggestion still starts with the current input.
val suggestionSuffix = state.accountProviderSuggestion val suggestionSuffix = state.accountProviderSuggestion
?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) } ?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) }
?.substring(input.length) ?.substring(input.length)
@@ -156,7 +156,6 @@ fun ConfirmAccountProviderView(
.fillMaxWidth() .fillMaxWidth()
.padding(top = 40.dp) .padding(top = 40.dp)
.focusRequester(focusRequester) .focusRequester(focusRequester)
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.changeServerServer), .testTag(TestTags.changeServerServer),
label = stringResource(id = CommonStrings.screen_change_server_textfield_header), label = stringResource(id = CommonStrings.screen_change_server_textfield_header),
placeholder = stringResource(id = CommonStrings.screen_change_server_textfield_placeholder), placeholder = stringResource(id = CommonStrings.screen_change_server_textfield_placeholder),
@@ -172,7 +171,22 @@ fun ConfirmAccountProviderView(
keyboardType = KeyboardType.Uri, keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done, imeAction = ImeAction.Done,
), ),
keyboardActions = KeyboardActions(onDone = { submit() }), 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, singleLine = true,
trailingIcon = if (input.isNotEmpty()) { trailingIcon = if (input.isNotEmpty()) {
{ {
@@ -11,6 +11,7 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
@@ -50,7 +51,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isAccountCreation).isFalse() assertThat(initialState.isAccountCreation).isFalse()
assertThat(initialState.submitEnabled).isTrue() assertThat(initialState.submitEnabled).isTrue()
assertThat(initialState.accountProviderInput).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) assertThat(initialState.accountProviderInput).isEqualTo("matrix.org")
assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
} }
} }
@@ -315,9 +316,10 @@ class ConfirmAccountProviderPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.accountProviderSuggestion).isNull() assertThat(initialState.accountProviderSuggestion).isNull()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random")) initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
val suggestionState = awaitState { it.accountProviderInput == "https://random" } val suggestionState = awaitState { it.accountProviderInput == "random" }
assertThat(suggestionState.accountProviderSuggestion).isEqualTo("https://randomcommunity.org") // Completion is offered without the https:// scheme, even though history stores the full URL.
assertThat(suggestionState.accountProviderSuggestion).isEqualTo("randomcommunity.org")
} }
} }
@@ -338,9 +340,9 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random")) initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
val suggestionState = awaitState { it.accountProviderSuggestion != null } val suggestionState = awaitState { it.accountProviderSuggestion != null }
suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("https://randomcommunity.org")) suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("randomcommunity.org"))
awaitLoginMode { it is AsyncData.Success } awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org") assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org")
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -348,15 +350,40 @@ class ConfirmAccountProviderPresenterTest {
} }
@Test @Test
fun `present - offers matrix_org as an autocomplete suggestion even without any history`() = runTest { 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( val presenter = createConfirmAccountProviderPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), matrixAuthenticationService = authenticationService,
appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = listOf("https://randomcommunity.org")),
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://matr")) initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("random"))
val suggestionState = awaitState { it.accountProviderInput == "https://matr" } val suggestionState = awaitState { it.accountProviderSuggestion != null }
assertThat(suggestionState.accountProviderSuggestion).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) 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")
} }
} }
@@ -373,6 +400,22 @@ class ConfirmAccountProviderPresenterTest {
} }
} }
@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 @Test
fun `present - continue prepends https to a bare account provider`() = runTest { fun `present - continue prepends https to a bare account provider`() = runTest {
val submittedUrls = mutableListOf<String>() val submittedUrls = mutableListOf<String>()
@@ -440,10 +483,12 @@ class ConfirmAccountProviderPresenterTest {
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(), defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
) = ConfirmAccountProviderPresenter( ) = ConfirmAccountProviderPresenter(
params = params, params = params,
accountProviderDataSource = accountProviderDataSource, accountProviderDataSource = accountProviderDataSource,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
enterpriseService = enterpriseService,
loginModePresenter = createLoginModePresenter( loginModePresenter = createLoginModePresenter(
authenticationService = matrixAuthenticationService, authenticationService = matrixAuthenticationService,
oAuthActionFlow = defaultOAuthActionFlow, oAuthActionFlow = defaultOAuthActionFlow,