Submit the exact account provider field text on continue

This commit is contained in:
Jenna Vassar
2026-08-10 09:28:31 -07:00
parent d7e6b64265
commit f8816b2449
4 changed files with 40 additions and 29 deletions
@@ -10,6 +10,9 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
sealed interface ConfirmAccountProviderEvents {
data class UserInputChanged(val accountProvider: String) : ConfirmAccountProviderEvents
data object Continue : 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
}
@@ -67,9 +67,10 @@ class ConfirmAccountProviderPresenter(
}
}
// Continue accepts the suggestion when one is offered, otherwise the raw input.
val accountProviderToSubmit = accountProviderSuggestion ?: accountProviderInput
val latestAccountProviderToSubmit by rememberUpdatedState(accountProviderToSubmit)
// 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.
LaunchedEffect(changeServerState.changeServerAction) {
@@ -77,7 +78,7 @@ class ConfirmAccountProviderPresenter(
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = params.isAccountCreation,
homeserverUrl = latestAccountProviderToSubmit.trim(),
homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(),
resolvedHomeserverUrl = null,
loginHint = null,
)
@@ -92,9 +93,12 @@ class ConfirmAccountProviderPresenter(
}
// 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.
ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink(
ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderToSubmit.trim()))
)
is ConfirmAccountProviderEvents.Continue -> {
submittedAccountProvider = event.accountProvider
changeServerState.eventSink(
ChangeServerEvents.ChangeServer(AccountProvider(url = event.accountProvider.trim()))
)
}
ConfirmAccountProviderEvents.ClearError -> {
loginModeState.eventSink(LoginModeEvent.ClearError)
changeServerState.eventSink(ChangeServerEvents.ClearError)
@@ -81,10 +81,20 @@ fun ConfirmAccountProviderView(
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). 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()
fun submit() {
// Dismiss the keyboard and release focus while the account provider is being validated.
focusManager.clearFocus(force = true)
eventSink(ConfirmAccountProviderEvents.Continue)
// Submit the exact field text (plus any accepted suggestion), read here so it can't lag behind
// fast input such as keyboard autofill.
eventSink(ConfirmAccountProviderEvents.Continue(input + suggestionSuffix))
}
// Once a validation / login error has been dismissed, return focus and the keyboard to the field so
@@ -128,12 +138,6 @@ fun ConfirmAccountProviderView(
}
}
) {
var input by textFieldState(stateValue = state.accountProviderInput)
// Inline autocomplete: render the un-typed remainder of the suggested account provider in grey.
val suggestionSuffix = state.accountProviderSuggestion
?.takeIf { it.length > input.length && it.startsWith(input, ignoreCase = true) }
?.substring(input.length)
.orEmpty()
val ghostColor = ElementTheme.colors.textSecondary
val ghostTransformation = remember(suggestionSuffix, ghostColor) {
GhostSuffixVisualTransformation(suggestionSuffix, ghostColor)
@@ -70,7 +70,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
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()
@@ -89,7 +89,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
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()
@@ -110,7 +110,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
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)
@@ -135,7 +135,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
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())
@@ -159,7 +159,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
awaitLoginMode { it is AsyncData.Loading }
defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
@@ -182,7 +182,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
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)
@@ -207,7 +207,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
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"))
@@ -229,7 +229,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
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)
@@ -251,7 +251,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem()
// Submit will return an error while validating the account provider
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// Check an error was returned
val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
@@ -279,7 +279,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// Check an error was returned
val submittedState = awaitLoginMode { it is AsyncData.Failure }
assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
@@ -304,7 +304,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
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()
@@ -326,7 +326,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
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()
@@ -348,7 +348,7 @@ class ConfirmAccountProviderPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val submittedState = awaitLoginMode { it is AsyncData.Success }
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
cancelAndIgnoreRemainingEvents()
@@ -390,7 +390,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random"))
val suggestionState = awaitState { it.accountProviderSuggestion != null }
suggestionState.eventSink(ConfirmAccountProviderEvents.Continue)
suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("https://randomcommunity.org"))
awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org")
cancelAndIgnoreRemainingEvents()