adding dedicated login action

This commit is contained in:
Adam Brown 2022-05-05 14:01:18 +01:00
parent 34e97112a4
commit 4b6f74364d
6 changed files with 493 additions and 13 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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<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.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<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 ->
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)
}

View File

@ -233,7 +233,7 @@ class FtueAuthVariant(
}
private fun onStartCombinedLogin() {
addRegistrationStageFragmentToBackstack(FtueAuthCombinedRegisterFragment::class.java)
addRegistrationStageFragmentToBackstack(FtueAuthCombinedLoginFragment::class.java)
}
private fun onRegistrationFlow(viewEvents: OnboardingViewEvents.RegistrationFlowResult) {

View File

@ -0,0 +1,275 @@
<?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/createAccountRoot"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/createAccountGutterStart"
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/createAccountGutterEnd"
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/createAccountHeaderTitle"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/createAccountHeaderTitle"
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/createAccountHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/headerSpacing" />
<TextView
android:id="@+id/createAccountHeaderSubtitle"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_auth_create_account_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderTitle" />
<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/createAccountHeaderSubtitle" />
<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/createAccountGutterStart"
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/createAccountGutterStart"
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/createAccountGutterStart"
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/createAccountGutterEnd"
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/createAccountInput"
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/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toTopOf="@id/serverSelectionSpacing" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/createAccountInput"
style="@style/Widget.Vector.TextInputLayout.Username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/username"
app:layout_constraintBottom_toTopOf="@id/createAccountEntryFooter"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
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/createAccountPasswordInput" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/createAccountEntryFooter"
style="@style/Widget.Vector.TextView.Micro"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/ftue_auth_create_account_username_entry_footer"
app:layout_constraintBottom_toTopOf="@id/entrySpacing"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountInput" />
<Space
android:id="@+id/entrySpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/createAccountPasswordInput"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/createAccountEntryFooter" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/createAccountPasswordInput"
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/createAccountPasswordEntryFooter"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
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>
<TextView
android:id="@+id/createAccountPasswordEntryFooter"
style="@style/Widget.Vector.TextView.Micro"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/ftue_auth_create_account_password_entry_footer"
app:layout_constraintBottom_toTopOf="@id/actionSpacing"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountPasswordInput" />
<Space
android:id="@+id/actionSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/createAccountSubmit"
app:layout_constraintHeight_percent="0.02"
app:layout_constraintTop_toBottomOf="@id/createAccountPasswordEntryFooter" />
<Button
android:id="@+id/createAccountSubmit"
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/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
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/createAccountSubmit"
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/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountSubmit" />
<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/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/ssoButtonsHeader"
tools:signMode="signup" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -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>