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 { sealed interface ConfirmAccountProviderEvents {
data class UserInputChanged(val accountProvider: String) : 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 data object ClearError : ConfirmAccountProviderEvents
} }
@@ -67,9 +67,10 @@ class ConfirmAccountProviderPresenter(
} }
} }
// Continue accepts the suggestion when one is offered, otherwise the raw input. // The account provider submitted via Continue (carried by the event, so it reflects the exact field
val accountProviderToSubmit = accountProviderSuggestion ?: accountProviderInput // text at click time); used to actually sign in once validation succeeds.
val latestAccountProviderToSubmit by rememberUpdatedState(accountProviderToSubmit) 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. // Once the chosen account provider has been validated and persisted, proceed with the actual sign in / sign up.
LaunchedEffect(changeServerState.changeServerAction) { LaunchedEffect(changeServerState.changeServerAction) {
@@ -77,7 +78,7 @@ class ConfirmAccountProviderPresenter(
loginModeState.eventSink( loginModeState.eventSink(
LoginModeEvent.Submit( LoginModeEvent.Submit(
isAccountCreation = params.isAccountCreation, isAccountCreation = params.isAccountCreation,
homeserverUrl = latestAccountProviderToSubmit.trim(), homeserverUrl = latestSubmittedAccountProvider.orEmpty().trim(),
resolvedHomeserverUrl = null, resolvedHomeserverUrl = null,
loginHint = null, loginHint = null,
) )
@@ -92,9 +93,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.
ConfirmAccountProviderEvents.Continue -> changeServerState.eventSink( is ConfirmAccountProviderEvents.Continue -> {
ChangeServerEvents.ChangeServer(AccountProvider(url = accountProviderToSubmit.trim())) submittedAccountProvider = event.accountProvider
) changeServerState.eventSink(
ChangeServerEvents.ChangeServer(AccountProvider(url = event.accountProvider.trim()))
)
}
ConfirmAccountProviderEvents.ClearError -> { ConfirmAccountProviderEvents.ClearError -> {
loginModeState.eventSink(LoginModeEvent.ClearError) loginModeState.eventSink(LoginModeEvent.ClearError)
changeServerState.eventSink(ChangeServerEvents.ClearError) changeServerState.eventSink(ChangeServerEvents.ClearError)
@@ -81,10 +81,20 @@ fun ConfirmAccountProviderView(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current 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() { fun submit() {
// Dismiss the keyboard and release focus while the account provider is being validated. // Dismiss the keyboard and release focus while the account provider is being validated.
focusManager.clearFocus(force = true) 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 // 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 ghostColor = ElementTheme.colors.textSecondary
val ghostTransformation = remember(suggestionSuffix, ghostColor) { val ghostTransformation = remember(suggestionSuffix, ghostColor) {
GhostSuffixVisualTransformation(suggestionSuffix, ghostColor) GhostSuffixVisualTransformation(suggestionSuffix, ghostColor)
@@ -70,7 +70,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin) assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -89,7 +89,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -110,7 +110,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenOAuthCancelError(AN_EXCEPTION) authenticationService.givenOAuthCancelError(AN_EXCEPTION)
@@ -135,7 +135,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOAuthActionFlow.post(OAuthAction.GoBack()) defaultOAuthActionFlow.post(OAuthAction.GoBack())
@@ -159,7 +159,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
awaitLoginMode { it is AsyncData.Loading } awaitLoginMode { it is AsyncData.Loading }
defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true)) defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
@@ -182,7 +182,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenLoginError(AN_EXCEPTION) authenticationService.givenLoginError(AN_EXCEPTION)
@@ -207,7 +207,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOidcActionFlow.post(OAuthAction.Success("aUrl")) defaultOidcActionFlow.post(OAuthAction.Success("aUrl"))
@@ -229,7 +229,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() 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. // The account provider validation fails, so the login is never attempted.
val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
@@ -251,7 +251,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
// Submit will return an error while validating the account provider // 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 // Check an error was returned
val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
@@ -279,7 +279,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
// Check an error was returned // Check an error was returned
val submittedState = awaitLoginMode { it is AsyncData.Failure } val submittedState = awaitLoginMode { it is AsyncData.Failure }
assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
@@ -304,7 +304,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val submittedState = awaitLoginMode { it is AsyncData.Success } val submittedState = awaitLoginMode { it is AsyncData.Success }
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -326,7 +326,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val submittedState = awaitLoginMode { it is AsyncData.Success } val submittedState = awaitLoginMode { it is AsyncData.Success }
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -348,7 +348,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.Continue) initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL))
val submittedState = awaitLoginMode { it is AsyncData.Success } val submittedState = awaitLoginMode { it is AsyncData.Success }
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl)) assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -390,7 +390,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random")) initialState.eventSink(ConfirmAccountProviderEvents.UserInputChanged("https://random"))
val suggestionState = awaitState { it.accountProviderSuggestion != null } val suggestionState = awaitState { it.accountProviderSuggestion != null }
suggestionState.eventSink(ConfirmAccountProviderEvents.Continue) suggestionState.eventSink(ConfirmAccountProviderEvents.Continue("https://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()