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() }
}
/**
* 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 (~),
* 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
}
}
@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 MatrixIdCase(val input: String, val userName: String?)

View file

@ -19,6 +19,7 @@ package im.vector.app.core.extensions
import android.util.Patterns
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.extensions.ensurePrefix
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.isMatrixId() = MatrixPatterns.isUserId(this.toString())
/**
* 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.clearErrorOnChange(lifecycleOwner: LifecycleOwner) {
onTextChange(lifecycleOwner) {
error = null
isErrorEnabled = false
}
}
fun TextInputLayout.onTextChange(lifecycleOwner: LifecycleOwner, action: (CharSequence) -> Unit) {
editText().textChanges()
.onEach { error = null }
.onEach(action)
.launchIn(lifecycleOwner.lifecycleScope)
}

View file

@ -52,9 +52,13 @@ sealed interface OnboardingAction : VectorViewModelAction {
object ResendResetPassword : 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 {
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
}
@ -71,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
@ -144,7 +147,7 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action)
is OnboardingAction.InitWith -> handleInitWith(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 OnboardingAction.LoginWithToken -> handleLoginWithToken(action)
is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action)
@ -167,13 +170,47 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
private fun handleMaybeUpdateHomeserver(action: OnboardingAction.MaybeUpdateHomeserverFromMatrixId) {
val isFullMatrixId = MatrixPatterns.isUserId(action.userId)
private fun handleUserNameEntered(action: OnboardingAction.UserNameEnteredAction) {
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) {
val domain = action.userId.getServerName().substringBeforeLast(":").ensureProtocol()
handleHomeserverChange(OnboardingAction.HomeServerChange.EditHomeServer(domain))
val domain = userNameOrMatrixId.getServerName().substringBeforeLast(":").ensureProtocol()
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))
}
}
}
@ -184,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)
}
@ -322,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
)
)
}
@ -368,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 -> {
@ -380,6 +427,11 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
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)
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(),
@ -66,7 +69,6 @@ enum class OnboardingFlow {
@Parcelize
data class SelectedHomeserverState(
val description: String? = null,
val userFacingUrl: String? = null,
val upstreamUrl: String? = null,
val preferredLoginMode: LoginMode = LoginMode.Unknown,
@ -96,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

@ -16,10 +16,7 @@
package im.vector.app.features.onboarding
import im.vector.app.R
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 org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
@ -29,7 +26,6 @@ import javax.inject.Inject
class StartAuthenticationFlowUseCase @Inject constructor(
private val authenticationService: AuthenticationService,
private val stringProvider: StringProvider
) {
suspend fun execute(config: HomeServerConnectionConfig): StartAuthenticationResult {
@ -46,10 +42,6 @@ class StartAuthenticationFlowUseCase @Inject constructor(
config: HomeServerConnectionConfig,
preferredLoginMode: LoginMode
) = 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(),
upstreamUrl = authFlow.homeServerUrl,
preferredLoginMode = preferredLoginMode,
@ -57,8 +49,6 @@ class StartAuthenticationFlowUseCase @Inject constructor(
isLogoutDevicesSupported = authFlow.isLogoutDevicesSupported
)
private fun matrixOrgUrl() = stringProvider.getString(R.string.matrix_org_server_url).ensureTrailingSlash()
private fun LoginFlowResult.findPreferredLoginMode() = when {
supportedLoginTypes.containsAllItems(LoginFlowTypes.SSO, LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(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.lifecycle.lifecycleScope
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.editText
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.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject
class FtueAuthCombinedLoginFragment @Inject constructor(
@ -60,14 +63,18 @@ class FtueAuthCombinedLoginFragment @Inject constructor(
views.loginRoot.realignPercentagesToParent()
views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) }
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)) }
}
private fun setupSubmitButton() {
views.loginSubmit.setOnClickListener { submit() }
observeContentChangesAndResetErrors(views.loginInput, views.loginPasswordInput, views.loginSubmit)
.launchIn(viewLifecycleOwner.lifecycleScope)
views.loginInput.clearErrorOnChange(viewLifecycleOwner)
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() {
@ -105,7 +112,6 @@ class FtueAuthCombinedLoginFragment @Inject constructor(
setupAutoFill()
views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl()
views.selectedServerDescription.text = state.selectedHomeserver.description
if (state.isLoading) {
// Ensure password is hidden

View file

@ -28,11 +28,14 @@ import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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.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
@ -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.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
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.isUsernameInUse
import org.matrix.android.sdk.api.failure.isWeakPassword
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject
private const val MINIMUM_PASSWORD_LENGTH = 8
class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAuthFragment<FragmentFtueCombinedRegisterBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueCombinedRegisterBinding {
@ -69,15 +76,27 @@ 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.MaybeUpdateHomeserverFromMatrixId(views.createAccountInput.content()))
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(views.createAccountInput.content()))
}
}
private fun setupSubmitButton() {
views.createAccountSubmit.setOnClickListener { submit() }
observeContentChangesAndResetErrors(views.createAccountInput, views.createAccountPasswordInput, views.createAccountSubmit)
.launchIn(viewLifecycleOwner.lifecycleScope)
views.createAccountInput.clearErrorOnChange(viewLifecycleOwner)
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() {
@ -103,7 +122,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,17 +177,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()
views.selectedServerDescription.text = state.selectedHomeserver.description
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

@ -16,16 +16,10 @@
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.core.extensions.hasContentFlow
import im.vector.app.features.login.SignMode
import im.vector.app.features.onboarding.OnboardingAction
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 {
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()) {
true -> R.drawable.bg_gradient_ftue_breaker
false -> R.drawable.bg_color_background

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,24 +62,10 @@
android:gravity="center"
android:text="@string/ftue_auth_create_account_title"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/createAccountHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/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_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderTitle" />
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderIcon" />
<Space
android:id="@+id/titleContentSpacing"
@ -87,7 +73,7 @@
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/chooseYourServerHeader"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderSubtitle" />
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderTitle" />
<TextView
android:id="@+id/chooseYourServerHeader"
@ -110,22 +96,11 @@
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/selectedServerDescription"
app:layout_constraintEnd_toStartOf="@id/editServerButton"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseYourServerHeader" />
<TextView
android:id="@+id/selectedServerDescription"
style="@style/Widget.Vector.TextView.Micro"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textColor="?vctr_content_tertiary"
app:layout_constraintBottom_toTopOf="@id/serverSelectionSpacing"
app:layout_constraintEnd_toStartOf="@id/editServerButton"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/selectedServerName" />
app:layout_constraintTop_toBottomOf="@id/chooseYourServerHeader"
tools:text="matrix.org" />
<Button
android:id="@+id/editServerButton"
@ -137,7 +112,7 @@
android:paddingEnd="12dp"
android:text="@string/ftue_auth_create_account_edit_server_selection"
android:textAllCaps="true"
app:layout_constraintBottom_toBottomOf="@id/selectedServerDescription"
app:layout_constraintBottom_toBottomOf="@id/selectedServerName"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintTop_toTopOf="@id/chooseYourServerHeader" />
@ -147,7 +122,7 @@
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/createAccountInput"
app:layout_constraintHeight_percent="0.05"
app:layout_constraintTop_toBottomOf="@id/selectedServerDescription" />
app:layout_constraintTop_toBottomOf="@id/selectedServerName" />
<View
android:layout_width="0dp"
@ -185,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,12 +11,11 @@
<!-- WIP -->
<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>
<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>
<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.FakeLoginWizard
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.FakeStartAuthenticationFlowUseCase
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.FakeVectorOverrides
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.aHomeServerCapabilities
import im.vector.app.test.test
@ -50,11 +52,13 @@ import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
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.homeserver.HomeServerCapabilities
private const val A_DISPLAY_NAME = "a display name"
private const val A_PICTURE_FILENAME = "a-picture.png"
private val A_SERVER_ERROR = a401ServerError()
private val AN_ERROR = RuntimeException("an error!")
private val A_LOADABLE_REGISTER_ACTION = RegisterAction.StartRegistration
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 const val A_HOMESERVER_URL = "https://edited-homeserver.org"
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 const val AN_EMAIL = "hello@example.com"
private const val A_PASSWORD = "a-password"
@ -290,13 +294,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.MaybeUpdateHomeserverFromMatrixId(fullMatrixId))
viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(fullMatrixId))
test
.assertStatesChanges(
@ -311,12 +315,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.MaybeUpdateHomeserverFromMatrixId(onlyUsername))
viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(onlyUsername))
test
.assertStates(initialState)
@ -324,6 +327,93 @@ class OnboardingViewModelTest {
.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
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))
@ -640,6 +730,14 @@ class OnboardingViewModelTest {
givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationActionHandler.Result.Error(error))
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(

View file

@ -16,13 +16,10 @@
package im.vector.app.features.onboarding
import im.vector.app.R
import im.vector.app.features.login.LoginMode
import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult
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.toTestString
import io.mockk.coVerifyOrder
import kotlinx.coroutines.test.runTest
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.SsoIdentityProvider
private const val MATRIX_ORG_URL = "https://any-value.org/"
private const val A_DECLARED_HOMESERVER_URL = "https://foo.bar"
private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(homeServerUri = FakeUri().instance)
private val SSO_IDENTITY_PROVIDERS = emptyList<SsoIdentityProvider>()
@ -41,9 +37,8 @@ private val SSO_IDENTITY_PROVIDERS = emptyList<SsoIdentityProvider>()
class StartAuthenticationFlowUseCaseTest {
private val fakeAuthenticationService = FakeAuthenticationService()
private val fakeStringProvider = FakeStringProvider()
private val useCase = StartAuthenticationFlowUseCase(fakeAuthenticationService, fakeStringProvider.instance)
private val useCase = StartAuthenticationFlowUseCase(fakeAuthenticationService)
@Before
fun setUp() {
@ -106,21 +101,6 @@ class StartAuthenticationFlowUseCaseTest {
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(
supportedLoginTypes: List<String> = emptyList()
) = LoginFlowResult(
@ -134,14 +114,12 @@ class StartAuthenticationFlowUseCaseTest {
private fun expectedResult(
isHomeserverOutdated: Boolean = false,
description: String? = null,
preferredLoginMode: LoginMode = LoginMode.Unsupported,
supportedLoginTypes: List<String> = emptyList(),
homeserverSourceUrl: String = A_HOMESERVER_CONFIG.homeServerUri.toString()
) = StartAuthenticationResult(
isHomeserverOutdated,
SelectedHomeserverState(
description = description,
userFacingUrl = homeserverSourceUrl,
upstreamUrl = A_DECLARED_HOMESERVER_URL,
preferredLoginMode = preferredLoginMode,

View file

@ -20,8 +20,10 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
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.RegistrationWizard
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.Session
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) {
coVerify(exactly = times) { checkIfEmailHasBeenValidated(any()) }
}