mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 01:19:33 +00:00
Complete account providers without the https scheme and include the enterprise allow-list
This commit is contained in:
+31
-13
@@ -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("://")
|
||||||
|
|||||||
+18
-4
@@ -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()) {
|
||||||
{
|
{
|
||||||
|
|||||||
+56
-11
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user