diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt index 1fb024156b..9093a8121f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt @@ -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 } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index f83bce0510..fbadcddc15 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -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(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) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index e1be11da3a..385a8477f6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -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) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index eba6a4474f..b3bf502965 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -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()