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 {
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 LoginDirect(val matrixId: String, val password: String, val initialDeviceName: String) : AuthenticateAction
}
@ -74,6 +75,7 @@ sealed interface OnboardingAction : VectorViewModelAction {
object ResetSignMode : ResetAction
object ResetAuthenticationAttempt : ResetAction
object ResetResetPassword : ResetAction
object ResetSelectedRegistrationUserName : ResetAction
// Homeserver history
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.configureAndStart
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.platform.VectorViewModel
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.SsoIdentityProvider
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.failure.isHomeserverUnavailable
import org.matrix.android.sdk.api.session.Session
@ -168,19 +171,46 @@ class OnboardingViewModel @AssistedInject constructor(
}
private fun handleUserNameEntered(action: OnboardingAction.UserNameEnteredAction) {
when(action) {
when (action) {
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)
if (isFullMatrixId) {
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 {
// 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) {
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.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 {
val authDescription = AuthenticationDescription.Register(AuthenticationDescription.AuthenticationType.Password)
copy(selectedAuthenticationState = SelectedAuthenticationState(authDescription))
}
reAuthHelper.data = action.password
reAuthHelper.data = password
handleRegisterAction(
RegisterAction.CreateAccount(
action.username,
action.password,
action.initialDeviceName
userName,
password,
initialDeviceName
)
)
}
@ -375,7 +410,12 @@ class OnboardingViewModel @AssistedInject constructor(
OnboardingAction.ResetAuthenticationAttempt -> {
viewModelScope.launch {
authenticationService.cancelPendingLoginOrRegistration()
setState { copy(isLoading = false) }
setState {
copy(
isLoading = false,
registrationState = RegistrationState(),
)
}
}
}
OnboardingAction.ResetResetPassword -> {
@ -387,6 +427,11 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
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)
if (homeServerConnectionConfig == null) {
// This is invalid
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig")))
} else {
startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride)
startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride, postAction)
}
}
private fun startAuthenticationFlow(
trigger: OnboardingAction.HomeServerChange,
homeServerConnectionConfig: HomeServerConnectionConfig,
serverTypeOverride: ServerType?
serverTypeOverride: ServerType?,
postAction: suspend () -> Unit = {},
) {
currentHomeServerConnectionConfig = homeServerConnectionConfig
currentJob = viewModelScope.launch {
setState { copy(isLoading = true) }
runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold(
onSuccess = { onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride) },
onSuccess = {
onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride)
postAction()
},
onFailure = { onAuthenticationStartError(it, trigger) }
)
setState { copy(isLoading = false) }

View file

@ -48,6 +48,9 @@ data class OnboardingViewState(
val knownCustomHomeServersUrls: List<String> = emptyList(),
val isForceLoginFallbackEnabled: Boolean = false,
@PersistState
val registrationState: RegistrationState = RegistrationState(),
@PersistState
val selectedHomeserver: SelectedHomeserverState = SelectedHomeserverState(),
@ -95,3 +98,9 @@ data class ResetState(
data class SelectedAuthenticationState(
val description: AuthenticationDescription? = null,
) : 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.hideKeyboard
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.setOnFocusLostListener
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.OnboardingViewState
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.isHomeserverUnavailable
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.isUsernameInUse
import org.matrix.android.sdk.api.failure.isWeakPassword
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject
class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAuthFragment<FragmentFtueCombinedRegisterBinding>() {
@ -69,6 +73,12 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
views.createAccountRoot.realignPercentagesToParent()
views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) }
views.createAccountPasswordInput.setOnImeDoneListener { submit() }
views.createAccountInput.onTextChange(viewLifecycleOwner) {
viewModel.handle(OnboardingAction.ResetSelectedRegistrationUserName)
views.createAccountEntryFooter.text = ""
}
views.createAccountInput.setOnFocusLostListener {
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(views.createAccountInput.content()))
}
@ -103,7 +113,12 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
}
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) {
setupUi(state)
setupAutoFill()
}
private fun setupUi(state: OnboardingViewState) {
views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl()
if (state.isLoading) {
// Ensure password is hidden
views.createAccountPasswordInput.editText().hidePassword()
}
}
private fun setupUi(state: OnboardingViewState) {
views.createAccountEntryFooter.text = when {
state.registrationState.isUserNameAvailable -> getString(
R.string.ftue_auth_create_account_username_entry_footer,
state.registrationState.selectedMatrixId
)
else -> ""
}
when (state.selectedHomeserver.preferredLoginMode) {
is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders)
else -> hideSsoProviders()

View file

@ -34,8 +34,8 @@
android:layout_height="52dp"
app:layout_constraintBottom_toTopOf="@id/createAccountHeaderIcon"
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
android:id="@+id/createAccountHeaderIcon"
@ -62,7 +62,7 @@
android:gravity="center"
android:text="@string/ftue_auth_create_account_title"
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_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderIcon" />
@ -160,18 +160,18 @@
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" />
app:layout_constraintTop_toBottomOf="@id/createAccountInput"
tools:text="Others can discover you %s" />
<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_constraintHeight_percent="0.02"
app:layout_constraintTop_toBottomOf="@id/createAccountEntryFooter" />
<com.google.android.material.textfield.TextInputLayout

View file

@ -11,9 +11,10 @@
<!-- WIP -->
<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_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_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>

View file

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