diff --git a/changelog.d/5749.wip b/changelog.d/5749.wip new file mode 100644 index 0000000000..a933f55cf5 --- /dev/null +++ b/changelog.d/5749.wip @@ -0,0 +1 @@ +Adds error handling within the new FTUE server selection screen \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt index 99fc0ba8b7..362ebcec26 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt @@ -22,34 +22,29 @@ import org.matrix.android.sdk.api.session.contentscanner.ContentScannerError import org.matrix.android.sdk.api.session.contentscanner.ScanFailure import org.matrix.android.sdk.internal.di.MoshiProvider import java.io.IOException +import java.net.UnknownHostException import javax.net.ssl.HttpsURLConnection -fun Throwable.is401() = - this is Failure.ServerError && - httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED && /* 401 */ - error.code == MatrixError.M_UNAUTHORIZED +fun Throwable.is401() = this is Failure.ServerError && + httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED && /* 401 */ + error.code == MatrixError.M_UNAUTHORIZED -fun Throwable.is404() = - this is Failure.ServerError && - httpCode == HttpsURLConnection.HTTP_NOT_FOUND && /* 404 */ - error.code == MatrixError.M_NOT_FOUND +fun Throwable.is404() = this is Failure.ServerError && + httpCode == HttpsURLConnection.HTTP_NOT_FOUND && /* 404 */ + error.code == MatrixError.M_NOT_FOUND -fun Throwable.isTokenError() = - this is Failure.ServerError && - (error.code == MatrixError.M_UNKNOWN_TOKEN || - error.code == MatrixError.M_MISSING_TOKEN || - error.code == MatrixError.ORG_MATRIX_EXPIRED_ACCOUNT) +fun Throwable.isTokenError() = this is Failure.ServerError && + (error.code == MatrixError.M_UNKNOWN_TOKEN || + error.code == MatrixError.M_MISSING_TOKEN || + error.code == MatrixError.ORG_MATRIX_EXPIRED_ACCOUNT) -fun Throwable.isLimitExceededError() = - this is Failure.ServerError && - httpCode == 429 && - error.code == MatrixError.M_LIMIT_EXCEEDED +fun Throwable.isLimitExceededError() = this is Failure.ServerError && + httpCode == 429 && + error.code == MatrixError.M_LIMIT_EXCEEDED -fun Throwable.shouldBeRetried(): Boolean { - return this is Failure.NetworkConnection || - this is IOException || - this.isLimitExceededError() -} +fun Throwable.shouldBeRetried() = this is Failure.NetworkConnection || + this is IOException || + isLimitExceededError() /** * Get the retry delay in case of rate limit exceeded error, adding 100 ms, of defaultValue otherwise @@ -63,41 +58,33 @@ fun Throwable.getRetryDelay(defaultValue: Long): Long { ?: defaultValue } -fun Throwable.isUsernameInUse(): Boolean { - return this is Failure.ServerError && error.code == MatrixError.M_USER_IN_USE -} +fun Throwable.isUsernameInUse() = this is Failure.ServerError && + error.code == MatrixError.M_USER_IN_USE -fun Throwable.isInvalidUsername(): Boolean { - return this is Failure.ServerError && - error.code == MatrixError.M_INVALID_USERNAME -} +fun Throwable.isInvalidUsername() = this is Failure.ServerError && + error.code == MatrixError.M_INVALID_USERNAME -fun Throwable.isInvalidPassword(): Boolean { - return this is Failure.ServerError && - error.code == MatrixError.M_FORBIDDEN && - error.message == "Invalid password" -} +fun Throwable.isInvalidPassword() = this is Failure.ServerError && + error.code == MatrixError.M_FORBIDDEN && + error.message == "Invalid password" -fun Throwable.isRegistrationDisabled(): Boolean { - return this is Failure.ServerError && error.code == MatrixError.M_FORBIDDEN && - httpCode == HttpsURLConnection.HTTP_FORBIDDEN -} +fun Throwable.isRegistrationDisabled() = this is Failure.ServerError && + error.code == MatrixError.M_FORBIDDEN && + httpCode == HttpsURLConnection.HTTP_FORBIDDEN -fun Throwable.isWeakPassword(): Boolean { - return this is Failure.ServerError && error.code == MatrixError.M_WEAK_PASSWORD -} +fun Throwable.isWeakPassword() = this is Failure.ServerError && + error.code == MatrixError.M_WEAK_PASSWORD -fun Throwable.isLoginEmailUnknown(): Boolean { - return this is Failure.ServerError && - error.code == MatrixError.M_FORBIDDEN && - error.message.isEmpty() -} +fun Throwable.isLoginEmailUnknown() = this is Failure.ServerError && + error.code == MatrixError.M_FORBIDDEN && + error.message.isEmpty() -fun Throwable.isInvalidUIAAuth(): Boolean { - return this is Failure.ServerError && - error.code == MatrixError.M_FORBIDDEN && - error.flows != null -} +fun Throwable.isInvalidUIAAuth() = this is Failure.ServerError && + error.code == MatrixError.M_FORBIDDEN && + error.flows != null + +fun Throwable.isHomeserverUnavailable() = this is Failure.NetworkConnection && + this.ioException is UnknownHostException /** * Try to convert to a RegistrationFlowResponse. Return null in the cases it's not possible @@ -129,13 +116,11 @@ fun Throwable.toRegistrationFlowResponse(): RegistrationFlowResponse? { } } -fun Throwable.isRegistrationAvailabilityError(): Boolean { - return this is Failure.ServerError && - httpCode == HttpsURLConnection.HTTP_BAD_REQUEST && /* 400 */ - (error.code == MatrixError.M_USER_IN_USE || - error.code == MatrixError.M_INVALID_USERNAME || - error.code == MatrixError.M_EXCLUSIVE) -} +fun Throwable.isRegistrationAvailabilityError() = this is Failure.ServerError && + httpCode == HttpsURLConnection.HTTP_BAD_REQUEST && /* 400 */ + (error.code == MatrixError.M_USER_IN_USE || + error.code == MatrixError.M_INVALID_USERNAME || + error.code == MatrixError.M_EXCLUSIVE) /** * Try to convert to a ScanFailure. Return null in the cases it's not possible diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt index 54aea0185c..2b286e6d93 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt @@ -41,6 +41,7 @@ import im.vector.app.features.login.LoginMode import im.vector.app.features.login.ReAuthHelper import im.vector.app.features.login.ServerType import im.vector.app.features.login.SignMode +import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult import kotlinx.coroutines.Job import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @@ -140,14 +141,14 @@ class OnboardingViewModel @AssistedInject constructor( is OnboardingAction.UpdateServerType -> handleUpdateServerType(action) is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action) is OnboardingAction.InitWith -> handleInitWith(action) - is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action.homeServerUrl) } + is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action) } is OnboardingAction.LoginOrRegister -> handleLoginOrRegister(action).also { lastAction = action } is OnboardingAction.Register -> handleRegisterWith(action).also { lastAction = action } is OnboardingAction.LoginWithToken -> handleLoginWithToken(action) is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action) is OnboardingAction.ResetPassword -> handleResetPassword(action) is OnboardingAction.ResetPasswordMailConfirmed -> handleResetPasswordMailConfirmed() - is OnboardingAction.PostRegisterAction -> handleRegisterAction(action.registerAction) + is OnboardingAction.PostRegisterAction -> handleRegisterAction(action.registerAction, ::emitFlowResultViewEvent) is OnboardingAction.ResetAction -> handleResetAction(action) is OnboardingAction.UserAcceptCertificate -> handleUserAcceptCertificate(action) OnboardingAction.ClearHomeServerHistory -> handleClearHomeServerHistory() @@ -175,7 +176,7 @@ class OnboardingViewModel @AssistedInject constructor( return when (val config = loginConfig.toHomeserverConfig()) { null -> continueToPageAfterSplash(onboardingFlow) - else -> startAuthenticationFlow(config, ServerType.Other) + else -> startAuthenticationFlow(trigger = null, config, ServerType.Other) } } @@ -209,9 +210,9 @@ class OnboardingViewModel @AssistedInject constructor( is OnboardingAction.HomeServerChange.SelectHomeServer -> { currentHomeServerConnectionConfig ?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) } - ?.let { startAuthenticationFlow(it) } + ?.let { startAuthenticationFlow(finalLastAction, it) } } - is OnboardingAction.LoginOrRegister -> + is OnboardingAction.LoginOrRegister -> handleDirectLogin( finalLastAction, HomeServerConnectionConfig.Builder() @@ -220,7 +221,7 @@ class OnboardingViewModel @AssistedInject constructor( .withAllowedFingerPrints(listOf(action.fingerprint)) .build() ) - else -> Unit + else -> Unit } } @@ -255,41 +256,52 @@ class OnboardingViewModel @AssistedInject constructor( } } - private fun handleRegisterAction(action: RegisterAction) { + private fun handleRegisterAction(action: RegisterAction, onNextRegistrationStepAction: (FlowResult) -> Unit) { currentJob = viewModelScope.launch { if (action.hasLoadingState()) { setState { copy(isLoading = true) } } - runCatching { registrationActionHandler.handleRegisterAction(registrationWizard, action) } - .fold( - onSuccess = { - when { - action.ignoresResult() -> { - // do nothing - } - else -> when (it) { - is RegistrationResult.Success -> onSessionCreated(it.session, isAccountCreated = true) - is RegistrationResult.FlowResponse -> onFlowResponse(it.flowResult) - } - } - }, - onFailure = { - if (it !is CancellationException) { - _viewEvents.post(OnboardingViewEvents.Failure(it)) - } - } - ) + internalRegisterAction(action, onNextRegistrationStepAction) setState { copy(isLoading = false) } } } + private suspend fun internalRegisterAction(action: RegisterAction, onNextRegistrationStepAction: (FlowResult) -> Unit) { + runCatching { registrationActionHandler.handleRegisterAction(registrationWizard, action) } + .fold( + onSuccess = { + when { + action.ignoresResult() -> { + // do nothing + } + else -> when (it) { + is RegistrationResult.Success -> onSessionCreated(it.session, isAccountCreated = true) + is RegistrationResult.FlowResponse -> onFlowResponse(it.flowResult, onNextRegistrationStepAction) + } + } + }, + onFailure = { + if (it !is CancellationException) { + _viewEvents.post(OnboardingViewEvents.Failure(it)) + } + } + ) + } + + private fun emitFlowResultViewEvent(flowResult: FlowResult) { + _viewEvents.post(OnboardingViewEvents.RegistrationFlowResult(flowResult, isRegistrationStarted)) + } + private fun handleRegisterWith(action: OnboardingAction.Register) { reAuthHelper.data = action.password - handleRegisterAction(RegisterAction.CreateAccount( - action.username, - action.password, - action.initialDeviceName - )) + handleRegisterAction( + RegisterAction.CreateAccount( + action.username, + action.password, + action.initialDeviceName + ), + ::emitFlowResultViewEvent + ) } private fun handleResetAction(action: OnboardingAction.ResetAction) { @@ -337,20 +349,19 @@ class OnboardingViewModel @AssistedInject constructor( } private fun handleUpdateSignMode(action: OnboardingAction.UpdateSignMode) { - setState { - copy( - signMode = action.signMode - ) - } - + updateSignMode(action.signMode) when (action.signMode) { - SignMode.SignUp -> handleRegisterAction(RegisterAction.StartRegistration) + SignMode.SignUp -> handleRegisterAction(RegisterAction.StartRegistration, ::emitFlowResultViewEvent) SignMode.SignIn -> startAuthenticationFlow() SignMode.SignInWithMatrixId -> _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignInWithMatrixId)) SignMode.Unknown -> Unit } } + private fun updateSignMode(signMode: SignMode) { + setState { copy(signMode = signMode) } + } + private fun handleUpdateUseCase(action: OnboardingAction.UpdateUseCase) { setState { copy(useCase = action.useCase) } when (vectorFeatures.isOnboardingCombinedRegisterEnabled()) { @@ -509,18 +520,17 @@ class OnboardingViewModel @AssistedInject constructor( _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignIn)) } - private fun onFlowResponse(flowResult: FlowResult) { + private suspend fun onFlowResponse(flowResult: FlowResult, onNextRegistrationStepAction: (FlowResult) -> Unit) { // If dummy stage is mandatory, and password is already sent, do the dummy stage now if (isRegistrationStarted && flowResult.missingStages.any { it is Stage.Dummy && it.mandatory }) { - handleRegisterDummy() + handleRegisterDummy(onNextRegistrationStepAction) } else { - // Notify the user - _viewEvents.post(OnboardingViewEvents.RegistrationFlowResult(flowResult, isRegistrationStarted)) + onNextRegistrationStepAction(flowResult) } } - private fun handleRegisterDummy() { - handleRegisterAction(RegisterAction.RegisterDummy) + private suspend fun handleRegisterDummy(onNextRegistrationStepAction: (FlowResult) -> Unit) { + internalRegisterAction(RegisterAction.RegisterDummy, onNextRegistrationStepAction) } private suspend fun onSessionCreated(session: Session, isAccountCreated: Boolean) { @@ -581,42 +591,87 @@ class OnboardingViewModel @AssistedInject constructor( } } - private fun handleHomeserverChange(homeserverUrl: String) { - val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(homeserverUrl) + private fun handleHomeserverChange(action: OnboardingAction.HomeServerChange) { + val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl) if (homeServerConnectionConfig == null) { // This is invalid _viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) } else { - startAuthenticationFlow(homeServerConnectionConfig) + startAuthenticationFlow(action, homeServerConnectionConfig) } } - private fun startAuthenticationFlow(homeServerConnectionConfig: HomeServerConnectionConfig, serverTypeOverride: ServerType? = null) { + private fun startAuthenticationFlow( + trigger: OnboardingAction?, + homeServerConnectionConfig: HomeServerConnectionConfig, + serverTypeOverride: ServerType? = null + ) { currentHomeServerConnectionConfig = homeServerConnectionConfig currentJob = viewModelScope.launch { setState { copy(isLoading = true) } - runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold( - onSuccess = { - rememberHomeServer(homeServerConnectionConfig.homeServerUri.toString()) - if (it.isHomeserverOutdated) { - _viewEvents.post(OnboardingViewEvents.OutdatedHomeserver) - } + onSuccess = { onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride) }, + onFailure = { _viewEvents.post(OnboardingViewEvents.Failure(it)) } + ) + setState { copy(isLoading = false) } + } + } - setState { - copy( - serverType = alignServerTypeAfterSubmission(homeServerConnectionConfig, serverTypeOverride), - selectedHomeserver = it.selectedHomeserver, - isLoading = false, - ) - } - onAuthenticationStartedSuccess() - }, - onFailure = { - setState { copy(isLoading = false) } - _viewEvents.post(OnboardingViewEvents.Failure(it)) + private suspend fun onAuthenticationStartedSuccess( + trigger: OnboardingAction?, + config: HomeServerConnectionConfig, + authResult: StartAuthenticationResult, + serverTypeOverride: ServerType? + ) { + rememberHomeServer(config.homeServerUri.toString()) + if (authResult.isHomeserverOutdated) { + _viewEvents.post(OnboardingViewEvents.OutdatedHomeserver) + } + + when (trigger) { + is OnboardingAction.HomeServerChange.EditHomeServer -> { + when (awaitState().onboardingFlow) { + OnboardingFlow.SignUp -> internalRegisterAction(RegisterAction.StartRegistration) { _ -> + updateServerSelection(config, serverTypeOverride, authResult) + _viewEvents.post(OnboardingViewEvents.OnHomeserverEdited) } + else -> throw IllegalArgumentException("developer error") + } + } + is OnboardingAction.HomeServerChange.SelectHomeServer -> { + updateServerSelection(config, serverTypeOverride, authResult) + if (authResult.selectedHomeserver.preferredLoginMode.supportsSignModeScreen()) { + when (awaitState().onboardingFlow) { + OnboardingFlow.SignIn -> { + updateSignMode(SignMode.SignIn) + internalRegisterAction(RegisterAction.StartRegistration, ::emitFlowResultViewEvent) + } + OnboardingFlow.SignUp -> { + updateSignMode(SignMode.SignUp) + internalRegisterAction(RegisterAction.StartRegistration, ::emitFlowResultViewEvent) + } + OnboardingFlow.SignInSignUp, + null -> { + _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) + } + } + } else { + _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) + } + } + else -> { + updateServerSelection(config, serverTypeOverride, authResult) + _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) + } + } + } + + private fun updateServerSelection(config: HomeServerConnectionConfig, serverTypeOverride: ServerType?, authResult: StartAuthenticationResult) { + setState { + copy( + serverType = alignServerTypeAfterSubmission(config, serverTypeOverride), + selectedHomeserver = authResult.selectedHomeserver, ) } } @@ -633,29 +688,6 @@ class OnboardingViewModel @AssistedInject constructor( } } - private fun onAuthenticationStartedSuccess() { - withState { - when (lastAction) { - is OnboardingAction.HomeServerChange.EditHomeServer -> _viewEvents.post(OnboardingViewEvents.OnHomeserverEdited) - is OnboardingAction.HomeServerChange.SelectHomeServer -> { - if (it.selectedHomeserver.preferredLoginMode.supportsSignModeScreen()) { - when (it.onboardingFlow) { - OnboardingFlow.SignIn -> handleUpdateSignMode(OnboardingAction.UpdateSignMode(SignMode.SignIn)) - OnboardingFlow.SignUp -> handleUpdateSignMode(OnboardingAction.UpdateSignMode(SignMode.SignUp)) - OnboardingFlow.SignInSignUp, - null -> { - _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) - } - } - } else { - _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) - } - } - else -> _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) - } - } - } - fun getInitialHomeServerUrl(): String? { return loginConfig?.homeServerUrl } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt index d1560d7be0..2e6057288a 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import androidx.lifecycle.lifecycleScope import im.vector.app.R import im.vector.app.core.extensions.content import im.vector.app.core.extensions.editText @@ -33,6 +34,10 @@ import im.vector.app.databinding.FragmentFtueServerSelectionCombinedBinding 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.launchIn +import kotlinx.coroutines.flow.onEach +import org.matrix.android.sdk.api.failure.isHomeserverUnavailable +import reactivecircus.flowbinding.android.widget.textChanges import javax.inject.Inject class FtueAuthCombinedServerSelectionFragment @Inject constructor() : AbstractFtueAuthFragment() { @@ -61,6 +66,9 @@ class FtueAuthCombinedServerSelectionFragment @Inject constructor() : AbstractFt } views.chooseServerGetInTouch.debouncedClicks { openUrlInExternalBrowser(requireContext(), getString(R.string.ftue_ems_url)) } views.chooseServerSubmit.debouncedClicks { updateServerUrl() } + views.chooseServerInput.editText().textChanges() + .onEach { views.chooseServerInput.error = null } + .launchIn(lifecycleScope) } private fun updateServerUrl() { @@ -78,5 +86,12 @@ class FtueAuthCombinedServerSelectionFragment @Inject constructor() : AbstractFt } } + override fun onError(throwable: Throwable) { + views.chooseServerInput.error = when { + throwable.isHomeserverUnavailable() -> getString(R.string.login_error_homeserver_not_found) + else -> errorFormatter.toHumanReadable(throwable) + } + } + private fun String.toReducedUrlKeepingSchemaIfInsecure() = toReducedUrl(keepSchema = this.startsWith("http://")) } diff --git a/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml b/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml index 8f4902a577..5a60632e86 100644 --- a/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml +++ b/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml @@ -106,7 +106,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:imeOptions="actionDone" - android:inputType="text" + android:inputType="textUri" android:maxLines="1" /> diff --git a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt index 77b3f495f0..62fc9548b2 100644 --- a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt @@ -73,7 +73,6 @@ class OnboardingViewModelTest { private val fakeUri = FakeUri() private val fakeContext = FakeContext() - private val initialState = OnboardingViewState() private val fakeSession = FakeSession() private val fakeUriFilenameResolver = FakeUriFilenameResolver() private val fakeActiveSessionHolder = FakeActiveSessionHolder(fakeSession) @@ -85,11 +84,12 @@ class OnboardingViewModelTest { private val fakeStartAuthenticationFlowUseCase = FakeStartAuthenticationFlowUseCase() private val fakeHomeServerHistoryService = FakeHomeServerHistoryService() - lateinit var viewModel: OnboardingViewModel + private var initialState = OnboardingViewState() + private lateinit var viewModel: OnboardingViewModel @Before fun setUp() { - viewModel = createViewModel() + viewModelWith(initialState) } @Test @@ -105,8 +105,7 @@ class OnboardingViewModelTest { @Test fun `given supports changing display name, when handling PersonalizeProfile, then emits contents choose display name`() = runTest { - val initialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = true, supportsChangingProfilePicture = false)) - viewModel = createViewModel(initialState) + viewModelWith(initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = true, supportsChangingProfilePicture = false))) val test = viewModel.test() viewModel.handle(OnboardingAction.PersonalizeProfile) @@ -118,8 +117,7 @@ class OnboardingViewModelTest { @Test fun `given only supports changing profile picture, when handling PersonalizeProfile, then emits contents choose profile picture`() = runTest { - val initialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = false, supportsChangingProfilePicture = true)) - viewModel = createViewModel(initialState) + viewModelWith(initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = false, supportsChangingProfilePicture = true))) val test = viewModel.test() viewModel.handle(OnboardingAction.PersonalizeProfile) @@ -131,8 +129,7 @@ class OnboardingViewModelTest { @Test fun `given has sign in with matrix id sign mode, when handling login or register action, then logs in directly`() = runTest { - val initialState = initialState.copy(signMode = SignMode.SignInWithMatrixId) - viewModel = createViewModel(initialState) + viewModelWith(initialState.copy(signMode = SignMode.SignInWithMatrixId)) fakeDirectLoginUseCase.givenSuccessResult(A_LOGIN_OR_REGISTER_ACTION, config = null, result = fakeSession) givenInitialisesSession(fakeSession) val test = viewModel.test() @@ -151,8 +148,7 @@ class OnboardingViewModelTest { @Test fun `given has sign in with matrix id sign mode, when handling login or register action fails, then emits error`() = runTest { - val initialState = initialState.copy(signMode = SignMode.SignInWithMatrixId) - viewModel = createViewModel(initialState) + viewModelWith(initialState.copy(signMode = SignMode.SignInWithMatrixId)) fakeDirectLoginUseCase.givenFailureResult(A_LOGIN_OR_REGISTER_ACTION, config = null, cause = AN_ERROR) givenInitialisesSession(fakeSession) val test = viewModel.test() @@ -235,11 +231,13 @@ class OnboardingViewModelTest { } @Test - fun `given when editing homeserver, then updates selected homeserver state and emits edited event`() = runTest { - val test = viewModel.test() + fun `given in the sign up flow, when editing homeserver, then updates selected homeserver state and emits edited event`() = runTest { + viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp)) fakeHomeServerConnectionConfigFactory.givenConfigFor(A_HOMESERVER_URL, A_HOMESERVER_CONFIG) - fakeStartAuthenticationFlowUseCase.givenResult(A_HOMESERVER_CONFIG, StartAuthenticationResult(false, SELECTED_HOMESERVER_STATE)) + fakeStartAuthenticationFlowUseCase.givenResult(A_HOMESERVER_CONFIG, StartAuthenticationResult(isHomeserverOutdated = false, SELECTED_HOMESERVER_STATE)) + givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationResult.FlowResponse(AN_IGNORED_FLOW_RESULT)) fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString()) + val test = viewModel.test() viewModel.handle(OnboardingAction.HomeServerChange.EditHomeServer(A_HOMESERVER_URL)) @@ -247,12 +245,35 @@ class OnboardingViewModelTest { .assertStatesChanges( initialState, { copy(isLoading = true) }, - { copy(isLoading = false, selectedHomeserver = SELECTED_HOMESERVER_STATE) }, + { copy(selectedHomeserver = SELECTED_HOMESERVER_STATE) }, + { copy(isLoading = false) } + ) .assertEvents(OnboardingViewEvents.OnHomeserverEdited) .finish() } + @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)) + fakeHomeServerConnectionConfigFactory.givenConfigFor(A_HOMESERVER_URL, A_HOMESERVER_CONFIG) + fakeStartAuthenticationFlowUseCase.givenResult(A_HOMESERVER_CONFIG, StartAuthenticationResult(isHomeserverOutdated = false, SELECTED_HOMESERVER_STATE)) + givenRegistrationActionErrors(RegisterAction.StartRegistration, AN_ERROR) + fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString()) + val test = viewModel.test() + + viewModel.handle(OnboardingAction.HomeServerChange.EditHomeServer(A_HOMESERVER_URL)) + + test + .assertStatesChanges( + initialState, + { copy(isLoading = true) }, + { copy(isLoading = false) } + ) + .assertEvents(OnboardingViewEvents.Failure(AN_ERROR)) + .finish() + } + @Test fun `given personalisation enabled, when registering account, then updates state and emits account created event`() = runTest { fakeVectorFeatures.givenPersonalisationEnabled() @@ -292,14 +313,13 @@ class OnboardingViewModelTest { @Test fun `given changing profile picture is supported, when updating display name, then updates upstream user display name and moves to choose profile picture`() = runTest { - val personalisedInitialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = true)) - viewModel = createViewModel(personalisedInitialState) + viewModelWith(initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = true))) val test = viewModel.test() viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME)) test - .assertStatesChanges(personalisedInitialState, expectedSuccessfulDisplayNameUpdateStates()) + .assertStatesChanges(initialState, expectedSuccessfulDisplayNameUpdateStates()) .assertEvents(OnboardingViewEvents.OnChooseProfilePicture) .finish() fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME) @@ -307,14 +327,13 @@ class OnboardingViewModelTest { @Test fun `given changing profile picture is not supported, when updating display name, then updates upstream user display name and completes personalization`() = runTest { - val personalisedInitialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = false)) - viewModel = createViewModel(personalisedInitialState) + viewModelWith(initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = false))) val test = viewModel.test() viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME)) test - .assertStatesChanges(personalisedInitialState, expectedSuccessfulDisplayNameUpdateStates()) + .assertStatesChanges(initialState, expectedSuccessfulDisplayNameUpdateStates()) .assertEvents(OnboardingViewEvents.OnPersonalizationComplete) .finish() fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME) @@ -354,14 +373,13 @@ class OnboardingViewModelTest { @Test fun `given a selected picture, when handling save selected profile picture, then updates upstream avatar and completes personalization`() = runTest { - val initialStateWithPicture = givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME) - viewModel = createViewModel(initialStateWithPicture) + viewModelWith(givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME)) val test = viewModel.test() viewModel.handle(OnboardingAction.SaveSelectedProfilePicture) test - .assertStates(expectedProfilePictureSuccessStates(initialStateWithPicture)) + .assertStates(expectedProfilePictureSuccessStates(initialState)) .assertEvents(OnboardingViewEvents.OnPersonalizationComplete) .finish() fakeSession.fakeProfileService.verifyAvatarUpdated(fakeSession.myUserId, fakeUri.instance, A_PICTURE_FILENAME) @@ -370,14 +388,13 @@ class OnboardingViewModelTest { @Test fun `given upstream update avatar fails, when saving selected profile picture, then emits failure event`() = runTest { fakeSession.fakeProfileService.givenUpdateAvatarErrors(AN_ERROR) - val initialStateWithPicture = givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME) - viewModel = createViewModel(initialStateWithPicture) + viewModelWith(givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME)) val test = viewModel.test() viewModel.handle(OnboardingAction.SaveSelectedProfilePicture) test - .assertStates(expectedProfilePictureFailureStates(initialStateWithPicture)) + .assertStates(expectedProfilePictureFailureStates(initialState)) .assertEvents(OnboardingViewEvents.Failure(AN_ERROR)) .finish() } @@ -406,8 +423,8 @@ class OnboardingViewModelTest { .finish() } - private fun createViewModel(state: OnboardingViewState = initialState): OnboardingViewModel { - return OnboardingViewModel( + private fun viewModelWith(state: OnboardingViewState) { + OnboardingViewModel( state, fakeContext.instance, fakeAuthenticationService, @@ -423,7 +440,10 @@ class OnboardingViewModelTest { fakeDirectLoginUseCase.instance, fakeStartAuthenticationFlowUseCase.instance, FakeVectorOverrides() - ) + ).also { + viewModel = it + initialState = state + } } private fun givenPictureSelected(fileUri: Uri, filename: String): OnboardingViewState { @@ -481,6 +501,12 @@ class OnboardingViewModelTest { fakeAuthenticationService.givenRegistrationWizard(registrationWizard) fakeRegisterActionHandler.givenResultsFor(registrationWizard, results) } + + private fun givenRegistrationActionErrors(action: RegisterAction, cause: Throwable) { + val registrationWizard = FakeRegistrationWizard() + fakeAuthenticationService.givenRegistrationWizard(registrationWizard) + fakeRegisterActionHandler.givenThrowsFor(registrationWizard, action, cause) + } } private fun HomeServerCapabilities.toPersonalisationState() = PersonalizationState( diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRegisterActionHandler.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRegisterActionHandler.kt index 8d595d91e9..61d0e438ab 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeRegisterActionHandler.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRegisterActionHandler.kt @@ -33,4 +33,8 @@ class FakeRegisterActionHandler { result.first { it.first == actionArg }.second } } + + fun givenThrowsFor(wizard: RegistrationWizard, action: RegisterAction, cause: Throwable) { + coEvery { instance.handleRegisterAction(wizard, action) } throws cause + } }