checking user name is available at the point of user name entry during the registration flow

This commit is contained in:
Adam Brown 2022-07-13 14:32:12 +01:00
parent b8d4ff552f
commit 1062bfe039
7 changed files with 117 additions and 33 deletions

View File

@ -58,6 +58,7 @@ sealed interface OnboardingAction : VectorViewModelAction {
} }
sealed interface AuthenticateAction : OnboardingAction { sealed interface AuthenticateAction : OnboardingAction {
data class Register(val username: String, val password: String, val initialDeviceName: String) : AuthenticateAction data class Register(val username: String, val password: String, val initialDeviceName: String) : AuthenticateAction
data class RegisterWithMatrixId(val matrixId: String, val password: String, val initialDeviceName: String) : AuthenticateAction
data class Login(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 data class LoginDirect(val matrixId: String, val password: String, val initialDeviceName: String) : AuthenticateAction
} }
@ -74,6 +75,7 @@ sealed interface OnboardingAction : VectorViewModelAction {
object ResetSignMode : ResetAction object ResetSignMode : ResetAction
object ResetAuthenticationAttempt : ResetAction object ResetAuthenticationAttempt : ResetAction
object ResetResetPassword : ResetAction object ResetResetPassword : ResetAction
object ResetSelectedRegistrationUserName : ResetAction
// Homeserver history // Homeserver history
object ClearHomeServerHistory : OnboardingAction object ClearHomeServerHistory : OnboardingAction

View File

@ -28,6 +28,8 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.cancelCurrentOnSet import im.vector.app.core.extensions.cancelCurrentOnSet
import im.vector.app.core.extensions.configureAndStart import im.vector.app.core.extensions.configureAndStart
import im.vector.app.core.extensions.inferNoConnectivity import im.vector.app.core.extensions.inferNoConnectivity
import im.vector.app.core.extensions.isMatrixId
import im.vector.app.core.extensions.toReducedUrl
import im.vector.app.core.extensions.vectorStore import im.vector.app.core.extensions.vectorStore
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.BuildMeta import im.vector.app.core.resources.BuildMeta
@ -57,6 +59,7 @@ import org.matrix.android.sdk.api.auth.HomeServerHistoryService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
import org.matrix.android.sdk.api.auth.login.LoginWizard import org.matrix.android.sdk.api.auth.login.LoginWizard
import org.matrix.android.sdk.api.auth.registration.RegistrationAvailability
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.failure.isHomeserverUnavailable import org.matrix.android.sdk.api.failure.isHomeserverUnavailable
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
@ -168,19 +171,46 @@ class OnboardingViewModel @AssistedInject constructor(
} }
private fun handleUserNameEntered(action: OnboardingAction.UserNameEnteredAction) { private fun handleUserNameEntered(action: OnboardingAction.UserNameEnteredAction) {
when(action) { when (action) {
is OnboardingAction.UserNameEnteredAction.Login -> maybeUpdateHomeserver(action.userId) is OnboardingAction.UserNameEnteredAction.Login -> maybeUpdateHomeserver(action.userId)
is OnboardingAction.UserNameEnteredAction.Registration -> maybeUpdateHomeserver(action.userId) is OnboardingAction.UserNameEnteredAction.Registration -> maybeUpdateHomeserver(action.userId, continuation = { userName ->
checkUserNameAvailability(userName)
})
} }
} }
private fun maybeUpdateHomeserver(userNameOrMatrixId: String) { private fun maybeUpdateHomeserver(userNameOrMatrixId: String, continuation: suspend (String) -> Unit = {}) {
val isFullMatrixId = MatrixPatterns.isUserId(userNameOrMatrixId) val isFullMatrixId = MatrixPatterns.isUserId(userNameOrMatrixId)
if (isFullMatrixId) { if (isFullMatrixId) {
val domain = userNameOrMatrixId.getServerName().substringBeforeLast(":").ensureProtocol() val domain = userNameOrMatrixId.getServerName().substringBeforeLast(":").ensureProtocol()
handleHomeserverChange(OnboardingAction.HomeServerChange.EditHomeServer(domain)) handleHomeserverChange(OnboardingAction.HomeServerChange.EditHomeServer(domain), postAction = {
val userName = MatrixPatterns.extractUserNameFromId(userNameOrMatrixId) ?: throw IllegalStateException("unexpected non matrix id")
continuation(userName)
})
} else { } else {
// ignore the action currentJob = viewModelScope.launch { continuation(userNameOrMatrixId) }
}
}
private suspend fun checkUserNameAvailability(userName: String) {
when (val result = registrationWizard.registrationAvailable(userName)) {
RegistrationAvailability.Available -> {
setState {
copy(
registrationState = RegistrationState(
isUserNameAvailable = true,
selectedMatrixId = when {
userName.isMatrixId() -> userName
else -> "@$userName:${selectedHomeserver.userFacingUrl.toReducedUrl()}"
},
)
)
}
}
is RegistrationAvailability.NotAvailable -> {
_viewEvents.post(OnboardingViewEvents.Failure(result.failure))
}
} }
} }
@ -191,7 +221,12 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleAuthenticateAction(action: AuthenticateAction) { private fun handleAuthenticateAction(action: AuthenticateAction) {
when (action) { when (action) {
is AuthenticateAction.Register -> handleRegisterWith(action) is AuthenticateAction.Register -> handleRegisterWith(action.username, action.password, action.initialDeviceName)
is AuthenticateAction.RegisterWithMatrixId -> handleRegisterWith(
MatrixPatterns.extractUserNameFromId(action.matrixId) ?: throw IllegalStateException("unexpected non matrix id"),
action.password,
action.initialDeviceName
)
is AuthenticateAction.Login -> handleLogin(action) is AuthenticateAction.Login -> handleLogin(action)
is AuthenticateAction.LoginDirect -> handleDirectLogin(action, homeServerConnectionConfig = null) is AuthenticateAction.LoginDirect -> handleDirectLogin(action, homeServerConnectionConfig = null)
} }
@ -329,17 +364,17 @@ class OnboardingViewModel @AssistedInject constructor(
) )
} }
private fun handleRegisterWith(action: AuthenticateAction.Register) { private fun handleRegisterWith(userName: String, password: String, initialDeviceName: String) {
setState { setState {
val authDescription = AuthenticationDescription.Register(AuthenticationDescription.AuthenticationType.Password) val authDescription = AuthenticationDescription.Register(AuthenticationDescription.AuthenticationType.Password)
copy(selectedAuthenticationState = SelectedAuthenticationState(authDescription)) copy(selectedAuthenticationState = SelectedAuthenticationState(authDescription))
} }
reAuthHelper.data = action.password reAuthHelper.data = password
handleRegisterAction( handleRegisterAction(
RegisterAction.CreateAccount( RegisterAction.CreateAccount(
action.username, userName,
action.password, password,
action.initialDeviceName initialDeviceName
) )
) )
} }
@ -375,7 +410,12 @@ class OnboardingViewModel @AssistedInject constructor(
OnboardingAction.ResetAuthenticationAttempt -> { OnboardingAction.ResetAuthenticationAttempt -> {
viewModelScope.launch { viewModelScope.launch {
authenticationService.cancelPendingLoginOrRegistration() authenticationService.cancelPendingLoginOrRegistration()
setState { copy(isLoading = false) } setState {
copy(
isLoading = false,
registrationState = RegistrationState(),
)
}
} }
} }
OnboardingAction.ResetResetPassword -> { OnboardingAction.ResetResetPassword -> {
@ -387,6 +427,11 @@ class OnboardingViewModel @AssistedInject constructor(
} }
} }
OnboardingAction.ResetDeeplinkConfig -> loginConfig = null OnboardingAction.ResetDeeplinkConfig -> loginConfig = null
OnboardingAction.ResetSelectedRegistrationUserName -> {
setState {
copy(registrationState = RegistrationState())
}
}
} }
} }
@ -626,27 +671,31 @@ class OnboardingViewModel @AssistedInject constructor(
} }
} }
private fun handleHomeserverChange(action: OnboardingAction.HomeServerChange, serverTypeOverride: ServerType? = null) { private fun handleHomeserverChange(action: OnboardingAction.HomeServerChange, serverTypeOverride: ServerType? = null, postAction: suspend () -> Unit = {}) {
val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl) val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl)
if (homeServerConnectionConfig == null) { if (homeServerConnectionConfig == null) {
// This is invalid // This is invalid
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) _viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig")))
} else { } else {
startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride) startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride, postAction)
} }
} }
private fun startAuthenticationFlow( private fun startAuthenticationFlow(
trigger: OnboardingAction.HomeServerChange, trigger: OnboardingAction.HomeServerChange,
homeServerConnectionConfig: HomeServerConnectionConfig, homeServerConnectionConfig: HomeServerConnectionConfig,
serverTypeOverride: ServerType? serverTypeOverride: ServerType?,
postAction: suspend () -> Unit = {},
) { ) {
currentHomeServerConnectionConfig = homeServerConnectionConfig currentHomeServerConnectionConfig = homeServerConnectionConfig
currentJob = viewModelScope.launch { currentJob = viewModelScope.launch {
setState { copy(isLoading = true) } setState { copy(isLoading = true) }
runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold( runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold(
onSuccess = { onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride) }, onSuccess = {
onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride)
postAction()
},
onFailure = { onAuthenticationStartError(it, trigger) } onFailure = { onAuthenticationStartError(it, trigger) }
) )
setState { copy(isLoading = false) } setState { copy(isLoading = false) }

View File

@ -48,6 +48,9 @@ data class OnboardingViewState(
val knownCustomHomeServersUrls: List<String> = emptyList(), val knownCustomHomeServersUrls: List<String> = emptyList(),
val isForceLoginFallbackEnabled: Boolean = false, val isForceLoginFallbackEnabled: Boolean = false,
@PersistState
val registrationState: RegistrationState = RegistrationState(),
@PersistState @PersistState
val selectedHomeserver: SelectedHomeserverState = SelectedHomeserverState(), val selectedHomeserver: SelectedHomeserverState = SelectedHomeserverState(),
@ -95,3 +98,9 @@ data class ResetState(
data class SelectedAuthenticationState( data class SelectedAuthenticationState(
val description: AuthenticationDescription? = null, val description: AuthenticationDescription? = null,
) : Parcelable ) : Parcelable
@Parcelize
data class RegistrationState(
val isUserNameAvailable: Boolean = false,
val selectedMatrixId: String? = null,
) : Parcelable

View File

@ -33,6 +33,8 @@ import im.vector.app.core.extensions.editText
import im.vector.app.core.extensions.hasSurroundingSpaces import im.vector.app.core.extensions.hasSurroundingSpaces
import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.hidePassword import im.vector.app.core.extensions.hidePassword
import im.vector.app.core.extensions.isMatrixId
import im.vector.app.core.extensions.onTextChange
import im.vector.app.core.extensions.realignPercentagesToParent import im.vector.app.core.extensions.realignPercentagesToParent
import im.vector.app.core.extensions.setOnFocusLostListener import im.vector.app.core.extensions.setOnFocusLostListener
import im.vector.app.core.extensions.setOnImeDoneListener import im.vector.app.core.extensions.setOnImeDoneListener
@ -47,6 +49,7 @@ import im.vector.app.features.onboarding.OnboardingAction.AuthenticateAction
import im.vector.app.features.onboarding.OnboardingViewEvents import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.launchIn 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.auth.data.SsoIdentityProvider
import org.matrix.android.sdk.api.failure.isHomeserverUnavailable import org.matrix.android.sdk.api.failure.isHomeserverUnavailable
import org.matrix.android.sdk.api.failure.isInvalidPassword import org.matrix.android.sdk.api.failure.isInvalidPassword
@ -55,6 +58,7 @@ import org.matrix.android.sdk.api.failure.isLoginEmailUnknown
import org.matrix.android.sdk.api.failure.isRegistrationDisabled import org.matrix.android.sdk.api.failure.isRegistrationDisabled
import org.matrix.android.sdk.api.failure.isUsernameInUse import org.matrix.android.sdk.api.failure.isUsernameInUse
import org.matrix.android.sdk.api.failure.isWeakPassword import org.matrix.android.sdk.api.failure.isWeakPassword
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject import javax.inject.Inject
class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAuthFragment<FragmentFtueCombinedRegisterBinding>() { class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAuthFragment<FragmentFtueCombinedRegisterBinding>() {
@ -69,6 +73,12 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
views.createAccountRoot.realignPercentagesToParent() views.createAccountRoot.realignPercentagesToParent()
views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) } views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) }
views.createAccountPasswordInput.setOnImeDoneListener { submit() } views.createAccountPasswordInput.setOnImeDoneListener { submit() }
views.createAccountInput.onTextChange(viewLifecycleOwner) {
viewModel.handle(OnboardingAction.ResetSelectedRegistrationUserName)
views.createAccountEntryFooter.text = ""
}
views.createAccountInput.setOnFocusLostListener { views.createAccountInput.setOnFocusLostListener {
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(views.createAccountInput.content())) viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(views.createAccountInput.content()))
} }
@ -103,7 +113,12 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
} }
if (error == 0) { if (error == 0) {
viewModel.handle(AuthenticateAction.Register(login, password, getString(R.string.login_default_session_public_name))) val initialDeviceName = getString(R.string.login_default_session_public_name)
val registerAction = when {
login.isMatrixId() -> AuthenticateAction.RegisterWithMatrixId(login, password, initialDeviceName)
else -> AuthenticateAction.Register(login, password, initialDeviceName)
}
viewModel.handle(registerAction)
} }
} }
} }
@ -153,16 +168,25 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
override fun updateWithState(state: OnboardingViewState) { override fun updateWithState(state: OnboardingViewState) {
setupUi(state) setupUi(state)
setupAutoFill() setupAutoFill()
}
private fun setupUi(state: OnboardingViewState) {
views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl() views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl()
if (state.isLoading) { if (state.isLoading) {
// Ensure password is hidden // Ensure password is hidden
views.createAccountPasswordInput.editText().hidePassword() views.createAccountPasswordInput.editText().hidePassword()
} }
views.createAccountEntryFooter.text = when {
state.registrationState.isUserNameAvailable -> getString(
R.string.ftue_auth_create_account_username_entry_footer,
state.registrationState.selectedMatrixId
)
else -> ""
} }
private fun setupUi(state: OnboardingViewState) {
when (state.selectedHomeserver.preferredLoginMode) { when (state.selectedHomeserver.preferredLoginMode) {
is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders) is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders)
else -> hideSsoProviders() else -> hideSsoProviders()

View File

@ -34,8 +34,8 @@
android:layout_height="52dp" android:layout_height="52dp"
app:layout_constraintBottom_toTopOf="@id/createAccountHeaderIcon" app:layout_constraintBottom_toTopOf="@id/createAccountHeaderIcon"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0" app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintVertical_chainStyle="packed" /> app:layout_constraintVertical_bias="0" />
<ImageView <ImageView
android:id="@+id/createAccountHeaderIcon" android:id="@+id/createAccountHeaderIcon"
@ -62,7 +62,7 @@
android:gravity="center" android:gravity="center"
android:text="@string/ftue_auth_create_account_title" android:text="@string/ftue_auth_create_account_title"
android:textColor="?vctr_content_primary" android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/createAccountHeaderTitle" app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd" app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart" app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderIcon" /> app:layout_constraintTop_toBottomOf="@id/createAccountHeaderIcon" />
@ -160,18 +160,18 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:text="@string/ftue_auth_create_account_username_entry_footer"
app:layout_constraintBottom_toTopOf="@id/entrySpacing" app:layout_constraintBottom_toTopOf="@id/entrySpacing"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd" app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart" app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountInput" /> app:layout_constraintTop_toBottomOf="@id/createAccountInput"
tools:text="Others can discover you %s" />
<Space <Space
android:id="@+id/entrySpacing" android:id="@+id/entrySpacing"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/createAccountPasswordInput" app:layout_constraintBottom_toTopOf="@id/createAccountPasswordInput"
app:layout_constraintHeight_percent="0.03" app:layout_constraintHeight_percent="0.02"
app:layout_constraintTop_toBottomOf="@id/createAccountEntryFooter" /> app:layout_constraintTop_toBottomOf="@id/createAccountEntryFooter" />
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout

View File

@ -11,9 +11,10 @@
<!-- WIP --> <!-- WIP -->
<string name="ftue_auth_create_account_title">Create your account</string> <string name="ftue_auth_create_account_title">Create your account</string>
<string name="ftue_auth_create_account_username_entry_footer">You can\'t change this later</string> <!-- Note for translators, %s is the full matrix of the account being created, eg @hello:matrix.org -->
<string name="ftue_auth_create_account_username_entry_footer">Others can discover you %s</string>
<string name="ftue_auth_create_account_password_entry_footer">Must be 8 characters or more</string> <string name="ftue_auth_create_account_password_entry_footer">Must be 8 characters or more</string>
<string name="ftue_auth_create_account_choose_server_header">Choose your server to store your data</string> <string name="ftue_auth_create_account_choose_server_header">Where your conversations will live</string>
<string name="ftue_auth_create_account_sso_section_header">Or</string> <string name="ftue_auth_create_account_sso_section_header">Or</string>
<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_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_create_account_edit_server_selection">Edit</string>

View File

@ -290,13 +290,13 @@ class OnboardingViewModelTest {
} }
@Test @Test
fun `given a full matrix id, when maybe updating homeserver, then updates selected homeserver state and emits edited event`() = runTest { fun `given a full matrix id, when a login username is entered, then updates selected homeserver state and emits edited event`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp)) viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignIn))
givenCanSuccessfullyUpdateHomeserver(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE) givenCanSuccessfullyUpdateHomeserver(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE)
val test = viewModel.test() val test = viewModel.test()
val fullMatrixId = "@a-user:${A_HOMESERVER_URL.removePrefix("https://")}" val fullMatrixId = "@a-user:${A_HOMESERVER_URL.removePrefix("https://")}"
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(fullMatrixId)) viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(fullMatrixId))
test test
.assertStatesChanges( .assertStatesChanges(
@ -311,12 +311,11 @@ class OnboardingViewModelTest {
} }
@Test @Test
fun `given a username, when maybe updating homeserver, then does nothing`() = runTest { fun `given a username, when a login username is entered, then does nothing`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
val test = viewModel.test() val test = viewModel.test()
val onlyUsername = "a-username" val onlyUsername = "a-username"
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(onlyUsername)) viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(onlyUsername))
test test
.assertStates(initialState) .assertStates(initialState)