From 4b6f74364d68e09265eb1ecb4d6665ba6d75a230 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 5 May 2022 14:01:18 +0100 Subject: [PATCH] adding dedicated login action --- .../features/onboarding/OnboardingAction.kt | 1 + .../onboarding/OnboardingViewModel.kt | 28 +- .../ftueauth/FtueAuthCombinedLoginFragment.kt | 198 +++++++++++++ .../onboarding/ftueauth/FtueAuthVariant.kt | 2 +- .../layout/fragment_ftue_combined_login.xml | 275 ++++++++++++++++++ vector/src/main/res/values/donottranslate.xml | 2 + 6 files changed, 493 insertions(+), 13 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedLoginFragment.kt create mode 100644 vector/src/main/res/layout/fragment_ftue_combined_login.xml diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt index 9f7dce56ea..d5b12a8071 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt @@ -49,6 +49,7 @@ sealed interface OnboardingAction : VectorViewModelAction { // Login or Register, depending on the signMode data class LoginOrRegister(val username: String, val password: String, val initialDeviceName: String) : OnboardingAction data class Register(val username: String, val password: String, val initialDeviceName: String) : OnboardingAction + data class Login(val username: String, val password: String, val initialDeviceName: String) : OnboardingAction object StopEmailValidationCheck : OnboardingAction data class PostRegisterAction(val registerAction: RegisterAction) : OnboardingAction diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt index 4ee26c2976..1ca2556c71 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt @@ -141,6 +141,7 @@ class OnboardingViewModel @AssistedInject constructor( is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action) } is OnboardingAction.LoginOrRegister -> handleLoginOrRegister(action).also { lastAction = action } is OnboardingAction.Register -> handleRegisterWith(action).also { lastAction = action } + is OnboardingAction.Login -> handleLogin(action).also { lastAction = action } is OnboardingAction.LoginWithToken -> handleLoginWithToken(action) is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action) is OnboardingAction.ResetPassword -> handleResetPassword(action) @@ -188,18 +189,21 @@ class OnboardingViewModel @AssistedInject constructor( } private fun continueToPageAfterSplash(onboardingFlow: OnboardingFlow) { - val nextOnboardingStep = when (onboardingFlow) { - OnboardingFlow.SignUp -> if (vectorFeatures.isOnboardingUseCaseEnabled()) { - OnboardingViewEvents.OpenUseCaseSelection - } else { - OnboardingViewEvents.OpenServerSelection + when (onboardingFlow) { + OnboardingFlow.SignUp -> { + _viewEvents.post( + if (vectorFeatures.isOnboardingUseCaseEnabled()) { + OnboardingViewEvents.OpenUseCaseSelection + } else { + OnboardingViewEvents.OpenServerSelection + } + ) } - OnboardingFlow.SignIn -> if (vectorFeatures.isOnboardingCombinedRegisterEnabled()) { - OnboardingViewEvents.OpenCombinedLogin - } else OnboardingViewEvents.OpenServerSelection - OnboardingFlow.SignInSignUp -> OnboardingViewEvents.OpenServerSelection + OnboardingFlow.SignIn -> if (vectorFeatures.isOnboardingCombinedLoginEnabled()) { + handle(OnboardingAction.HomeServerChange.SelectHomeServer(defaultHomeserverUrl)) + } else _viewEvents.post(OnboardingViewEvents.OpenServerSelection) + OnboardingFlow.SignInSignUp -> _viewEvents.post(OnboardingViewEvents.OpenServerSelection) } - _viewEvents.post(nextOnboardingStep) } private fun handleUserAcceptCertificate(action: OnboardingAction.UserAcceptCertificate) { @@ -487,7 +491,7 @@ class OnboardingViewModel @AssistedInject constructor( private fun handleLoginOrRegister(action: OnboardingAction.LoginOrRegister) = withState { state -> when (state.signMode) { SignMode.Unknown -> error("Developer error, invalid sign mode") - SignMode.SignIn -> handleLogin(action) + SignMode.SignIn -> handleLogin(OnboardingAction.Login(action.username, action.password, action.initialDeviceName)) SignMode.SignUp -> handleRegisterWith(OnboardingAction.Register(action.username, action.password, action.initialDeviceName)) SignMode.SignInWithMatrixId -> handleDirectLogin(action, null) } @@ -506,7 +510,7 @@ class OnboardingViewModel @AssistedInject constructor( } } - private fun handleLogin(action: OnboardingAction.LoginOrRegister) { + private fun handleLogin(action: OnboardingAction.Login) { val safeLoginWizard = loginWizard if (safeLoginWizard == null) { diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedLoginFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedLoginFragment.kt new file mode 100644 index 0000000000..66c7199acb --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedLoginFragment.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.onboarding.ftueauth + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.autofill.HintConstants +import androidx.core.text.isDigitsOnly +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.content +import im.vector.app.core.extensions.editText +import im.vector.app.core.extensions.hasContentFlow +import im.vector.app.core.extensions.hasSurroundingSpaces +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.hidePassword +import im.vector.app.core.extensions.realignPercentagesToParent +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentFtueCombinedLoginBinding +import im.vector.app.features.login.LoginMode +import im.vector.app.features.login.SSORedirectRouterActivity +import im.vector.app.features.login.SocialLoginButtonsView +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewEvents +import im.vector.app.features.onboarding.OnboardingViewState +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider +import org.matrix.android.sdk.api.failure.isInvalidPassword +import org.matrix.android.sdk.api.failure.isInvalidUsername +import org.matrix.android.sdk.api.failure.isLoginEmailUnknown +import javax.inject.Inject + +class FtueAuthCombinedLoginFragment @Inject constructor() : AbstractSSOFtueAuthFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueCombinedLoginBinding { + return FragmentFtueCombinedLoginBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupSubmitButton() + views.createAccountRoot.realignPercentagesToParent() + views.editServerButton.debouncedClicks { + viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) + } + + views.createAccountPasswordInput.editText().setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupSubmitButton() { + views.createAccountSubmit.setOnClickListener { submit() } + observeInputFields() + .onEach { + views.createAccountPasswordInput.error = null + views.createAccountInput.error = null + views.createAccountSubmit.isEnabled = it + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun observeInputFields() = combine( + views.createAccountInput.hasContentFlow { it.trim() }, + views.createAccountPasswordInput.hasContentFlow(), + transform = { isLoginNotEmpty, isPasswordNotEmpty -> isLoginNotEmpty && isPasswordNotEmpty } + ) + + private fun submit() { + withState(viewModel) { state -> + cleanupUi() + + val login = views.createAccountInput.content() + val password = views.createAccountPasswordInput.content() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (login.isEmpty()) { + views.createAccountInput.error = getString(R.string.error_empty_field_choose_user_name) + error++ + } + if (state.isNumericOnlyUserIdForbidden() && login.isDigitsOnly()) { + views.createAccountInput.error = getString(R.string.error_forbidden_digits_only_username) + error++ + } + if (password.isEmpty()) { + views.createAccountPasswordInput.error = getString(R.string.error_empty_field_choose_password) + error++ + } + + if (error == 0) { + viewModel.handle(OnboardingAction.Login(login, password, getString(R.string.login_default_session_public_name))) + } + } + } + + private fun cleanupUi() { + views.createAccountSubmit.hideKeyboard() + views.createAccountInput.error = null + views.createAccountPasswordInput.error = null + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetAuthenticationAttempt) + } + + override fun onError(throwable: Throwable) { + // Trick to display the error without text. + views.createAccountInput.error = " " + when { + throwable.isInvalidUsername() -> { + views.createAccountInput.error = errorFormatter.toHumanReadable(throwable) + } + throwable.isLoginEmailUnknown() -> { + views.createAccountInput.error = getString(R.string.login_login_with_email_error) + } + throwable.isInvalidPassword() && views.createAccountPasswordInput.hasSurroundingSpaces() -> { + views.createAccountPasswordInput.error = getString(R.string.auth_invalid_login_param_space_in_password) + } + else -> { + super.onError(throwable) + } + } + } + + override fun updateWithState(state: OnboardingViewState) { + setupUi(state) + setupAutoFill() + + views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl() + views.selectedServerDescription.text = state.selectedHomeserver.description + + if (state.isLoading) { + // Ensure password is hidden + views.createAccountPasswordInput.editText().hidePassword() + } + } + + private fun setupUi(state: OnboardingViewState) { + when (state.selectedHomeserver.preferredLoginMode) { + is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders) + else -> hideSsoProviders() + } + } + + private fun renderSsoProviders(deviceId: String?, ssoProviders: List?) { + views.ssoGroup.isVisible = ssoProviders?.isNotEmpty() == true + views.ssoButtons.mode = SocialLoginButtonsView.Mode.MODE_CONTINUE + views.ssoButtons.ssoIdentityProviders = ssoProviders?.sorted() + views.ssoButtons.listener = SocialLoginButtonsView.InteractionListener { id -> + viewModel.getSsoUrl( + redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL, + deviceId = deviceId, + providerId = id + )?.let { openInCustomTab(it) } + } + } + + private fun hideSsoProviders() { + views.ssoGroup.isVisible = false + views.ssoButtons.ssoIdentityProviders = null + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.createAccountInput.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_USERNAME) + views.createAccountPasswordInput.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + } + } + + private fun OnboardingViewState.isNumericOnlyUserIdForbidden() = selectedHomeserver.userFacingUrl == getString(R.string.matrix_org_server_url) +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt index d7b8171225..5ad6b7e78d 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt @@ -233,7 +233,7 @@ class FtueAuthVariant( } private fun onStartCombinedLogin() { - addRegistrationStageFragmentToBackstack(FtueAuthCombinedRegisterFragment::class.java) + addRegistrationStageFragmentToBackstack(FtueAuthCombinedLoginFragment::class.java) } private fun onRegistrationFlow(viewEvents: OnboardingViewEvents.RegistrationFlowResult) { diff --git a/vector/src/main/res/layout/fragment_ftue_combined_login.xml b/vector/src/main/res/layout/fragment_ftue_combined_login.xml new file mode 100644 index 0000000000..684d6cf671 --- /dev/null +++ b/vector/src/main/res/layout/fragment_ftue_combined_login.xml @@ -0,0 +1,275 @@ + + + + + + + + + + + + + + + + + + + + + + + +