From 34e97112a491cdeab5b03a96b134a00e494d104b Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 29 Apr 2022 11:55:43 +0100 Subject: [PATCH 01/15] adding feature flag and redirecting the sign in flow - temporarily to the combined register fragment --- .../features/debug/features/DebugFeaturesStateFactory.kt | 5 +++++ .../app/features/debug/features/DebugVectorFeatures.kt | 4 ++++ .../src/main/java/im/vector/app/features/VectorFeatures.kt | 2 ++ .../vector/app/features/onboarding/OnboardingViewEvents.kt | 1 + .../vector/app/features/onboarding/OnboardingViewModel.kt | 4 +++- .../app/features/onboarding/ftueauth/FtueAuthVariant.kt | 7 ++++++- 6 files changed, 21 insertions(+), 2 deletions(-) diff --git a/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt b/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt index 00a073f832..aa4df5e308 100644 --- a/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt +++ b/vector/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt @@ -60,6 +60,11 @@ class DebugFeaturesStateFactory @Inject constructor( key = DebugFeatureKeys.onboardingCombinedRegister, factory = VectorFeatures::isOnboardingCombinedRegisterEnabled ), + createBooleanFeature( + label = "FTUE Combined login", + key = DebugFeatureKeys.onboardingCombinedLogin, + factory = VectorFeatures::isOnboardingCombinedLoginEnabled + ), ) ) } diff --git a/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt b/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt index 1bc37ff97e..f36b1a804a 100644 --- a/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt +++ b/vector/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt @@ -57,6 +57,9 @@ class DebugVectorFeatures( override fun isOnboardingCombinedRegisterEnabled(): Boolean = read(DebugFeatureKeys.onboardingCombinedRegister) ?: vectorFeatures.isOnboardingCombinedRegisterEnabled() + override fun isOnboardingCombinedLoginEnabled(): Boolean = read(DebugFeatureKeys.onboardingCombinedLogin) + ?: vectorFeatures.isOnboardingCombinedLoginEnabled() + override fun isScreenSharingEnabled(): Boolean = read(DebugFeatureKeys.screenSharing) ?: vectorFeatures.isScreenSharingEnabled() @@ -113,6 +116,7 @@ object DebugFeatureKeys { val onboardingUseCase = booleanPreferencesKey("onboarding-splash-carousel") val onboardingPersonalize = booleanPreferencesKey("onboarding-personalize") val onboardingCombinedRegister = booleanPreferencesKey("onboarding-combined-register") + val onboardingCombinedLogin = booleanPreferencesKey("onboarding-combined-login") val liveLocationSharing = booleanPreferencesKey("live-location-sharing") val screenSharing = booleanPreferencesKey("screen-sharing") } diff --git a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt index e3fded2824..6a7a0865de 100644 --- a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt +++ b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt @@ -26,6 +26,7 @@ interface VectorFeatures { fun isOnboardingUseCaseEnabled(): Boolean fun isOnboardingPersonalizeEnabled(): Boolean fun isOnboardingCombinedRegisterEnabled(): Boolean + fun isOnboardingCombinedLoginEnabled(): Boolean fun isScreenSharingEnabled(): Boolean enum class OnboardingVariant { @@ -42,5 +43,6 @@ class DefaultVectorFeatures : VectorFeatures { override fun isOnboardingUseCaseEnabled() = true override fun isOnboardingPersonalizeEnabled() = false override fun isOnboardingCombinedRegisterEnabled() = false + override fun isOnboardingCombinedLoginEnabled() = false override fun isScreenSharingEnabled(): Boolean = true } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt index 6ffece4ab6..5dbcd162f3 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt @@ -37,6 +37,7 @@ sealed class OnboardingViewEvents : VectorViewEvents { object OpenUseCaseSelection : OnboardingViewEvents() object OpenServerSelection : OnboardingViewEvents() object OpenCombinedRegister : OnboardingViewEvents() + object OpenCombinedLogin : OnboardingViewEvents() object EditServerSelection : OnboardingViewEvents() data class OnServerSelectionDone(val serverType: ServerType) : OnboardingViewEvents() object OnLoginFlowRetrieved : OnboardingViewEvents() 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 cf730a0266..4ee26c2976 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 @@ -194,7 +194,9 @@ class OnboardingViewModel @AssistedInject constructor( } else { OnboardingViewEvents.OpenServerSelection } - OnboardingFlow.SignIn, + OnboardingFlow.SignIn -> if (vectorFeatures.isOnboardingCombinedRegisterEnabled()) { + OnboardingViewEvents.OpenCombinedLogin + } else OnboardingViewEvents.OpenServerSelection OnboardingFlow.SignInSignUp -> OnboardingViewEvents.OpenServerSelection } _viewEvents.post(nextOnboardingStep) 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 8430b483d2..d7b8171225 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 @@ -227,10 +227,15 @@ class FtueAuthVariant( option = commonOption ) } - OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack() + OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack() + OnboardingViewEvents.OpenCombinedLogin -> onStartCombinedLogin() } } + private fun onStartCombinedLogin() { + addRegistrationStageFragmentToBackstack(FtueAuthCombinedRegisterFragment::class.java) + } + private fun onRegistrationFlow(viewEvents: OnboardingViewEvents.RegistrationFlowResult) { when { registrationShouldFallback(viewEvents) -> displayFallbackWebDialog() From 4b6f74364d68e09265eb1ecb4d6665ba6d75a230 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 5 May 2022 14:01:18 +0100 Subject: [PATCH 02/15] 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 @@ + + + + + + + + + + + + + + + + + + + + + + + +