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 a3dfdf66bc..d07ac46b85 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 @@ -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 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 8b7d0349af..96c77b5341 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 @@ -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) } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt index cb8113157f..fe2134618d 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt @@ -48,6 +48,9 @@ data class OnboardingViewState( val knownCustomHomeServersUrls: List = 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 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 2a20c53f99..0f7d15db0d 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 @@ -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() { @@ -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() diff --git a/vector/src/main/res/layout/fragment_ftue_combined_register.xml b/vector/src/main/res/layout/fragment_ftue_combined_register.xml index 503d731b31..0a7b5b57d0 100644 --- a/vector/src/main/res/layout/fragment_ftue_combined_register.xml +++ b/vector/src/main/res/layout/fragment_ftue_combined_register.xml @@ -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" /> @@ -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" /> Create your account - You can\'t change this later + + Others can discover you %s Must be 8 characters or more - Choose your server to store your data + Where your conversations will live Or Join millions for free on the largest public server Edit 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 17c6692fea..6868bf0dae 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 @@ -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)