Merge pull request #6545 from vector-im/feature/adm/ftue-combined-register-copy-review

FTUE - Combined register copy review
This commit is contained in:
Benoit Marty 2022-07-19 14:44:29 +02:00 committed by GitHub
commit 70c8703b2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 310 additions and 133 deletions

1
changelog.d/6546.feature Normal file
View File

@ -0,0 +1 @@
Updates FTUE registration to include username availability check and update copy

View File

@ -156,6 +156,20 @@ object MatrixPatterns {
return matrixId?.substringAfter(":", missingDelimiterValue = "")?.takeIf { it.isNotEmpty() } return matrixId?.substringAfter(":", missingDelimiterValue = "")?.takeIf { it.isNotEmpty() }
} }
/**
* Extract user name from a matrix id.
*
* @param matrixId
* @return null if the input is not a valid matrixId
*/
fun extractUserNameFromId(matrixId: String): String? {
return if (isUserId(matrixId)) {
matrixId.removePrefix("@").substringBefore(":", missingDelimiterValue = "")
} else {
null
}
}
/** /**
* Orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7E (~), * Orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7E (~),
* or consist of more than 50 characters, are forbidden and the field should be ignored if received. * or consist of more than 50 characters, are forbidden and the field should be ignored if received.

View File

@ -35,6 +35,23 @@ class MatrixPatternsTest {
MatrixPatterns.isUserId(input) shouldBeEqualTo expected MatrixPatterns.isUserId(input) shouldBeEqualTo expected
} }
} }
@Test
fun `given matrix id cases, when extracting userName, then returns expected`() {
val cases = listOf(
MatrixIdCase("foobar", userName = null),
MatrixIdCase("@foobar", userName = null),
MatrixIdCase("foobar@matrix.org", userName = null),
MatrixIdCase("@foobar: matrix.org", userName = null),
MatrixIdCase("foobar:matrix.org", userName = null),
MatrixIdCase("@foobar:matrix.org", userName = "foobar"),
)
cases.forEach { (input, expected) ->
MatrixPatterns.extractUserNameFromId(input) shouldBeEqualTo expected
}
}
} }
private data class UserIdCase(val input: String, val isUserId: Boolean) private data class UserIdCase(val input: String, val isUserId: Boolean)
private data class MatrixIdCase(val input: String, val userName: String?)

View File

@ -19,6 +19,7 @@ package im.vector.app.core.extensions
import android.util.Patterns import android.util.Patterns
import com.google.i18n.phonenumbers.NumberParseException import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.extensions.ensurePrefix import org.matrix.android.sdk.api.extensions.ensurePrefix
fun Boolean.toOnOff() = if (this) "ON" else "OFF" fun Boolean.toOnOff() = if (this) "ON" else "OFF"
@ -30,6 +31,8 @@ inline fun <T> T.ooi(block: (T) -> Unit): T = also(block)
*/ */
fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches() fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches()
fun CharSequence.isMatrixId() = MatrixPatterns.isUserId(this.toString())
/** /**
* Return empty CharSequence if the CharSequence is null. * Return empty CharSequence if the CharSequence is null.
*/ */

View File

@ -44,8 +44,15 @@ fun TextInputLayout.content() = editText().text.toString()
fun TextInputLayout.hasContent() = !editText().text.isNullOrEmpty() fun TextInputLayout.hasContent() = !editText().text.isNullOrEmpty()
fun TextInputLayout.clearErrorOnChange(lifecycleOwner: LifecycleOwner) { fun TextInputLayout.clearErrorOnChange(lifecycleOwner: LifecycleOwner) {
onTextChange(lifecycleOwner) {
error = null
isErrorEnabled = false
}
}
fun TextInputLayout.onTextChange(lifecycleOwner: LifecycleOwner, action: (CharSequence) -> Unit) {
editText().textChanges() editText().textChanges()
.onEach { error = null } .onEach(action)
.launchIn(lifecycleOwner.lifecycleScope) .launchIn(lifecycleOwner.lifecycleScope)
} }

