Merge pull request #5763 from vector-im/feature/adm/server-selection-errors

FTUE - Server selection errors
This commit is contained in:
Adam Brown 2022-04-14 17:22:58 +01:00 committed by GitHub
commit e58677a104
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 241 additions and 178 deletions

1
changelog.d/5749.wip Normal file
View file

@ -0,0 +1 @@
Adds error handling within the new FTUE server selection screen

View file

@ -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

View file

@ -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
}

View file

@ -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<FragmentFtueServerSelectionCombinedBinding>() {
@ -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://"))
}

View file

@ -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" />
</com.google.android.material.textfield.TextInputLayout>

View file

@ -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(

View file

@ -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
}
}