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.api.session.contentscanner.ScanFailure
import org.matrix.android.sdk.internal.di.MoshiProvider import org.matrix.android.sdk.internal.di.MoshiProvider
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
fun Throwable.is401() = fun Throwable.is401() = this is Failure.ServerError &&
this is Failure.ServerError && httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED && /* 401 */
httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED && /* 401 */ error.code == MatrixError.M_UNAUTHORIZED
error.code == MatrixError.M_UNAUTHORIZED
fun Throwable.is404() = fun Throwable.is404() = this is Failure.ServerError &&
this is Failure.ServerError && httpCode == HttpsURLConnection.HTTP_NOT_FOUND && /* 404 */
httpCode == HttpsURLConnection.HTTP_NOT_FOUND && /* 404 */ error.code == MatrixError.M_NOT_FOUND
error.code == MatrixError.M_NOT_FOUND
fun Throwable.isTokenError() = fun Throwable.isTokenError() = this is Failure.ServerError &&
this is Failure.ServerError && (error.code == MatrixError.M_UNKNOWN_TOKEN ||
(error.code == MatrixError.M_UNKNOWN_TOKEN || error.code == MatrixError.M_MISSING_TOKEN ||
error.code == MatrixError.M_MISSING_TOKEN || error.code == MatrixError.ORG_MATRIX_EXPIRED_ACCOUNT)
error.code == MatrixError.ORG_MATRIX_EXPIRED_ACCOUNT)
fun Throwable.isLimitExceededError() = fun Throwable.isLimitExceededError() = this is Failure.ServerError &&
this is Failure.ServerError && httpCode == 429 &&
httpCode == 429 && error.code == MatrixError.M_LIMIT_EXCEEDED
error.code == MatrixError.M_LIMIT_EXCEEDED
fun Throwable.shouldBeRetried(): Boolean { fun Throwable.shouldBeRetried() = this is Failure.NetworkConnection ||
return this is Failure.NetworkConnection || this is IOException ||
this is IOException || isLimitExceededError()
this.isLimitExceededError()
}
/** /**
* Get the retry delay in case of rate limit exceeded error, adding 100 ms, of defaultValue otherwise * 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 ?: defaultValue
} }
fun Throwable.isUsernameInUse(): Boolean { fun Throwable.isUsernameInUse() = this is Failure.ServerError &&
return this is Failure.ServerError && error.code == MatrixError.M_USER_IN_USE error.code == MatrixError.M_USER_IN_USE
}
fun Throwable.isInvalidUsername(): Boolean { fun Throwable.isInvalidUsername() = this is Failure.ServerError &&
return this is Failure.ServerError && error.code == MatrixError.M_INVALID_USERNAME
error.code == MatrixError.M_INVALID_USERNAME
}
fun Throwable.isInvalidPassword(): Boolean { fun Throwable.isInvalidPassword() = this is Failure.ServerError &&
return this is Failure.ServerError && error.code == MatrixError.M_FORBIDDEN &&
error.code == MatrixError.M_FORBIDDEN && error.message == "Invalid password"
error.message == "Invalid password"
}
fun Throwable.isRegistrationDisabled(): Boolean { fun Throwable.isRegistrationDisabled() = this is Failure.ServerError &&
return this is Failure.ServerError && error.code == MatrixError.M_FORBIDDEN && error.code == MatrixError.M_FORBIDDEN &&
httpCode == HttpsURLConnection.HTTP_FORBIDDEN httpCode == HttpsURLConnection.HTTP_FORBIDDEN
}
fun Throwable.isWeakPassword(): Boolean { fun Throwable.isWeakPassword() = this is Failure.ServerError &&
return this is Failure.ServerError && error.code == MatrixError.M_WEAK_PASSWORD error.code == MatrixError.M_WEAK_PASSWORD
}
fun Throwable.isLoginEmailUnknown(): Boolean { fun Throwable.isLoginEmailUnknown() = this is Failure.ServerError &&
return this is Failure.ServerError && error.code == MatrixError.M_FORBIDDEN &&
error.code == MatrixError.M_FORBIDDEN && error.message.isEmpty()
error.message.isEmpty()
}
fun Throwable.isInvalidUIAAuth(): Boolean { fun Throwable.isInvalidUIAAuth() = this is Failure.ServerError &&
return this is Failure.ServerError && error.code == MatrixError.M_FORBIDDEN &&
error.code == MatrixError.M_FORBIDDEN && error.flows != null
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 * 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 { fun Throwable.isRegistrationAvailabilityError() = this is Failure.ServerError &&
return this is Failure.ServerError && httpCode == HttpsURLConnection.HTTP_BAD_REQUEST && /* 400 */
httpCode == HttpsURLConnection.HTTP_BAD_REQUEST && /* 400 */ (error.code == MatrixError.M_USER_IN_USE ||
(error.code == MatrixError.M_USER_IN_USE || error.code == MatrixError.M_INVALID_USERNAME ||
error.code == MatrixError.M_INVALID_USERNAME || error.code == MatrixError.M_EXCLUSIVE)
error.code == MatrixError.M_EXCLUSIVE)
}
/** /**
* Try to convert to a ScanFailure. Return null in the cases it's not possible * 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.ReAuthHelper
import im.vector.app.features.login.ServerType import im.vector.app.features.login.ServerType
import im.vector.app.features.login.SignMode import im.vector.app.features.login.SignMode
import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -140,14 +141,14 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.UpdateServerType -> handleUpdateServerType(action) is OnboardingAction.UpdateServerType -> handleUpdateServerType(action)
is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action) is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action)
is OnboardingAction.InitWith -> handleInitWith(action) is OnboardingAction.InitWith -> handleInitWith(action)
is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action.homeServerUrl) } is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action) }
is OnboardingAction.LoginOrRegister -> handleLoginOrRegister(action).also { lastAction = action } is OnboardingAction.LoginOrRegister -> handleLoginOrRegister(action).also { lastAction = action }
is OnboardingAction.Register -> handleRegisterWith(action).also { lastAction = action } is OnboardingAction.Register -> handleRegisterWith(action).also { lastAction = action }
is OnboardingAction.LoginWithToken -> handleLoginWithToken(action) is OnboardingAction.LoginWithToken -> handleLoginWithToken(action)
is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action) is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action)
is OnboardingAction.ResetPassword -> handleResetPassword(action) is OnboardingAction.ResetPassword -> handleResetPassword(action)
is OnboardingAction.ResetPasswordMailConfirmed -> handleResetPasswordMailConfirmed() is OnboardingAction.ResetPasswordMailConfirmed -> handleResetPasswordMailConfirmed()
is OnboardingAction.PostRegisterAction -> handleRegisterAction(action.registerAction) is OnboardingAction.PostRegisterAction -> handleRegisterAction(action.registerAction, ::emitFlowResultViewEvent)
is OnboardingAction.ResetAction -> handleResetAction(action) is OnboardingAction.ResetAction -> handleResetAction(action)
is OnboardingAction.UserAcceptCertificate -> handleUserAcceptCertificate(action) is OnboardingAction.UserAcceptCertificate -> handleUserAcceptCertificate(action)
OnboardingAction.ClearHomeServerHistory -> handleClearHomeServerHistory() OnboardingAction.ClearHomeServerHistory -> handleClearHomeServerHistory()
@ -175,7 +176,7 @@ class OnboardingViewModel @AssistedInject constructor(
return when (val config = loginConfig.toHomeserverConfig()) { return when (val config = loginConfig.toHomeserverConfig()) {
null -> continueToPageAfterSplash(onboardingFlow) 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 -> { is OnboardingAction.HomeServerChange.SelectHomeServer -> {
currentHomeServerConnectionConfig currentHomeServerConnectionConfig
?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) } ?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) }
?.let { startAuthenticationFlow(it) } ?.let { startAuthenticationFlow(finalLastAction, it) }
} }
is OnboardingAction.LoginOrRegister -> is OnboardingAction.LoginOrRegister ->
handleDirectLogin( handleDirectLogin(
finalLastAction, finalLastAction,
HomeServerConnectionConfig.Builder() HomeServerConnectionConfig.Builder()
@ -220,7 +221,7 @@ class OnboardingViewModel @AssistedInject constructor(
.withAllowedFingerPrints(listOf(action.fingerprint)) .withAllowedFingerPrints(listOf(action.fingerprint))
.build() .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 { currentJob = viewModelScope.launch {
if (action.hasLoadingState()) { if (action.hasLoadingState()) {
setState { copy(isLoading = true) } setState { copy(isLoading = true) }
} }
runCatching { registrationActionHandler.handleRegisterAction(registrationWizard, action) } internalRegisterAction(action, onNextRegistrationStepAction)
.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))
}
}
)
setState { copy(isLoading = false) } 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) { private fun handleRegisterWith(action: OnboardingAction.Register) {
reAuthHelper.data = action.password reAuthHelper.data = action.password
handleRegisterAction(RegisterAction.CreateAccount( handleRegisterAction(
action.username, RegisterAction.CreateAccount(
action.password, action.username,
action.initialDeviceName action.password,
)) action.initialDeviceName
),
::emitFlowResultViewEvent
)
} }
private fun handleResetAction(action: OnboardingAction.ResetAction) { private fun handleResetAction(action: OnboardingAction.ResetAction) {
@ -337,20 +349,19 @@ class OnboardingViewModel @AssistedInject constructor(
} }
private fun handleUpdateSignMode(action: OnboardingAction.UpdateSignMode) { private fun handleUpdateSignMode(action: OnboardingAction.UpdateSignMode) {
setState { updateSignMode(action.signMode)
copy(
signMode = action.signMode
)
}
when (action.signMode) { when (action.signMode) {
SignMode.SignUp -> handleRegisterAction(RegisterAction.StartRegistration) SignMode.SignUp -> handleRegisterAction(RegisterAction.StartRegistration, ::emitFlowResultViewEvent)
SignMode.SignIn -> startAuthenticationFlow() SignMode.SignIn -> startAuthenticationFlow()
SignMode.SignInWithMatrixId -> _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignInWithMatrixId)) SignMode.SignInWithMatrixId -> _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignInWithMatrixId))
SignMode.Unknown -> Unit SignMode.Unknown -> Unit
} }
} }
private fun updateSignMode(signMode: SignMode) {
setState { copy(signMode = signMode) }
}
private fun handleUpdateUseCase(action: OnboardingAction.UpdateUseCase) { private fun handleUpdateUseCase(action: OnboardingAction.UpdateUseCase) {
setState { copy(useCase = action.useCase) } setState { copy(useCase = action.useCase) }
when (vectorFeatures.isOnboardingCombinedRegisterEnabled()) { when (vectorFeatures.isOnboardingCombinedRegisterEnabled()) {
@ -509,18 +520,17 @@ class OnboardingViewModel @AssistedInject constructor(
_viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignIn)) _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 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 }) { if (isRegistrationStarted && flowResult.missingStages.any { it is Stage.Dummy && it.mandatory }) {
handleRegisterDummy() handleRegisterDummy(onNextRegistrationStepAction)
} else { } else {
// Notify the user onNextRegistrationStepAction(flowResult)
_viewEvents.post(OnboardingViewEvents.RegistrationFlowResult(flowResult, isRegistrationStarted))
} }
} }
private fun handleRegisterDummy() { private suspend fun handleRegisterDummy(onNextRegistrationStepAction: (FlowResult) -> Unit) {
handleRegisterAction(RegisterAction.RegisterDummy) internalRegisterAction(RegisterAction.RegisterDummy, onNextRegistrationStepAction)
} }
private suspend fun onSessionCreated(session: Session, isAccountCreated: Boolean) { private suspend fun onSessionCreated(session: Session, isAccountCreated: Boolean) {
@ -581,42 +591,87 @@ class OnboardingViewModel @AssistedInject constructor(
} }
} }
private fun handleHomeserverChange(homeserverUrl: String) { private fun handleHomeserverChange(action: OnboardingAction.HomeServerChange) {
val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(homeserverUrl) val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl)
if (homeServerConnectionConfig == null) { if (homeServerConnectionConfig == null) {
// This is invalid // This is invalid
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) _viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig")))
} else { } else {
startAuthenticationFlow(homeServerConnectionConfig) startAuthenticationFlow(action, homeServerConnectionConfig)
} }
} }
private fun startAuthenticationFlow(homeServerConnectionConfig: HomeServerConnectionConfig, serverTypeOverride: ServerType? = null) { private fun startAuthenticationFlow(
trigger: OnboardingAction?,
homeServerConnectionConfig: HomeServerConnectionConfig,
serverTypeOverride: ServerType? = null
) {
currentHomeServerConnectionConfig = homeServerConnectionConfig currentHomeServerConnectionConfig = homeServerConnectionConfig
currentJob = viewModelScope.launch { currentJob = viewModelScope.launch {
setState { copy(isLoading = true) } setState { copy(isLoading = true) }
runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold( runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold(
onSuccess = { onSuccess = { onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride) },
rememberHomeServer(homeServerConnectionConfig.homeServerUri.toString()) onFailure = { _viewEvents.post(OnboardingViewEvents.Failure(it)) }
if (it.isHomeserverOutdated) { )
_viewEvents.post(OnboardingViewEvents.OutdatedHomeserver) setState { copy(isLoading = false) }
} }
}
setState { private suspend fun onAuthenticationStartedSuccess(
copy( trigger: OnboardingAction?,
serverType = alignServerTypeAfterSubmission(homeServerConnectionConfig, serverTypeOverride), config: HomeServerConnectionConfig,
selectedHomeserver = it.selectedHomeserver, authResult: StartAuthenticationResult,
isLoading = false, serverTypeOverride: ServerType?
) ) {
} rememberHomeServer(config.homeServerUri.toString())
onAuthenticationStartedSuccess() if (authResult.isHomeserverOutdated) {
}, _viewEvents.post(OnboardingViewEvents.OutdatedHomeserver)
onFailure = { }
setState { copy(isLoading = false) }
_viewEvents.post(OnboardingViewEvents.Failure(it)) 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? { fun getInitialHomeServerUrl(): String? {
return loginConfig?.homeServerUrl return loginConfig?.homeServerUrl
} }

View file

@ -21,6 +21,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import androidx.lifecycle.lifecycleScope
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.content import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.editText import im.vector.app.core.extensions.editText
@ -33,6 +34,10 @@ import im.vector.app.databinding.FragmentFtueServerSelectionCombinedBinding
import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.failure.isHomeserverUnavailable
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject import javax.inject.Inject
class FtueAuthCombinedServerSelectionFragment @Inject constructor() : AbstractFtueAuthFragment<FragmentFtueServerSelectionCombinedBinding>() { 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.chooseServerGetInTouch.debouncedClicks { openUrlInExternalBrowser(requireContext(), getString(R.string.ftue_ems_url)) }
views.chooseServerSubmit.debouncedClicks { updateServerUrl() } views.chooseServerSubmit.debouncedClicks { updateServerUrl() }
views.chooseServerInput.editText().textChanges()
.onEach { views.chooseServerInput.error = null }
.launchIn(lifecycleScope)
} }
private fun updateServerUrl() { 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://")) private fun String.toReducedUrlKeepingSchemaIfInsecure() = toReducedUrl(keepSchema = this.startsWith("http://"))
} }

View file

@ -106,7 +106,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:imeOptions="actionDone" android:imeOptions="actionDone"
android:inputType="text" android:inputType="textUri"
android:maxLines="1" /> android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>

View file

@ -73,7 +73,6 @@ class OnboardingViewModelTest {
private val fakeUri = FakeUri() private val fakeUri = FakeUri()
private val fakeContext = FakeContext() private val fakeContext = FakeContext()
private val initialState = OnboardingViewState()
private val fakeSession = FakeSession() private val fakeSession = FakeSession()
private val fakeUriFilenameResolver = FakeUriFilenameResolver() private val fakeUriFilenameResolver = FakeUriFilenameResolver()
private val fakeActiveSessionHolder = FakeActiveSessionHolder(fakeSession) private val fakeActiveSessionHolder = FakeActiveSessionHolder(fakeSession)
@ -85,11 +84,12 @@ class OnboardingViewModelTest {
private val fakeStartAuthenticationFlowUseCase = FakeStartAuthenticationFlowUseCase() private val fakeStartAuthenticationFlowUseCase = FakeStartAuthenticationFlowUseCase()
private val fakeHomeServerHistoryService = FakeHomeServerHistoryService() private val fakeHomeServerHistoryService = FakeHomeServerHistoryService()
lateinit var viewModel: OnboardingViewModel private var initialState = OnboardingViewState()
private lateinit var viewModel: OnboardingViewModel
@Before @Before
fun setUp() { fun setUp() {
viewModel = createViewModel() viewModelWith(initialState)
} }
@Test @Test
@ -105,8 +105,7 @@ class OnboardingViewModelTest {
@Test @Test
fun `given supports changing display name, when handling PersonalizeProfile, then emits contents choose display name`() = runTest { 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)) viewModelWith(initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = true, supportsChangingProfilePicture = false)))
viewModel = createViewModel(initialState)
val test = viewModel.test() val test = viewModel.test()
viewModel.handle(OnboardingAction.PersonalizeProfile) viewModel.handle(OnboardingAction.PersonalizeProfile)
@ -118,8 +117,7 @@ class OnboardingViewModelTest {
@Test @Test
fun `given only supports changing profile picture, when handling PersonalizeProfile, then emits contents choose profile picture`() = runTest { 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)) viewModelWith(initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = false, supportsChangingProfilePicture = true)))
viewModel = createViewModel(initialState)
val test = viewModel.test() val test = viewModel.test()
viewModel.handle(OnboardingAction.PersonalizeProfile) viewModel.handle(OnboardingAction.PersonalizeProfile)
@ -131,8 +129,7 @@ class OnboardingViewModelTest {
@Test @Test
fun `given has sign in with matrix id sign mode, when handling login or register action, then logs in directly`() = runTest { 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) viewModelWith(initialState.copy(signMode = SignMode.SignInWithMatrixId))
viewModel = createViewModel(initialState)
fakeDirectLoginUseCase.givenSuccessResult(A_LOGIN_OR_REGISTER_ACTION, config = null, result = fakeSession) fakeDirectLoginUseCase.givenSuccessResult(A_LOGIN_OR_REGISTER_ACTION, config = null, result = fakeSession)
givenInitialisesSession(fakeSession) givenInitialisesSession(fakeSession)
val test = viewModel.test() val test = viewModel.test()
@ -151,8 +148,7 @@ class OnboardingViewModelTest {
@Test @Test
fun `given has sign in with matrix id sign mode, when handling login or register action fails, then emits error`() = runTest { 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) viewModelWith(initialState.copy(signMode = SignMode.SignInWithMatrixId))
viewModel = createViewModel(initialState)
fakeDirectLoginUseCase.givenFailureResult(A_LOGIN_OR_REGISTER_ACTION, config = null, cause = AN_ERROR) fakeDirectLoginUseCase.givenFailureResult(A_LOGIN_OR_REGISTER_ACTION, config = null, cause = AN_ERROR)
givenInitialisesSession(fakeSession) givenInitialisesSession(fakeSession)
val test = viewModel.test() val test = viewModel.test()
@ -235,11 +231,13 @@ class OnboardingViewModelTest {
} }
@Test @Test
fun `given when editing homeserver, then updates selected homeserver state and emits edited event`() = runTest { fun `given in the sign up flow, when editing homeserver, then updates selected homeserver state and emits edited event`() = runTest {
val test = viewModel.test() viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
fakeHomeServerConnectionConfigFactory.givenConfigFor(A_HOMESERVER_URL, A_HOMESERVER_CONFIG) 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()) fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString())
val test = viewModel.test()
viewModel.handle(OnboardingAction.HomeServerChange.EditHomeServer(A_HOMESERVER_URL)) viewModel.handle(OnboardingAction.HomeServerChange.EditHomeServer(A_HOMESERVER_URL))
@ -247,12 +245,35 @@ class OnboardingViewModelTest {
.assertStatesChanges( .assertStatesChanges(
initialState, initialState,
{ copy(isLoading = true) }, { copy(isLoading = true) },
{ copy(isLoading = false, selectedHomeserver = SELECTED_HOMESERVER_STATE) }, { copy(selectedHomeserver = SELECTED_HOMESERVER_STATE) },
{ copy(isLoading = false) }
) )
.assertEvents(OnboardingViewEvents.OnHomeserverEdited) .assertEvents(OnboardingViewEvents.OnHomeserverEdited)
.finish() .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 @Test
fun `given personalisation enabled, when registering account, then updates state and emits account created event`() = runTest { fun `given personalisation enabled, when registering account, then updates state and emits account created event`() = runTest {
fakeVectorFeatures.givenPersonalisationEnabled() fakeVectorFeatures.givenPersonalisationEnabled()
@ -292,14 +313,13 @@ class OnboardingViewModelTest {
@Test @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 { 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)) viewModelWith(initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = true)))
viewModel = createViewModel(personalisedInitialState)
val test = viewModel.test() val test = viewModel.test()
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME)) viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
test test
.assertStatesChanges(personalisedInitialState, expectedSuccessfulDisplayNameUpdateStates()) .assertStatesChanges(initialState, expectedSuccessfulDisplayNameUpdateStates())
.assertEvents(OnboardingViewEvents.OnChooseProfilePicture) .assertEvents(OnboardingViewEvents.OnChooseProfilePicture)
.finish() .finish()
fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME) fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME)
@ -307,14 +327,13 @@ class OnboardingViewModelTest {
@Test @Test
fun `given changing profile picture is not supported, when updating display name, then updates upstream user display name and completes personalization`() = runTest { 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)) viewModelWith(initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = false)))
viewModel = createViewModel(personalisedInitialState)
val test = viewModel.test() val test = viewModel.test()
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME)) viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
test test
.assertStatesChanges(personalisedInitialState, expectedSuccessfulDisplayNameUpdateStates()) .assertStatesChanges(initialState, expectedSuccessfulDisplayNameUpdateStates())
.assertEvents(OnboardingViewEvents.OnPersonalizationComplete) .assertEvents(OnboardingViewEvents.OnPersonalizationComplete)
.finish() .finish()
fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME) fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME)
@ -354,14 +373,13 @@ class OnboardingViewModelTest {
@Test @Test
fun `given a selected picture, when handling save selected profile picture, then updates upstream avatar and completes personalization`() = runTest { 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) viewModelWith(givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME))
viewModel = createViewModel(initialStateWithPicture)
val test = viewModel.test() val test = viewModel.test()
viewModel.handle(OnboardingAction.SaveSelectedProfilePicture) viewModel.handle(OnboardingAction.SaveSelectedProfilePicture)
test test
.assertStates(expectedProfilePictureSuccessStates(initialStateWithPicture)) .assertStates(expectedProfilePictureSuccessStates(initialState))
.assertEvents(OnboardingViewEvents.OnPersonalizationComplete) .assertEvents(OnboardingViewEvents.OnPersonalizationComplete)
.finish() .finish()
fakeSession.fakeProfileService.verifyAvatarUpdated(fakeSession.myUserId, fakeUri.instance, A_PICTURE_FILENAME) fakeSession.fakeProfileService.verifyAvatarUpdated(fakeSession.myUserId, fakeUri.instance, A_PICTURE_FILENAME)
@ -370,14 +388,13 @@ class OnboardingViewModelTest {
@Test @Test
fun `given upstream update avatar fails, when saving selected profile picture, then emits failure event`() = runTest { fun `given upstream update avatar fails, when saving selected profile picture, then emits failure event`() = runTest {
fakeSession.fakeProfileService.givenUpdateAvatarErrors(AN_ERROR) fakeSession.fakeProfileService.givenUpdateAvatarErrors(AN_ERROR)
val initialStateWithPicture = givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME) viewModelWith(givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME))
viewModel = createViewModel(initialStateWithPicture)
val test = viewModel.test() val test = viewModel.test()
viewModel.handle(OnboardingAction.SaveSelectedProfilePicture) viewModel.handle(OnboardingAction.SaveSelectedProfilePicture)
test test
.assertStates(expectedProfilePictureFailureStates(initialStateWithPicture)) .assertStates(expectedProfilePictureFailureStates(initialState))
.assertEvents(OnboardingViewEvents.Failure(AN_ERROR)) .assertEvents(OnboardingViewEvents.Failure(AN_ERROR))
.finish() .finish()
} }
@ -406,8 +423,8 @@ class OnboardingViewModelTest {
.finish() .finish()
} }
private fun createViewModel(state: OnboardingViewState = initialState): OnboardingViewModel { private fun viewModelWith(state: OnboardingViewState) {
return OnboardingViewModel( OnboardingViewModel(
state, state,
fakeContext.instance, fakeContext.instance,
fakeAuthenticationService, fakeAuthenticationService,
@ -423,7 +440,10 @@ class OnboardingViewModelTest {
fakeDirectLoginUseCase.instance, fakeDirectLoginUseCase.instance,
fakeStartAuthenticationFlowUseCase.instance, fakeStartAuthenticationFlowUseCase.instance,
FakeVectorOverrides() FakeVectorOverrides()
) ).also {
viewModel = it
initialState = state
}
} }
private fun givenPictureSelected(fileUri: Uri, filename: String): OnboardingViewState { private fun givenPictureSelected(fileUri: Uri, filename: String): OnboardingViewState {
@ -481,6 +501,12 @@ class OnboardingViewModelTest {
fakeAuthenticationService.givenRegistrationWizard(registrationWizard) fakeAuthenticationService.givenRegistrationWizard(registrationWizard)
fakeRegisterActionHandler.givenResultsFor(registrationWizard, results) 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( private fun HomeServerCapabilities.toPersonalisationState() = PersonalizationState(

View file

@ -33,4 +33,8 @@ class FakeRegisterActionHandler {
result.first { it.first == actionArg }.second result.first { it.first == actionArg }.second
} }
} }
fun givenThrowsFor(wizard: RegistrationWizard, action: RegisterAction, cause: Throwable) {
coEvery { instance.handleRegisterAction(wizard, action) } throws cause
}
} }