View File

@ -52,9 +52,13 @@ sealed interface OnboardingAction : VectorViewModelAction {
object ResendResetPassword : OnboardingAction object ResendResetPassword : OnboardingAction
object ResetPasswordMailConfirmed : OnboardingAction object ResetPasswordMailConfirmed : OnboardingAction
data class MaybeUpdateHomeserverFromMatrixId(val userId: String) : OnboardingAction sealed interface UserNameEnteredAction : OnboardingAction {
data class Registration(val userId: String) : UserNameEnteredAction
data class Login(val userId: String) : UserNameEnteredAction
}
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
} }
@ -71,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
@ -144,7 +147,7 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action) is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action)
is OnboardingAction.InitWith -> handleInitWith(action) is OnboardingAction.InitWith -> handleInitWith(action)
is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action) } is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action) }
is OnboardingAction.MaybeUpdateHomeserverFromMatrixId -> handleMaybeUpdateHomeserver(action) is OnboardingAction.UserNameEnteredAction -> handleUserNameEntered(action)
is AuthenticateAction -> withAction(action) { handleAuthenticateAction(action) } is AuthenticateAction -> withAction(action) { handleAuthenticateAction(action) }
is OnboardingAction.LoginWithToken -> handleLoginWithToken(action) is OnboardingAction.LoginWithToken -> handleLoginWithToken(action)
is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action) is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action)
@ -167,13 +170,47 @@ class OnboardingViewModel @AssistedInject constructor(
} }
} }
private fun handleMaybeUpdateHomeserver(action: OnboardingAction.MaybeUpdateHomeserverFromMatrixId) { private fun handleUserNameEntered(action: OnboardingAction.UserNameEnteredAction) {
val isFullMatrixId = MatrixPatterns.isUserId(action.userId) when (action) {
is OnboardingAction.UserNameEnteredAction.Login -> maybeUpdateHomeserver(action.userId)
is OnboardingAction.UserNameEnteredAction.Registration -> maybeUpdateHomeserver(action.userId, continuation = { userName ->
checkUserNameAvailability(userName)
})
}
}
private fun maybeUpdateHomeserver(userNameOrMatrixId: String, continuation: suspend (String) -> Unit = {}) {
val isFullMatrixId = MatrixPatterns.isUserId(userNameOrMatrixId)
if (isFullMatrixId) { if (isFullMatrixId) {
val domain = action.userId.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))
}
} }
} }
@ -184,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)
} }
@ -322,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
) )
) )
} }
@ -368,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 -> {
@ -380,6 +427,11 @@ class OnboardingViewModel @AssistedInject constructor(
} }
} }
OnboardingAction.ResetDeeplinkConfig -> loginConfig = null OnboardingAction.ResetDeeplinkConfig -> loginConfig = null
OnboardingAction.ResetSelectedRegistrationUserName -> {
setState {
copy(registrationState = RegistrationState())
}
}
} }
} }
@ -619,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(),
@ -66,7 +69,6 @@ enum class OnboardingFlow {
@Parcelize @Parcelize
data class SelectedHomeserverState( data class SelectedHomeserverState(
val description: String? = null,
val userFacingUrl: String? = null, val userFacingUrl: String? = null,
val upstreamUrl: String? = null, val upstreamUrl: String? = null,
val preferredLoginMode: LoginMode = LoginMode.Unknown, val preferredLoginMode: LoginMode = LoginMode.Unknown,
@ -96,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

@ -16,10 +16,7 @@
package im.vector.app.features.onboarding package im.vector.app.features.onboarding
import im.vector.app.R
import im.vector.app.core.extensions.containsAllItems import im.vector.app.core.extensions.containsAllItems
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.ensureTrailingSlash
import im.vector.app.features.login.LoginMode import im.vector.app.features.login.LoginMode
import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
@ -29,7 +26,6 @@ import javax.inject.Inject
class StartAuthenticationFlowUseCase @Inject constructor( class StartAuthenticationFlowUseCase @Inject constructor(
private val authenticationService: AuthenticationService, private val authenticationService: AuthenticationService,
private val stringProvider: StringProvider
) { ) {
suspend fun execute(config: HomeServerConnectionConfig): StartAuthenticationResult { suspend fun execute(config: HomeServerConnectionConfig): StartAuthenticationResult {
@ -46,10 +42,6 @@ class StartAuthenticationFlowUseCase @Inject constructor(
config: HomeServerConnectionConfig, config: HomeServerConnectionConfig,
preferredLoginMode: LoginMode preferredLoginMode: LoginMode
) = SelectedHomeserverState( ) = SelectedHomeserverState(
description = when (config.homeServerUri.toString()) {
matrixOrgUrl() -> stringProvider.getString(R.string.ftue_auth_create_account_matrix_dot_org_server_description)
else -> null
},
userFacingUrl = config.homeServerUri.toString(), userFacingUrl = config.homeServerUri.toString(),
upstreamUrl = authFlow.homeServerUrl, upstreamUrl = authFlow.homeServerUrl,
preferredLoginMode = preferredLoginMode, preferredLoginMode = preferredLoginMode,
@ -57,8 +49,6 @@ class StartAuthenticationFlowUseCase @Inject constructor(
isLogoutDevicesSupported = authFlow.isLogoutDevicesSupported isLogoutDevicesSupported = authFlow.isLogoutDevicesSupported
) )
private fun matrixOrgUrl() = stringProvider.getString(R.string.matrix_org_server_url).ensureTrailingSlash()
private fun LoginFlowResult.findPreferredLoginMode() = when { private fun LoginFlowResult.findPreferredLoginMode() = when {
supportedLoginTypes.containsAllItems(LoginFlowTypes.SSO, LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(ssoIdentityProviders) supportedLoginTypes.containsAllItems(LoginFlowTypes.SSO, LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(ssoIdentityProviders)
supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(ssoIdentityProviders) supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(ssoIdentityProviders)

View File

@ -25,6 +25,7 @@ import androidx.autofill.HintConstants
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.clearErrorOnChange
import im.vector.app.core.extensions.content import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.editText import im.vector.app.core.extensions.editText
import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.hideKeyboard
@ -41,8 +42,10 @@ import im.vector.app.features.login.render
import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingAction
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.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject import javax.inject.Inject
class FtueAuthCombinedLoginFragment @Inject constructor( class FtueAuthCombinedLoginFragment @Inject constructor(
@ -60,14 +63,18 @@ class FtueAuthCombinedLoginFragment @Inject constructor(
views.loginRoot.realignPercentagesToParent() views.loginRoot.realignPercentagesToParent()
views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) } views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) }
views.loginPasswordInput.setOnImeDoneListener { submit() } views.loginPasswordInput.setOnImeDoneListener { submit() }
views.loginInput.setOnFocusLostListener { viewModel.handle(OnboardingAction.MaybeUpdateHomeserverFromMatrixId(views.loginInput.content())) } views.loginInput.setOnFocusLostListener { viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(views.loginInput.content())) }
views.loginForgotPassword.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnForgetPasswordClicked)) } views.loginForgotPassword.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnForgetPasswordClicked)) }
} }
private fun setupSubmitButton() { private fun setupSubmitButton() {
views.loginSubmit.setOnClickListener { submit() } views.loginSubmit.setOnClickListener { submit() }
observeContentChangesAndResetErrors(views.loginInput, views.loginPasswordInput, views.loginSubmit) views.loginInput.clearErrorOnChange(viewLifecycleOwner)
.launchIn(viewLifecycleOwner.lifecycleScope) views.loginPasswordInput.clearErrorOnChange(viewLifecycleOwner)
combine(views.loginInput.editText().textChanges(), views.loginPasswordInput.editText().textChanges()) { account, password ->
views.loginSubmit.isEnabled = account.isNotEmpty() && password.isNotEmpty()
}.launchIn(viewLifecycleOwner.lifecycleScope)
} }
private fun submit() { private fun submit() {
@ -105,7 +112,6 @@ class FtueAuthCombinedLoginFragment @Inject constructor(
setupAutoFill() setupAutoFill()
views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl() views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl()
views.selectedServerDescription.text = state.selectedHomeserver.description
if (state.isLoading) { if (state.isLoading) {
// Ensure password is hidden // Ensure password is hidden

View File

@ -28,11 +28,14 @@ import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.clearErrorOnChange
import im.vector.app.core.extensions.content import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.editText 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
@ -46,6 +49,7 @@ import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingAction.AuthenticateAction 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.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
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
@ -55,8 +59,11 @@ 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
private const val MINIMUM_PASSWORD_LENGTH = 8
class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAuthFragment<FragmentFtueCombinedRegisterBinding>() { class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAuthFragment<FragmentFtueCombinedRegisterBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueCombinedRegisterBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueCombinedRegisterBinding {
@ -69,15 +76,27 @@ 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.MaybeUpdateHomeserverFromMatrixId(views.createAccountInput.content())) viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(views.createAccountInput.content()))
} }
} }
private fun setupSubmitButton() { private fun setupSubmitButton() {
views.createAccountSubmit.setOnClickListener { submit() } views.createAccountSubmit.setOnClickListener { submit() }
observeContentChangesAndResetErrors(views.createAccountInput, views.createAccountPasswordInput, views.createAccountSubmit) views.createAccountInput.clearErrorOnChange(viewLifecycleOwner)
.launchIn(viewLifecycleOwner.lifecycleScope) views.createAccountPasswordInput.clearErrorOnChange(viewLifecycleOwner)
combine(views.createAccountInput.editText().textChanges(), views.createAccountPasswordInput.editText().textChanges()) { account, password ->
val accountIsValid = account.isNotEmpty()
val passwordIsValid = password.length >= MINIMUM_PASSWORD_LENGTH
views.createAccountSubmit.isEnabled = accountIsValid && passwordIsValid
}.launchIn(viewLifecycleOwner.lifecycleScope)
} }
private fun submit() { private fun submit() {
@ -103,7 +122,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,17 +177,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()
views.selectedServerDescription.text = state.selectedHomeserver.description
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

@ -16,16 +16,10 @@
package im.vector.app.features.onboarding.ftueauth package im.vector.app.features.onboarding.ftueauth
import android.widget.Button
import com.google.android.material.textfield.TextInputLayout
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.hasContentFlow
import im.vector.app.features.login.SignMode import im.vector.app.features.login.SignMode
import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.themes.ThemeProvider import im.vector.app.features.themes.ThemeProvider
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 { fun SignMode.toAuthenticateAction(login: String, password: String, initialDeviceName: String): OnboardingAction.AuthenticateAction {
return when (this) { return when (this) {
@ -36,22 +30,6 @@ fun SignMode.toAuthenticateAction(login: String, password: String, initialDevice
} }
} }
/**
* 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
}
}
fun ThemeProvider.ftueBreakerBackground() = when (isLightTheme()) { fun ThemeProvider.ftueBreakerBackground() = when (isLightTheme()) {
true -> R.drawable.bg_gradient_ftue_breaker true -> R.drawable.bg_gradient_ftue_breaker
false -> R.drawable.bg_color_background false -> R.drawable.bg_color_background

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,24 +62,10 @@
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/createAccountHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderIcon" />
<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_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/createAccountHeaderTitle" /> app:layout_constraintTop_toBottomOf="@id/createAccountHeaderIcon" />
<Space <Space
android:id="@+id/titleContentSpacing" android:id="@+id/titleContentSpacing"
@ -87,7 +73,7 @@
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/chooseYourServerHeader" app:layout_constraintBottom_toTopOf="@id/chooseYourServerHeader"
app:layout_constraintHeight_percent="0.03" app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderSubtitle" /> app:layout_constraintTop_toBottomOf="@id/createAccountHeaderTitle" />
<TextView <TextView
android:id="@+id/chooseYourServerHeader" android:id="@+id/chooseYourServerHeader"
@ -110,22 +96,11 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:textColor="?vctr_content_primary" 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_constraintBottom_toTopOf="@id/serverSelectionSpacing"
app:layout_constraintEnd_toStartOf="@id/editServerButton" app:layout_constraintEnd_toStartOf="@id/editServerButton"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart" app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/selectedServerName" /> app:layout_constraintTop_toBottomOf="@id/chooseYourServerHeader"
tools:text="matrix.org" />
<Button <Button
android:id="@+id/editServerButton" android:id="@+id/editServerButton"
@ -137,7 +112,7 @@
android:paddingEnd="12dp" android:paddingEnd="12dp"
android:text="@string/ftue_auth_create_account_edit_server_selection" android:text="@string/ftue_auth_create_account_edit_server_selection"
android:textAllCaps="true" android:textAllCaps="true"
app:layout_constraintBottom_toBottomOf="@id/selectedServerDescription" app:layout_constraintBottom_toBottomOf="@id/selectedServerName"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd" app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintTop_toTopOf="@id/chooseYourServerHeader" /> app:layout_constraintTop_toTopOf="@id/chooseYourServerHeader" />
@ -147,7 +122,7 @@
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/createAccountInput" app:layout_constraintBottom_toTopOf="@id/createAccountInput"
app:layout_constraintHeight_percent="0.05" app:layout_constraintHeight_percent="0.05"
app:layout_constraintTop_toBottomOf="@id/selectedServerDescription" /> app:layout_constraintTop_toBottomOf="@id/selectedServerName" />
<View <View
android:layout_width="0dp" android:layout_width="0dp"
@ -185,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,12 +11,11 @@
<!-- 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_subtitle">We\'ll need some info to get you set up.</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">You can\'t change this later</string> <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_edit_server_selection">Edit</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_welcome_back_title">Welcome back!</string>

View File

@ -33,6 +33,7 @@ import im.vector.app.test.fakes.FakeHomeServerConnectionConfigFactory
import im.vector.app.test.fakes.FakeHomeServerHistoryService import im.vector.app.test.fakes.FakeHomeServerHistoryService
import im.vector.app.test.fakes.FakeLoginWizard import im.vector.app.test.fakes.FakeLoginWizard
import im.vector.app.test.fakes.FakeRegistrationActionHandler import im.vector.app.test.fakes.FakeRegistrationActionHandler
import im.vector.app.test.fakes.FakeRegistrationWizard
import im.vector.app.test.fakes.FakeSession import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeStartAuthenticationFlowUseCase import im.vector.app.test.fakes.FakeStartAuthenticationFlowUseCase
import im.vector.app.test.fakes.FakeStringProvider import im.vector.app.test.fakes.FakeStringProvider
@ -41,6 +42,7 @@ import im.vector.app.test.fakes.FakeUriFilenameResolver
import im.vector.app.test.fakes.FakeVectorFeatures import im.vector.app.test.fakes.FakeVectorFeatures
import im.vector.app.test.fakes.FakeVectorOverrides import im.vector.app.test.fakes.FakeVectorOverrides
import im.vector.app.test.fakes.toTestString import im.vector.app.test.fakes.toTestString
import im.vector.app.test.fixtures.a401ServerError
import im.vector.app.test.fixtures.aBuildMeta import im.vector.app.test.fixtures.aBuildMeta
import im.vector.app.test.fixtures.aHomeServerCapabilities import im.vector.app.test.fixtures.aHomeServerCapabilities
import im.vector.app.test.test import im.vector.app.test.test
@ -50,11 +52,13 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
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.registration.Stage import org.matrix.android.sdk.api.auth.registration.Stage
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
private const val A_DISPLAY_NAME = "a display name" private const val A_DISPLAY_NAME = "a display name"
private const val A_PICTURE_FILENAME = "a-picture.png" private const val A_PICTURE_FILENAME = "a-picture.png"
private val A_SERVER_ERROR = a401ServerError()
private val AN_ERROR = RuntimeException("an error!") private val AN_ERROR = RuntimeException("an error!")
private val A_LOADABLE_REGISTER_ACTION = RegisterAction.StartRegistration private val A_LOADABLE_REGISTER_ACTION = RegisterAction.StartRegistration
private val A_NON_LOADABLE_REGISTER_ACTION = RegisterAction.CheckIfEmailHasBeenValidated(delayMillis = -1L) private val A_NON_LOADABLE_REGISTER_ACTION = RegisterAction.CheckIfEmailHasBeenValidated(delayMillis = -1L)
@ -64,7 +68,7 @@ private val ANY_CONTINUING_REGISTRATION_RESULT = RegistrationActionHandler.Resul
private val A_DIRECT_LOGIN = OnboardingAction.AuthenticateAction.LoginDirect("@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 const val A_HOMESERVER_URL = "https://edited-homeserver.org"
private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance) private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance)
private val SELECTED_HOMESERVER_STATE = SelectedHomeserverState(preferredLoginMode = LoginMode.Password) private val SELECTED_HOMESERVER_STATE = SelectedHomeserverState(preferredLoginMode = LoginMode.Password, userFacingUrl = A_HOMESERVER_URL)
private val SELECTED_HOMESERVER_STATE_SUPPORTED_LOGOUT_DEVICES = SelectedHomeserverState(isLogoutDevicesSupported = true) private val SELECTED_HOMESERVER_STATE_SUPPORTED_LOGOUT_DEVICES = SelectedHomeserverState(isLogoutDevicesSupported = true)
private const val AN_EMAIL = "hello@example.com" private const val AN_EMAIL = "hello@example.com"
private const val A_PASSWORD = "a-password" private const val A_PASSWORD = "a-password"
@ -290,13 +294,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.MaybeUpdateHomeserverFromMatrixId(fullMatrixId)) viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(fullMatrixId))
test test
.assertStatesChanges( .assertStatesChanges(
@ -311,12 +315,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.MaybeUpdateHomeserverFromMatrixId(onlyUsername)) viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(onlyUsername))
test test
.assertStates(initialState) .assertStates(initialState)
@ -324,6 +327,93 @@ class OnboardingViewModelTest {
.finish() .finish()
} }
@Test
fun `given available username, when a register username is entered, then emits available registration state`() = runTest {
viewModelWith(initialRegistrationState(A_HOMESERVER_URL))
val onlyUsername = "a-username"
givenUserNameIsAvailable(onlyUsername)
val test = viewModel.test()
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(onlyUsername))
test
.assertStatesChanges(
initialState,
{ copy(registrationState = availableRegistrationState(onlyUsername, A_HOMESERVER_URL)) }
)
.assertNoEvents()
.finish()
}
@Test
fun `given unavailable username, when a register username is entered, then emits availability error`() = runTest {
viewModelWith(initialRegistrationState(A_HOMESERVER_URL))
val onlyUsername = "a-username"
givenUserNameIsUnavailable(onlyUsername, A_SERVER_ERROR)
val test = viewModel.test()
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(onlyUsername))
test
.assertState(initialState)
.assertEvents(OnboardingViewEvents.Failure(A_SERVER_ERROR))
.finish()
}
@Test
fun `given available full matrix id, when a register username is entered, then changes homeserver and emits available registration state`() = runTest {
viewModelWith(initialRegistrationState("ignored-url"))
givenCanSuccessfullyUpdateHomeserver(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE)
val userName = "a-user"
val fullMatrixId = "@$userName:${A_HOMESERVER_URL.removePrefix("https://")}"
givenUserNameIsAvailable(userName)
val test = viewModel.test()
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(fullMatrixId))
test
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(selectedHomeserver = SELECTED_HOMESERVER_STATE) },
{ copy(registrationState = availableRegistrationState(userName, A_HOMESERVER_URL)) },
{ copy(isLoading = false) },
)
.assertEvents(OnboardingViewEvents.OnHomeserverEdited)
.finish()
}
@Test
fun `given unavailable full matrix id, when a register username is entered, then emits availability error`() = runTest {
viewModelWith(initialRegistrationState("ignored-url"))
givenCanSuccessfullyUpdateHomeserver(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE)
val userName = "a-user"
val fullMatrixId = "@$userName:${A_HOMESERVER_URL.removePrefix("https://")}"
givenUserNameIsUnavailable(userName, A_SERVER_ERROR)
val test = viewModel.test()
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(fullMatrixId))
test
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(selectedHomeserver = SELECTED_HOMESERVER_STATE) },
{ copy(isLoading = false) },
)
.assertEvents(OnboardingViewEvents.OnHomeserverEdited, OnboardingViewEvents.Failure(A_SERVER_ERROR))
.finish()
}
private fun availableRegistrationState(userName: String, homeServerUrl: String) = RegistrationState(
isUserNameAvailable = true,
selectedMatrixId = "@$userName:${homeServerUrl.removePrefix("https://")}"
)
private fun initialRegistrationState(homeServerUrl: String) = initialState.copy(
onboardingFlow = OnboardingFlow.SignUp, selectedHomeserver = SelectedHomeserverState(userFacingUrl = homeServerUrl)
)
@Test @Test
fun `given in the sign up flow, when editing homeserver errors, then does not update the selected homeserver state and emits error`() = runTest { fun `given in the sign up flow, when editing homeserver errors, then does not update the selected homeserver state and emits error`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp)) viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
@ -640,6 +730,14 @@ class OnboardingViewModelTest {
givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationActionHandler.Result.Error(error)) givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationActionHandler.Result.Error(error))
fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString()) fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString())
} }
private fun givenUserNameIsAvailable(userName: String) {
fakeAuthenticationService.givenRegistrationWizard(FakeRegistrationWizard().also { it.givenUserNameIsAvailable(userName) })
}
private fun givenUserNameIsUnavailable(userName: String, failure: Failure.ServerError) {
fakeAuthenticationService.givenRegistrationWizard(FakeRegistrationWizard().also { it.givenUserNameIsUnavailable(userName, failure) })
}
} }
private fun HomeServerCapabilities.toPersonalisationState() = PersonalizationState( private fun HomeServerCapabilities.toPersonalisationState() = PersonalizationState(

View File

@ -16,13 +16,10 @@
package im.vector.app.features.onboarding package im.vector.app.features.onboarding
import im.vector.app.R
import im.vector.app.features.login.LoginMode import im.vector.app.features.login.LoginMode
import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult
import im.vector.app.test.fakes.FakeAuthenticationService import im.vector.app.test.fakes.FakeAuthenticationService
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeUri import im.vector.app.test.fakes.FakeUri
import im.vector.app.test.fakes.toTestString
import io.mockk.coVerifyOrder import io.mockk.coVerifyOrder
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
@ -33,7 +30,6 @@ import org.matrix.android.sdk.api.auth.data.LoginFlowResult
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
private const val MATRIX_ORG_URL = "https://any-value.org/"
private const val A_DECLARED_HOMESERVER_URL = "https://foo.bar" private const val A_DECLARED_HOMESERVER_URL = "https://foo.bar"
private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(homeServerUri = FakeUri().instance) private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(homeServerUri = FakeUri().instance)
private val SSO_IDENTITY_PROVIDERS = emptyList<SsoIdentityProvider>() private val SSO_IDENTITY_PROVIDERS = emptyList<SsoIdentityProvider>()
@ -41,9 +37,8 @@ private val SSO_IDENTITY_PROVIDERS = emptyList<SsoIdentityProvider>()
class StartAuthenticationFlowUseCaseTest { class StartAuthenticationFlowUseCaseTest {
private val fakeAuthenticationService = FakeAuthenticationService() private val fakeAuthenticationService = FakeAuthenticationService()
private val fakeStringProvider = FakeStringProvider()
private val useCase = StartAuthenticationFlowUseCase(fakeAuthenticationService, fakeStringProvider.instance) private val useCase = StartAuthenticationFlowUseCase(fakeAuthenticationService)
@Before @Before
fun setUp() { fun setUp() {
@ -106,21 +101,6 @@ class StartAuthenticationFlowUseCaseTest {
verifyClearsAndThenStartsLogin(A_HOMESERVER_CONFIG) verifyClearsAndThenStartsLogin(A_HOMESERVER_CONFIG)
} }
@Test
fun `given matrix dot org url when starting authentication flow then provides description`() = runTest {
val matrixOrgConfig = HomeServerConnectionConfig(homeServerUri = FakeUri(MATRIX_ORG_URL).instance)
fakeStringProvider.given(R.string.matrix_org_server_url, result = MATRIX_ORG_URL)
fakeAuthenticationService.givenLoginFlow(matrixOrgConfig, aLoginResult())
val result = useCase.execute(matrixOrgConfig)
result shouldBeEqualTo expectedResult(
description = R.string.ftue_auth_create_account_matrix_dot_org_server_description.toTestString(),
homeserverSourceUrl = MATRIX_ORG_URL
)
verifyClearsAndThenStartsLogin(matrixOrgConfig)
}
private fun aLoginResult( private fun aLoginResult(
supportedLoginTypes: List<String> = emptyList() supportedLoginTypes: List<String> = emptyList()
) = LoginFlowResult( ) = LoginFlowResult(
@ -134,14 +114,12 @@ class StartAuthenticationFlowUseCaseTest {
private fun expectedResult( private fun expectedResult(
isHomeserverOutdated: Boolean = false, isHomeserverOutdated: Boolean = false,
description: String? = null,
preferredLoginMode: LoginMode = LoginMode.Unsupported, preferredLoginMode: LoginMode = LoginMode.Unsupported,
supportedLoginTypes: List<String> = emptyList(), supportedLoginTypes: List<String> = emptyList(),
homeserverSourceUrl: String = A_HOMESERVER_CONFIG.homeServerUri.toString() homeserverSourceUrl: String = A_HOMESERVER_CONFIG.homeServerUri.toString()
) = StartAuthenticationResult( ) = StartAuthenticationResult(
isHomeserverOutdated, isHomeserverOutdated,
SelectedHomeserverState( SelectedHomeserverState(
description = description,
userFacingUrl = homeserverSourceUrl, userFacingUrl = homeserverSourceUrl,
upstreamUrl = A_DECLARED_HOMESERVER_URL, upstreamUrl = A_DECLARED_HOMESERVER_URL,
preferredLoginMode = preferredLoginMode, preferredLoginMode = preferredLoginMode,

View File

@ -20,8 +20,10 @@ import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.api.auth.registration.RegistrationAvailability
import org.matrix.android.sdk.api.auth.registration.RegistrationResult import org.matrix.android.sdk.api.auth.registration.RegistrationResult
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.Failure
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
class FakeRegistrationWizard : RegistrationWizard by mockk(relaxed = false) { class FakeRegistrationWizard : RegistrationWizard by mockk(relaxed = false) {
@ -43,6 +45,14 @@ class FakeRegistrationWizard : RegistrationWizard by mockk(relaxed = false) {
} }
} }
fun givenUserNameIsAvailable(userName: String) {
coEvery { registrationAvailable(userName) } returns RegistrationAvailability.Available
}
fun givenUserNameIsUnavailable(userName: String, failure: Failure.ServerError) {
coEvery { registrationAvailable(userName) } returns RegistrationAvailability.NotAvailable(failure)
}
fun verifyCheckedEmailedVerification(times: Int) { fun verifyCheckedEmailedVerification(times: Int) {
coVerify(exactly = times) { checkIfEmailHasBeenValidated(any()) } coVerify(exactly = times) { checkIfEmailHasBeenValidated(any()) }
} }