diff --git a/changelog.d/5283.wip b/changelog.d/5283.wip new file mode 100644 index 0000000000..1c2fbfcd61 --- /dev/null +++ b/changelog.d/5283.wip @@ -0,0 +1 @@ +FTUE - Adds the redesigned Sign In screen 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/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index 3dba8b797b..e76f0ad672 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -101,6 +101,9 @@ import im.vector.app.features.onboarding.ftueauth.FtueAuthAccountCreatedFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthCaptchaFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseDisplayNameFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseProfilePictureFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthCombinedLoginFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthCombinedRegisterFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthCombinedServerSelectionFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthEmailEntryFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthGenericTextInputFormFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthLegacyStyleCaptchaFragment @@ -521,6 +524,21 @@ interface FragmentModule { @FragmentKey(FtueAuthPersonalizationCompleteFragment::class) fun bindFtueAuthPersonalizationCompleteFragment(fragment: FtueAuthPersonalizationCompleteFragment): Fragment + @Binds + @IntoMap + @FragmentKey(FtueAuthCombinedLoginFragment::class) + fun bindFtueAuthCombinedLoginFragment(fragment: FtueAuthCombinedLoginFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthCombinedRegisterFragment::class) + fun bindFtueAuthCombinedRegisterFragment(fragment: FtueAuthCombinedRegisterFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthCombinedServerSelectionFragment::class) + fun bindFtueAuthCombinedServerSelectionFragment(fragment: FtueAuthCombinedServerSelectionFragment): Fragment + @Binds @IntoMap @FragmentKey(UserListFragment::class) 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/login/SocialLoginButtonsView.kt b/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt index 68fc2d1c59..49fa815a56 100644 --- a/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt +++ b/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt @@ -159,3 +159,9 @@ class SocialLoginButtonsView @JvmOverloads constructor(context: Context, attrs: return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), resources.displayMetrics).toInt() } } + +fun SocialLoginButtonsView.render(ssoProviders: List<SsoIdentityProvider>?, mode: SocialLoginButtonsView.Mode, listener: (String?) -> Unit) { + this.mode = mode + this.ssoIdentityProviders = ssoProviders?.sorted() + this.listener = SocialLoginButtonsView.InteractionListener { listener(it) } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/DirectLoginUseCase.kt b/vector/src/main/java/im/vector/app/features/onboarding/DirectLoginUseCase.kt index 3014b199b4..925c838d80 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/DirectLoginUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/DirectLoginUseCase.kt @@ -19,7 +19,7 @@ package im.vector.app.features.onboarding import im.vector.app.R import im.vector.app.core.extensions.andThen import im.vector.app.core.resources.StringProvider -import im.vector.app.features.onboarding.OnboardingAction.LoginOrRegister +import im.vector.app.features.onboarding.OnboardingAction.AuthenticateAction.LoginDirect import org.matrix.android.sdk.api.MatrixPatterns.getServerName import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig @@ -33,8 +33,8 @@ class DirectLoginUseCase @Inject constructor( private val uriFactory: UriFactory ) { - suspend fun execute(action: LoginOrRegister, homeServerConnectionConfig: HomeServerConnectionConfig?): Result<Session> { - return fetchWellKnown(action.username, homeServerConnectionConfig) + suspend fun execute(action: LoginDirect, homeServerConnectionConfig: HomeServerConnectionConfig?): Result<Session> { + return fetchWellKnown(action.matrixId, homeServerConnectionConfig) .andThen { wellKnown -> createSessionFor(wellKnown, action, homeServerConnectionConfig) } } @@ -42,13 +42,13 @@ class DirectLoginUseCase @Inject constructor( authenticationService.getWellKnownData(matrixId, config) } - private suspend fun createSessionFor(data: WellknownResult, action: LoginOrRegister, config: HomeServerConnectionConfig?) = when (data) { - is WellknownResult.Prompt -> loginDirect(action, data, config) + private suspend fun createSessionFor(data: WellknownResult, action: LoginDirect, config: HomeServerConnectionConfig?) = when (data) { + is WellknownResult.Prompt -> loginDirect(action, data, config) is WellknownResult.FailPrompt -> handleFailPrompt(data, action, config) - else -> onWellKnownError() + else -> onWellKnownError() } - private suspend fun handleFailPrompt(data: WellknownResult.FailPrompt, action: LoginOrRegister, config: HomeServerConnectionConfig?): Result<Session> { + private suspend fun handleFailPrompt(data: WellknownResult.FailPrompt, action: LoginDirect, config: HomeServerConnectionConfig?): Result<Session> { // Relax on IS discovery if homeserver is valid val isMissingInformationToLogin = data.homeServerUrl == null || data.wellKnown == null return when { @@ -57,12 +57,12 @@ class DirectLoginUseCase @Inject constructor( } } - private suspend fun loginDirect(action: LoginOrRegister, wellKnownPrompt: WellknownResult.Prompt, config: HomeServerConnectionConfig?): Result<Session> { + private suspend fun loginDirect(action: LoginDirect, wellKnownPrompt: WellknownResult.Prompt, config: HomeServerConnectionConfig?): Result<Session> { val alteredHomeServerConnectionConfig = config?.updateWith(wellKnownPrompt) ?: fallbackConfig(action, wellKnownPrompt) return runCatching { authenticationService.directAuthentication( alteredHomeServerConnectionConfig, - action.username, + action.matrixId, action.password, action.initialDeviceName ) @@ -74,8 +74,8 @@ class DirectLoginUseCase @Inject constructor( identityServerUri = wellKnownPrompt.identityServerUrl?.let { uriFactory.parse(it) } ) - private fun fallbackConfig(action: LoginOrRegister, wellKnownPrompt: WellknownResult.Prompt) = HomeServerConnectionConfig( - homeServerUri = uriFactory.parse("https://${action.username.getServerName()}"), + private fun fallbackConfig(action: LoginDirect, wellKnownPrompt: WellknownResult.Prompt) = HomeServerConnectionConfig( + homeServerUri = uriFactory.parse("https://${action.matrixId.getServerName()}"), homeServerUriBase = uriFactory.parse(wellKnownPrompt.homeServerUrl), identityServerUri = wellKnownPrompt.identityServerUrl?.let { uriFactory.parse(it) } ) 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..bef624ddc4 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 @@ -46,9 +46,12 @@ sealed interface OnboardingAction : VectorViewModelAction { data class ResetPassword(val email: String, val newPassword: String) : OnboardingAction object ResetPasswordMailConfirmed : OnboardingAction - // 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 + sealed interface AuthenticateAction : OnboardingAction { + data class Register(val username: String, val password: String, val initialDeviceName: String) : AuthenticateAction + data class Login(val username: String, val password: String, val initialDeviceName: String) : AuthenticateAction + data class LoginDirect(val matrixId: String, val password: String, val initialDeviceName: String) : AuthenticateAction + } + object StopEmailValidationCheck : OnboardingAction data class PostRegisterAction(val registerAction: RegisterAction) : OnboardingAction 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..0bd61758bc 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 @@ -42,6 +42,7 @@ import im.vector.app.features.login.LoginMode import im.vector.app.features.login.ReAuthHelper import im.vector.app.features.login.ServerType import im.vector.app.features.login.SignMode +import im.vector.app.features.onboarding.OnboardingAction.AuthenticateAction import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult import im.vector.app.features.onboarding.ftueauth.MatrixOrgRegistrationStagesComparator import kotlinx.coroutines.Job @@ -139,8 +140,7 @@ class OnboardingViewModel @AssistedInject constructor( is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action) is OnboardingAction.InitWith -> handleInitWith(action) is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action) } - is OnboardingAction.LoginOrRegister -> handleLoginOrRegister(action).also { lastAction = action } - is OnboardingAction.Register -> handleRegisterWith(action).also { lastAction = action } + is AuthenticateAction -> withAction(action) { handleAuthenticateAction(action) } is OnboardingAction.LoginWithToken -> handleLoginWithToken(action) is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action) is OnboardingAction.ResetPassword -> handleResetPassword(action) @@ -165,6 +165,14 @@ class OnboardingViewModel @AssistedInject constructor( block(action) } + private fun handleAuthenticateAction(action: AuthenticateAction) { + when (action) { + is AuthenticateAction.Register -> handleRegisterWith(action) + is AuthenticateAction.Login -> handleLogin(action) + is AuthenticateAction.LoginDirect -> handleDirectLogin(action, homeServerConnectionConfig = null) + } + } + private fun handleSplashAction(resetConfig: Boolean, onboardingFlow: OnboardingFlow) { if (resetConfig) { loginConfig = null @@ -188,16 +196,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, - 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) { @@ -209,7 +222,7 @@ class OnboardingViewModel @AssistedInject constructor( ?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) } ?.let { startAuthenticationFlow(finalLastAction, it) } } - is OnboardingAction.LoginOrRegister -> + is AuthenticateAction.LoginDirect -> handleDirectLogin( finalLastAction, HomeServerConnectionConfig.Builder() @@ -307,7 +320,7 @@ class OnboardingViewModel @AssistedInject constructor( private fun OnboardingViewState.hasSelectedMatrixOrg() = selectedHomeserver.userFacingUrl == matrixOrgUrl - private fun handleRegisterWith(action: OnboardingAction.Register) { + private fun handleRegisterWith(action: AuthenticateAction.Register) { reAuthHelper.data = action.password handleRegisterAction( RegisterAction.CreateAccount( @@ -482,16 +495,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.SignUp -> handleRegisterWith(OnboardingAction.Register(action.username, action.password, action.initialDeviceName)) - SignMode.SignInWithMatrixId -> handleDirectLogin(action, null) - } - } - - private fun handleDirectLogin(action: OnboardingAction.LoginOrRegister, homeServerConnectionConfig: HomeServerConnectionConfig?) { + private fun handleDirectLogin(action: AuthenticateAction.LoginDirect, homeServerConnectionConfig: HomeServerConnectionConfig?) { setState { copy(isLoading = true) } currentJob = viewModelScope.launch { directLoginUseCase.execute(action, homeServerConnectionConfig).fold( @@ -504,7 +508,7 @@ class OnboardingViewModel @AssistedInject constructor( } } - private fun handleLogin(action: OnboardingAction.LoginOrRegister) { + private fun handleLogin(action: AuthenticateAction.Login) { val safeLoginWizard = loginWizard if (safeLoginWizard == null) { @@ -648,7 +652,11 @@ class OnboardingViewModel @AssistedInject constructor( when (trigger) { is OnboardingAction.HomeServerChange.EditHomeServer -> { when (awaitState().onboardingFlow) { - OnboardingFlow.SignUp -> internalRegisterAction(RegisterAction.StartRegistration) { _ -> + OnboardingFlow.SignUp -> internalRegisterAction(RegisterAction.StartRegistration) { + updateServerSelection(config, serverTypeOverride, authResult) + _viewEvents.post(OnboardingViewEvents.OnHomeserverEdited) + } + OnboardingFlow.SignIn -> { updateServerSelection(config, serverTypeOverride, authResult) _viewEvents.post(OnboardingViewEvents.OnHomeserverEdited) } @@ -661,7 +669,10 @@ class OnboardingViewModel @AssistedInject constructor( when (awaitState().onboardingFlow) { OnboardingFlow.SignIn -> { updateSignMode(SignMode.SignIn) - _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignIn)) + when (vectorFeatures.isOnboardingCombinedLoginEnabled()) { + true -> _viewEvents.post(OnboardingViewEvents.OpenCombinedLogin) + false -> _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignIn)) + } } OnboardingFlow.SignUp -> { updateSignMode(SignMode.SignUp) 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..7324c4fbb1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedLoginFragment.kt @@ -0,0 +1,161 @@ +/* + * 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 androidx.autofill.HintConstants +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +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.hideKeyboard +import im.vector.app.core.extensions.hidePassword +import im.vector.app.core.extensions.realignPercentagesToParent +import im.vector.app.core.extensions.setOnImeDoneListener +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.login.render +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.launchIn +import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider +import javax.inject.Inject + +class FtueAuthCombinedLoginFragment @Inject constructor( + private val loginFieldsValidation: LoginFieldsValidation, + private val loginErrorParser: LoginErrorParser +) : AbstractSSOFtueAuthFragment<FragmentFtueCombinedLoginBinding>() { + + 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.loginRoot.realignPercentagesToParent() + views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) } + views.loginPasswordInput.setOnImeDoneListener { submit() } + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + observeContentChangesAndResetErrors(views.loginInput, views.loginPasswordInput, views.loginSubmit) + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun submit() { + cleanupUi() + loginFieldsValidation.validate(views.loginInput.content(), views.loginPasswordInput.content()) + .onUsernameOrIdError { views.loginInput.error = it } + .onPasswordError { views.loginPasswordInput.error = it } + .onValid { usernameOrId, password -> + val initialDeviceName = getString(R.string.login_default_session_public_name) + viewModel.handle(OnboardingAction.AuthenticateAction.Login(usernameOrId, password, initialDeviceName)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.loginInput.error = null + views.loginPasswordInput.error = null + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetAuthenticationAttempt) + } + + override fun onError(throwable: Throwable) { + // Trick to display the error without text. + views.loginInput.error = " " + loginErrorParser.parse(throwable, views.loginPasswordInput.content()) + .onUnknown { super.onError(it) } + .onUsernameOrIdError { views.loginInput.error = it } + .onPasswordError { views.loginPasswordInput.error = it } + } + + 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.loginPasswordInput.editText().hidePassword() + } + } + + private fun setupUi(state: OnboardingViewState) { + when (state.selectedHomeserver.preferredLoginMode) { + is LoginMode.SsoAndPassword -> { + showUsernamePassword() + renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders) + } + is LoginMode.Sso -> { + hideUsernamePassword() + renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders) + } + else -> { + showUsernamePassword() + hideSsoProviders() + } + } + } + + private fun renderSsoProviders(deviceId: String?, ssoProviders: List<SsoIdentityProvider>?) { + views.ssoGroup.isVisible = ssoProviders?.isNotEmpty() == true + views.ssoButtonsHeader.isVisible = views.ssoGroup.isVisible && views.loginEntryGroup.isVisible + views.ssoButtons.render(ssoProviders, SocialLoginButtonsView.Mode.MODE_CONTINUE) { 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 hideUsernamePassword() { + views.loginEntryGroup.isVisible = false + } + + private fun showUsernamePassword() { + views.loginEntryGroup.isVisible = true + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.loginInput.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_USERNAME) + views.loginPasswordInput.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt index 0755f18c8c..62aa0854c3 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt @@ -21,7 +21,6 @@ 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 @@ -31,22 +30,22 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.setOnImeDoneListener import im.vector.app.core.extensions.toReducedUrl import im.vector.app.databinding.FragmentFtueCombinedRegisterBinding 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.login.render import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingAction.AuthenticateAction 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 @@ -66,36 +65,16 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu 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 - } + views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) } + views.createAccountPasswordInput.setOnImeDoneListener { submit() } } private fun setupSubmitButton() { views.createAccountSubmit.setOnClickListener { submit() } - observeInputFields() - .onEach { - views.createAccountPasswordInput.error = null - views.createAccountInput.error = null - views.createAccountSubmit.isEnabled = it - } + observeContentChangesAndResetErrors(views.createAccountInput, views.createAccountPasswordInput, views.createAccountSubmit) .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() @@ -119,7 +98,7 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu } if (error == 0) { - viewModel.handle(OnboardingAction.Register(login, password, getString(R.string.login_default_session_public_name))) + viewModel.handle(AuthenticateAction.Register(login, password, getString(R.string.login_default_session_public_name))) } } } @@ -185,9 +164,7 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu private fun renderSsoProviders(deviceId: String?, ssoProviders: List<SsoIdentityProvider>?) { views.ssoGroup.isVisible = ssoProviders?.isNotEmpty() == true - views.ssoButtons.mode = SocialLoginButtonsView.Mode.MODE_CONTINUE - views.ssoButtons.ssoIdentityProviders = ssoProviders?.sorted() - views.ssoButtons.listener = SocialLoginButtonsView.InteractionListener { id -> + views.ssoButtons.render(ssoProviders, SocialLoginButtonsView.Mode.MODE_CONTINUE) { id -> viewModel.getSsoUrl( redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL, deviceId = deviceId, diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt index 2308280400..98d9a24999 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt @@ -26,6 +26,7 @@ import androidx.autofill.HintConstants import androidx.core.text.isDigitsOnly import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder import im.vector.app.R import im.vector.app.core.extensions.hideKeyboard @@ -119,40 +120,43 @@ class FtueAuthLoginFragment @Inject constructor() : AbstractSSOFtueAuthFragment< } private fun submit() { - cleanupUi() + withState(viewModel) { state -> + cleanupUi() - val login = views.loginField.text.toString() - val password = views.passwordField.text.toString() + val login = views.loginField.text.toString() + val password = views.passwordField.text.toString() - // This can be called by the IME action, so deal with empty cases - var error = 0 - if (login.isEmpty()) { - views.loginFieldTil.error = getString( - if (isSignupMode) { - R.string.error_empty_field_choose_user_name - } else { - R.string.error_empty_field_enter_user_name - } - ) - error++ - } - if (isSignupMode && isNumericOnlyUserIdForbidden && login.isDigitsOnly()) { - views.loginFieldTil.error = getString(R.string.error_forbidden_digits_only_username) - error++ - } - if (password.isEmpty()) { - views.passwordFieldTil.error = getString( - if (isSignupMode) { - R.string.error_empty_field_choose_password - } else { - R.string.error_empty_field_your_password - } - ) - error++ - } + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (login.isEmpty()) { + views.loginFieldTil.error = getString( + if (isSignupMode) { + R.string.error_empty_field_choose_user_name + } else { + R.string.error_empty_field_enter_user_name + } + ) + error++ + } + if (isSignupMode && isNumericOnlyUserIdForbidden && login.isDigitsOnly()) { + views.loginFieldTil.error = getString(R.string.error_forbidden_digits_only_username) + error++ + } + if (password.isEmpty()) { + views.passwordFieldTil.error = getString( + if (isSignupMode) { + R.string.error_empty_field_choose_password + } else { + R.string.error_empty_field_your_password + } + ) + error++ + } - if (error == 0) { - viewModel.handle(OnboardingAction.LoginOrRegister(login, password, getString(R.string.login_default_session_public_name))) + if (error == 0) { + val initialDeviceName = getString(R.string.login_default_session_public_name) + viewModel.handle(state.signMode.toAuthenticateAction(login, password, initialDeviceName)) + } } } 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..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 @@ -227,10 +227,15 @@ class FtueAuthVariant( option = commonOption ) } - OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack() + OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack() + OnboardingViewEvents.OpenCombinedLogin -> onStartCombinedLogin() } } + private fun onStartCombinedLogin() { + addRegistrationStageFragmentToBackstack(FtueAuthCombinedLoginFragment::class.java) + } + private fun onRegistrationFlow(viewEvents: OnboardingViewEvents.RegistrationFlowResult) { when { registrationShouldFallback(viewEvents) -> displayFallbackWebDialog() diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueExtensions.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueExtensions.kt new file mode 100644 index 0000000000..8d63fbf547 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueExtensions.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 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.widget.Button +import com.google.android.material.textfield.TextInputLayout +import im.vector.app.core.extensions.hasContentFlow +import im.vector.app.features.login.SignMode +import im.vector.app.features.onboarding.OnboardingAction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach + +fun SignMode.toAuthenticateAction(login: String, password: String, initialDeviceName: String): OnboardingAction.AuthenticateAction { + return when (this) { + SignMode.Unknown -> error("developer error") + SignMode.SignUp -> OnboardingAction.AuthenticateAction.Register(username = login, password, initialDeviceName) + SignMode.SignIn -> OnboardingAction.AuthenticateAction.Login(username = login, password, initialDeviceName) + SignMode.SignInWithMatrixId -> OnboardingAction.AuthenticateAction.LoginDirect(matrixId = login, password, initialDeviceName) + } +} + +/** + * A flow to monitor content changes from both username/id and password fields, + * clearing errors and enabling/disabling the submission button on non empty content changes. + */ +fun observeContentChangesAndResetErrors(username: TextInputLayout, password: TextInputLayout, submit: Button): Flow<*> { + return combine( + username.hasContentFlow { it.trim() }, + password.hasContentFlow(), + transform = { usernameHasContent, passwordHasContent -> usernameHasContent && passwordHasContent } + ).onEach { + username.error = null + password.error = null + submit.isEnabled = it + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/LoginErrorParser.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/LoginErrorParser.kt new file mode 100644 index 0000000000..a92fdea04a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/LoginErrorParser.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 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 im.vector.app.R +import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.onboarding.ftueauth.LoginErrorParser.LoginErrorResult +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 LoginErrorParser @Inject constructor( + private val errorFormatter: ErrorFormatter, + private val stringProvider: StringProvider, +) { + fun parse(throwable: Throwable, password: String): LoginErrorResult { + return when { + throwable.isInvalidUsername() -> { + LoginErrorResult(throwable, usernameOrIdError = errorFormatter.toHumanReadable(throwable)) + } + throwable.isLoginEmailUnknown() -> { + LoginErrorResult(throwable, usernameOrIdError = stringProvider.getString(R.string.login_login_with_email_error)) + } + throwable.isInvalidPassword() && password.hasSurroundingSpaces() -> { + LoginErrorResult(throwable, passwordError = stringProvider.getString(R.string.auth_invalid_login_param_space_in_password)) + } + else -> { + LoginErrorResult(throwable) + } + } + } + + private fun String.hasSurroundingSpaces() = trim() != this + + data class LoginErrorResult(val cause: Throwable, val usernameOrIdError: String? = null, val passwordError: String? = null) +} + +fun LoginErrorResult.onUnknown(action: (Throwable) -> Unit): LoginErrorResult { + when { + usernameOrIdError == null && passwordError == null -> action(cause) + } + return this +} + +fun LoginErrorResult.onUsernameOrIdError(action: (String) -> Unit): LoginErrorResult { + usernameOrIdError?.let(action) + return this +} + +fun LoginErrorResult.onPasswordError(action: (String) -> Unit): LoginErrorResult { + passwordError?.let(action) + return this +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/LoginFieldsValidation.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/LoginFieldsValidation.kt new file mode 100644 index 0000000000..659a8cd2c1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/LoginFieldsValidation.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 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 im.vector.app.R +import im.vector.app.core.resources.StringProvider +import javax.inject.Inject + +class LoginFieldsValidation @Inject constructor( + private val stringProvider: StringProvider +) { + + fun validate(usernameOrId: String, password: String): LoginValidationResult { + return LoginValidationResult(usernameOrId, password, validateUsernameOrId(usernameOrId), validatePassword(password)) + } + + private fun validateUsernameOrId(usernameOrId: String): String? { + val accountError = when { + usernameOrId.isEmpty() -> stringProvider.getString(R.string.error_empty_field_enter_user_name) + else -> null + } + return accountError + } + + private fun validatePassword(password: String): String? { + val passwordError = when { + password.isEmpty() -> stringProvider.getString(R.string.error_empty_field_your_password) + else -> null + } + return passwordError + } +} + +fun LoginValidationResult.onValid(action: (String, String) -> Unit): LoginValidationResult { + when { + usernameOrIdError == null && passwordError == null -> action(usernameOrId, password) + } + return this +} + +fun LoginValidationResult.onUsernameOrIdError(action: (String) -> Unit): LoginValidationResult { + usernameOrIdError?.let(action) + return this +} + +fun LoginValidationResult.onPasswordError(action: (String) -> Unit): LoginValidationResult { + passwordError?.let(action) + return this +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/LoginValidationResult.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/LoginValidationResult.kt new file mode 100644 index 0000000000..caf127332a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/LoginValidationResult.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 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 + +data class LoginValidationResult( + val usernameOrId: String, + val password: String, + val usernameOrIdError: String?, + val passwordError: String? +) 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..1b65056e9f --- /dev/null +++ b/vector/src/main/res/layout/fragment_ftue_combined_login.xml @@ -0,0 +1,244 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + style="@style/LoginFormScrollView" + android:layout_height="match_parent" + android:background="?android:colorBackground" + android:fillViewport="true" + android:paddingTop="0dp" + android:paddingBottom="0dp"> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/loginRoot" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/loginGutterStart" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:orientation="vertical" + app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_start_percent" /> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/loginGutterEnd" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:orientation="vertical" + app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_end_percent" /> + + <Space + android:id="@+id/headerSpacing" + android:layout_width="match_parent" + android:layout_height="52dp" + app:layout_constraintBottom_toTopOf="@id/loginHeaderTitle" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_bias="0" + app:layout_constraintVertical_chainStyle="packed" /> + + <TextView + android:id="@+id/loginHeaderTitle" + style="@style/Widget.Vector.TextView.Title.Medium" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:gravity="center" + android:text="@string/ftue_auth_welcome_back_title" + android:textColor="?vctr_content_primary" + app:layout_constraintBottom_toTopOf="@id/titleContentSpacing" + app:layout_constraintEnd_toEndOf="@id/loginGutterEnd" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toBottomOf="@id/headerSpacing" /> + + <Space + android:id="@+id/titleContentSpacing" + android:layout_width="match_parent" + android:layout_height="0dp" + app:layout_constraintBottom_toTopOf="@id/chooseYourServerHeader" + app:layout_constraintHeight_percent="0.03" + app:layout_constraintTop_toBottomOf="@id/loginHeaderTitle" /> + + <TextView + android:id="@+id/chooseYourServerHeader" + style="@style/Widget.Vector.TextView.Caption" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="12dp" + android:layout_marginTop="4dp" + android:text="@string/ftue_auth_create_account_choose_server_header" + android:textColor="?vctr_content_secondary" + app:layout_constraintBottom_toTopOf="@id/selectedServerName" + app:layout_constraintEnd_toStartOf="@id/editServerButton" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toBottomOf="@id/titleContentSpacing" /> + + <TextView + android:id="@+id/selectedServerName" + style="@style/Widget.Vector.TextView.Subtitle" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="12dp" + android:textColor="?vctr_content_primary" + app:layout_constraintBottom_toTopOf="@id/selectedServerDescription" + app:layout_constraintEnd_toStartOf="@id/editServerButton" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toBottomOf="@id/chooseYourServerHeader" /> + + <TextView + android:id="@+id/selectedServerDescription" + style="@style/Widget.Vector.TextView.Micro" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="12dp" + android:textColor="?vctr_content_tertiary" + app:layout_constraintBottom_toTopOf="@id/serverSelectionSpacing" + app:layout_constraintEnd_toStartOf="@id/editServerButton" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toBottomOf="@id/selectedServerName" /> + + <Button + android:id="@+id/editServerButton" + style="@style/Widget.Vector.Button.Outlined" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:minWidth="0dp" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:text="@string/ftue_auth_create_account_edit_server_selection" + android:textAllCaps="true" + app:layout_constraintBottom_toBottomOf="@id/selectedServerDescription" + app:layout_constraintEnd_toEndOf="@id/loginGutterEnd" + app:layout_constraintTop_toTopOf="@id/chooseYourServerHeader" /> + + <Space + android:id="@+id/serverSelectionSpacing" + android:layout_width="match_parent" + android:layout_height="0dp" + app:layout_constraintBottom_toTopOf="@id/loginInput" + app:layout_constraintHeight_percent="0.05" + app:layout_constraintTop_toBottomOf="@id/selectedServerDescription" /> + + <View + android:layout_width="0dp" + android:layout_height="1dp" + android:background="?vctr_content_quaternary" + app:layout_constraintBottom_toBottomOf="@id/serverSelectionSpacing" + app:layout_constraintEnd_toEndOf="@id/loginGutterEnd" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toTopOf="@id/serverSelectionSpacing" /> + + <androidx.constraintlayout.widget.Group + android:id="@+id/loginEntryGroup" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:visibility="visible" + app:constraint_referenced_ids="loginInput,loginPasswordInput,entrySpacing,actionSpacing,loginSubmit" /> + + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/loginInput" + style="@style/Widget.Vector.TextInputLayout.Username" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:hint="@string/username" + app:layout_constraintBottom_toTopOf="@id/entrySpacing" + app:layout_constraintEnd_toEndOf="@id/loginGutterEnd" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toBottomOf="@id/serverSelectionSpacing"> + + <com.google.android.material.textfield.TextInputEditText + android:layout_width="match_parent" + android:layout_height="match_parent" + android:imeOptions="actionNext" + android:inputType="text" + android:maxLines="1" + android:nextFocusForward="@id/loginPasswordInput" /> + + </com.google.android.material.textfield.TextInputLayout> + + <Space + android:id="@+id/entrySpacing" + android:layout_width="match_parent" + android:layout_height="0dp" + app:layout_constraintBottom_toTopOf="@id/loginPasswordInput" + app:layout_constraintHeight_percent="0.03" + app:layout_constraintTop_toBottomOf="@id/loginInput" /> + + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/loginPasswordInput" + style="@style/Widget.Vector.TextInputLayout.Password" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:hint="@string/login_signup_password_hint" + app:layout_constraintBottom_toTopOf="@id/actionSpacing" + app:layout_constraintEnd_toEndOf="@id/loginGutterEnd" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toBottomOf="@id/entrySpacing"> + + <com.google.android.material.textfield.TextInputEditText + android:layout_width="match_parent" + android:layout_height="match_parent" + android:imeOptions="actionDone" + android:inputType="textPassword" + android:maxLines="1" /> + + </com.google.android.material.textfield.TextInputLayout> + + <Space + android:id="@+id/actionSpacing" + android:layout_width="match_parent" + android:layout_height="0dp" + app:layout_constraintBottom_toTopOf="@id/loginSubmit" + app:layout_constraintHeight_percent="0.02" + app:layout_constraintTop_toBottomOf="@id/loginPasswordInput" /> + + <Button + android:id="@+id/loginSubmit" + style="@style/Widget.Vector.Button.Login" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:text="@string/login_signup_submit" + android:textAllCaps="true" + app:layout_constraintBottom_toTopOf="@id/ssoButtonsHeader" + app:layout_constraintEnd_toEndOf="@id/loginGutterEnd" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toBottomOf="@id/actionSpacing" /> + + <androidx.constraintlayout.widget.Group + android:id="@+id/ssoGroup" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:visibility="gone" + app:constraint_referenced_ids="ssoButtonsHeader,ssoButtons" + app:layout_constraintBottom_toTopOf="@id/ssoButtonsHeader" + app:layout_constraintTop_toBottomOf="@id/loginSubmit" + tools:visibility="visible" /> + + <TextView + android:id="@+id/ssoButtonsHeader" + style="@style/Widget.Vector.TextView.Subtitle.Medium" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginBottom="16dp" + android:text="@string/ftue_auth_create_account_sso_section_header" + android:textColor="?vctr_content_secondary" + app:layout_constraintBottom_toTopOf="@id/ssoButtons" + app:layout_constraintEnd_toEndOf="@id/loginGutterEnd" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toBottomOf="@id/loginSubmit" /> + + <im.vector.app.features.login.SocialLoginButtonsView + android:id="@+id/ssoButtons" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginBottom="16dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="@id/loginGutterEnd" + app:layout_constraintStart_toStartOf="@id/loginGutterStart" + app:layout_constraintTop_toBottomOf="@id/ssoButtonsHeader" + tools:signMode="signup" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + +</androidx.core.widget.NestedScrollView> diff --git a/vector/src/main/res/values/donottranslate.xml b/vector/src/main/res/values/donottranslate.xml index 2dcd36d7c8..eb8efa0ad2 100755 --- a/vector/src/main/res/values/donottranslate.xml +++ b/vector/src/main/res/values/donottranslate.xml @@ -19,6 +19,8 @@ <string name="ftue_auth_create_account_matrix_dot_org_server_description">Join millions for free on the largest public server</string> <string name="ftue_auth_create_account_edit_server_selection">Edit</string> + <string name="ftue_auth_welcome_back_title">Welcome back!</string> + <string name="ftue_auth_choose_server_title">Choose your server</string> <string name="ftue_auth_choose_server_subtitle">What is the address of your server? Server is like a home for all your data.</string> <string name="ftue_auth_choose_server_entry_hint">Server URL</string> diff --git a/vector/src/test/java/im/vector/app/features/onboarding/DirectLoginUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/DirectLoginUseCaseTest.kt index 1fed03b601..d3600940ab 100644 --- a/vector/src/test/java/im/vector/app/features/onboarding/DirectLoginUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/onboarding/DirectLoginUseCaseTest.kt @@ -32,13 +32,13 @@ import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.WellKnown import org.matrix.android.sdk.api.auth.wellknown.WellknownResult -private val A_LOGIN_OR_REGISTER_ACTION = OnboardingAction.LoginOrRegister("@a-user:id.org", "a-password", "a-device-name") +private val A_DIRECT_LOGIN_ACTION = OnboardingAction.AuthenticateAction.LoginDirect("@a-user:id.org", "a-password", "a-device-name") private val A_WELLKNOWN_SUCCESS_RESULT = WellknownResult.Prompt("https://homeserverurl.com", identityServerUrl = null, WellKnown()) private val A_WELLKNOWN_FAILED_WITH_CONTENT_RESULT = WellknownResult.FailPrompt("https://homeserverurl.com", WellKnown()) private val A_WELLKNOWN_FAILED_WITHOUT_CONTENT_RESULT = WellknownResult.FailPrompt(null, null) private val NO_HOMESERVER_CONFIG: HomeServerConnectionConfig? = null private val A_FALLBACK_CONFIG: HomeServerConnectionConfig = HomeServerConnectionConfig( - homeServerUri = FakeUri("https://${A_LOGIN_OR_REGISTER_ACTION.username.getServerName()}").instance, + homeServerUri = FakeUri("https://${A_DIRECT_LOGIN_ACTION.matrixId.getServerName()}").instance, homeServerUriBase = FakeUri(A_WELLKNOWN_SUCCESS_RESULT.homeServerUrl).instance, identityServerUri = null ) @@ -54,11 +54,11 @@ class DirectLoginUseCaseTest { @Test fun `when logging in directly, then returns success with direct session result`() = runTest { - fakeAuthenticationService.givenWellKnown(A_LOGIN_OR_REGISTER_ACTION.username, config = NO_HOMESERVER_CONFIG, result = A_WELLKNOWN_SUCCESS_RESULT) - val (username, password, initialDeviceName) = A_LOGIN_OR_REGISTER_ACTION + fakeAuthenticationService.givenWellKnown(A_DIRECT_LOGIN_ACTION.matrixId, config = NO_HOMESERVER_CONFIG, result = A_WELLKNOWN_SUCCESS_RESULT) + val (username, password, initialDeviceName) = A_DIRECT_LOGIN_ACTION fakeAuthenticationService.givenDirectAuthentication(A_FALLBACK_CONFIG, username, password, initialDeviceName, result = fakeSession) - val result = useCase.execute(A_LOGIN_OR_REGISTER_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) + val result = useCase.execute(A_DIRECT_LOGIN_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) result shouldBeEqualTo Result.success(fakeSession) } @@ -66,14 +66,14 @@ class DirectLoginUseCaseTest { @Test fun `given wellknown fails with content, when logging in directly, then returns success with direct session result`() = runTest { fakeAuthenticationService.givenWellKnown( - A_LOGIN_OR_REGISTER_ACTION.username, + A_DIRECT_LOGIN_ACTION.matrixId, config = NO_HOMESERVER_CONFIG, result = A_WELLKNOWN_FAILED_WITH_CONTENT_RESULT ) - val (username, password, initialDeviceName) = A_LOGIN_OR_REGISTER_ACTION + val (username, password, initialDeviceName) = A_DIRECT_LOGIN_ACTION fakeAuthenticationService.givenDirectAuthentication(A_FALLBACK_CONFIG, username, password, initialDeviceName, result = fakeSession) - val result = useCase.execute(A_LOGIN_OR_REGISTER_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) + val result = useCase.execute(A_DIRECT_LOGIN_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) result shouldBeEqualTo Result.success(fakeSession) } @@ -81,14 +81,14 @@ class DirectLoginUseCaseTest { @Test fun `given wellknown fails without content, when logging in directly, then returns well known error`() = runTest { fakeAuthenticationService.givenWellKnown( - A_LOGIN_OR_REGISTER_ACTION.username, + A_DIRECT_LOGIN_ACTION.matrixId, config = NO_HOMESERVER_CONFIG, result = A_WELLKNOWN_FAILED_WITHOUT_CONTENT_RESULT ) - val (username, password, initialDeviceName) = A_LOGIN_OR_REGISTER_ACTION + val (username, password, initialDeviceName) = A_DIRECT_LOGIN_ACTION fakeAuthenticationService.givenDirectAuthentication(A_FALLBACK_CONFIG, username, password, initialDeviceName, result = fakeSession) - val result = useCase.execute(A_LOGIN_OR_REGISTER_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) + val result = useCase.execute(A_DIRECT_LOGIN_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) result should { this.isFailure } result should { this.exceptionOrNull() is Exception } @@ -97,20 +97,20 @@ class DirectLoginUseCaseTest { @Test fun `given wellknown throws, when logging in directly, then returns failure result with original cause`() = runTest { - fakeAuthenticationService.givenWellKnownThrows(A_LOGIN_OR_REGISTER_ACTION.username, config = NO_HOMESERVER_CONFIG, cause = AN_ERROR) + fakeAuthenticationService.givenWellKnownThrows(A_DIRECT_LOGIN_ACTION.matrixId, config = NO_HOMESERVER_CONFIG, cause = AN_ERROR) - val result = useCase.execute(A_LOGIN_OR_REGISTER_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) + val result = useCase.execute(A_DIRECT_LOGIN_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) result shouldBeEqualTo Result.failure(AN_ERROR) } @Test fun `given direct authentication throws, when logging in directly, then returns failure result with original cause`() = runTest { - fakeAuthenticationService.givenWellKnown(A_LOGIN_OR_REGISTER_ACTION.username, config = NO_HOMESERVER_CONFIG, result = A_WELLKNOWN_SUCCESS_RESULT) - val (username, password, initialDeviceName) = A_LOGIN_OR_REGISTER_ACTION + fakeAuthenticationService.givenWellKnown(A_DIRECT_LOGIN_ACTION.matrixId, config = NO_HOMESERVER_CONFIG, result = A_WELLKNOWN_SUCCESS_RESULT) + val (username, password, initialDeviceName) = A_DIRECT_LOGIN_ACTION fakeAuthenticationService.givenDirectAuthenticationThrows(A_FALLBACK_CONFIG, username, password, initialDeviceName, cause = AN_ERROR) - val result = useCase.execute(A_LOGIN_OR_REGISTER_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) + val result = useCase.execute(A_DIRECT_LOGIN_ACTION, homeServerConnectionConfig = NO_HOMESERVER_CONFIG) result shouldBeEqualTo Result.failure(AN_ERROR) } diff --git a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt index ac8a4c364e..e4e687536c 100644 --- a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt @@ -59,7 +59,7 @@ private val A_RESULT_IGNORED_REGISTER_ACTION = RegisterAction.SendAgainThreePid private val A_HOMESERVER_CAPABILITIES = aHomeServerCapabilities(canChangeDisplayName = true, canChangeAvatar = true) private val AN_IGNORED_FLOW_RESULT = FlowResult(missingStages = emptyList(), completedStages = emptyList()) private val ANY_CONTINUING_REGISTRATION_RESULT = RegistrationResult.NextStep(AN_IGNORED_FLOW_RESULT) -private val A_LOGIN_OR_REGISTER_ACTION = OnboardingAction.LoginOrRegister("@a-user:id.org", "a-password", "a-device-name") +private val A_DIRECT_LOGIN = OnboardingAction.AuthenticateAction.LoginDirect("@a-user:id.org", "a-password", "a-device-name") private const val A_HOMESERVER_URL = "https://edited-homeserver.org" private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance) private val SELECTED_HOMESERVER_STATE = SelectedHomeserverState(preferredLoginMode = LoginMode.Password) @@ -142,11 +142,11 @@ class OnboardingViewModelTest { @Test fun `given has sign in with matrix id sign mode, when handling login or register action, then logs in directly`() = runTest { viewModelWith(initialState.copy(signMode = SignMode.SignInWithMatrixId)) - fakeDirectLoginUseCase.givenSuccessResult(A_LOGIN_OR_REGISTER_ACTION, config = null, result = fakeSession) + fakeDirectLoginUseCase.givenSuccessResult(A_DIRECT_LOGIN, config = null, result = fakeSession) givenInitialisesSession(fakeSession) val test = viewModel.test() - viewModel.handle(A_LOGIN_OR_REGISTER_ACTION) + viewModel.handle(A_DIRECT_LOGIN) test .assertStatesChanges( @@ -161,11 +161,11 @@ class OnboardingViewModelTest { @Test fun `given has sign in with matrix id sign mode, when handling login or register action fails, then emits error`() = runTest { viewModelWith(initialState.copy(signMode = SignMode.SignInWithMatrixId)) - fakeDirectLoginUseCase.givenFailureResult(A_LOGIN_OR_REGISTER_ACTION, config = null, cause = AN_ERROR) + fakeDirectLoginUseCase.givenFailureResult(A_DIRECT_LOGIN, config = null, cause = AN_ERROR) givenInitialisesSession(fakeSession) val test = viewModel.test() - viewModel.handle(A_LOGIN_OR_REGISTER_ACTION) + viewModel.handle(A_DIRECT_LOGIN) test .assertStatesChanges( diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeDirectLoginUseCase.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeDirectLoginUseCase.kt index 8a5c6b1cee..289c0a6159 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeDirectLoginUseCase.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeDirectLoginUseCase.kt @@ -17,7 +17,7 @@ package im.vector.app.test.fakes import im.vector.app.features.onboarding.DirectLoginUseCase -import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingAction.AuthenticateAction import io.mockk.coEvery import io.mockk.mockk import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig @@ -25,11 +25,11 @@ import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig class FakeDirectLoginUseCase { val instance = mockk<DirectLoginUseCase>() - fun givenSuccessResult(action: OnboardingAction.LoginOrRegister, config: HomeServerConnectionConfig?, result: FakeSession) { + fun givenSuccessResult(action: AuthenticateAction.LoginDirect, config: HomeServerConnectionConfig?, result: FakeSession) { coEvery { instance.execute(action, config) } returns Result.success(result) } - fun givenFailureResult(action: OnboardingAction.LoginOrRegister, config: HomeServerConnectionConfig?, cause: Throwable) { + fun givenFailureResult(action: AuthenticateAction.LoginDirect, config: HomeServerConnectionConfig?, cause: Throwable) { coEvery { instance.execute(action, config) } returns Result.failure(cause) } }