Merge pull request #6271 from vector-im/feature/adm/full-matrix-id-homeserver-switching

[FTUE] Switch homeserver on full matrix id entry
This commit is contained in:
Adam Brown 2022-06-16 14:23:12 +01:00 committed by GitHub
commit d37b273eee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 351 additions and 14 deletions

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

@ -0,0 +1 @@
FTUE - Adds automatic homeserver selection when typing a full matrix id during registration or login

View file

@ -0,0 +1,40 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
class MatrixPatternsTest {
@Test
fun `given user id cases, when checking isUserId, then returns expected`() {
val cases = listOf(
UserIdCase("foobar", isUserId = false),
UserIdCase("@foobar", isUserId = false),
UserIdCase("foobar@matrix.org", isUserId = false),
UserIdCase("@foobar: matrix.org", isUserId = false),
UserIdCase("@foobar:matrix.org", isUserId = true),
)
cases.forEach { (input, expected) ->
MatrixPatterns.isUserId(input) shouldBeEqualTo expected
}
}
}
private data class UserIdCase(val input: String, val isUserId: Boolean)

View file

@ -57,3 +57,14 @@ fun TextInputLayout.setOnImeDoneListener(action: () -> Unit) {
}
}
}
fun TextInputLayout.setOnFocusLostListener(action: () -> Unit) {
editText().setOnFocusChangeListener { _, hasFocus ->
when (hasFocus) {
false -> action()
else -> {
// do nothing
}
}
}
}

View file

@ -50,6 +50,7 @@ sealed interface OnboardingAction : VectorViewModelAction {
data class ResetPassword(val email: String, val newPassword: String) : OnboardingAction
object ResetPasswordMailConfirmed : OnboardingAction
data class MaybeUpdateHomeserverFromMatrixId(val userId: String) : OnboardingAction
sealed interface AuthenticateAction : OnboardingAction {
data class Register(val username: String, val password: String, val initialDeviceName: String) : AuthenticateAction
data class Login(val username: String, val password: String, val initialDeviceName: String) : AuthenticateAction

View file

@ -50,6 +50,8 @@ import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAut
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
@ -142,6 +144,7 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action)
is OnboardingAction.InitWith -> handleInitWith(action)
is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action) }
is OnboardingAction.MaybeUpdateHomeserverFromMatrixId -> handleMaybeUpdateHomeserver(action)
is AuthenticateAction -> withAction(action) { handleAuthenticateAction(action) }
is OnboardingAction.LoginWithToken -> handleLoginWithToken(action)
is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action)
@ -162,6 +165,16 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
private fun handleMaybeUpdateHomeserver(action: OnboardingAction.MaybeUpdateHomeserverFromMatrixId) {
val isFullMatrixId = MatrixPatterns.isUserId(action.userId)
if (isFullMatrixId) {
val domain = action.userId.getServerName().substringBeforeLast(":").ensureProtocol()
handleHomeserverChange(OnboardingAction.HomeServerChange.EditHomeServer(domain))
} else {
// ignore the action
}
}
private fun withAction(action: OnboardingAction, block: (OnboardingAction) -> Unit) {
lastAction = action
block(action)

View file

@ -30,6 +30,7 @@ import im.vector.app.core.extensions.editText
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.hidePassword
import im.vector.app.core.extensions.realignPercentagesToParent
import im.vector.app.core.extensions.setOnFocusLostListener
import im.vector.app.core.extensions.setOnImeDoneListener
import im.vector.app.core.extensions.toReducedUrl
import im.vector.app.databinding.FragmentFtueCombinedLoginBinding
@ -59,6 +60,7 @@ class FtueAuthCombinedLoginFragment @Inject constructor(
views.loginRoot.realignPercentagesToParent()
views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) }
views.loginPasswordInput.setOnImeDoneListener { submit() }
views.loginInput.setOnFocusLostListener { viewModel.handle(OnboardingAction.MaybeUpdateHomeserverFromMatrixId(views.loginInput.content())) }
}
private fun setupSubmitButton() {

View file

@ -34,6 +34,7 @@ import im.vector.app.core.extensions.hasSurroundingSpaces
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.hidePassword
import im.vector.app.core.extensions.realignPercentagesToParent
import im.vector.app.core.extensions.setOnFocusLostListener
import im.vector.app.core.extensions.setOnImeDoneListener
import im.vector.app.core.extensions.toReducedUrl
import im.vector.app.databinding.FragmentFtueCombinedRegisterBinding
@ -47,6 +48,7 @@ import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.launchIn
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
import org.matrix.android.sdk.api.failure.isHomeserverUnavailable
import org.matrix.android.sdk.api.failure.isInvalidPassword
import org.matrix.android.sdk.api.failure.isInvalidUsername
import org.matrix.android.sdk.api.failure.isLoginEmailUnknown
@ -67,6 +69,9 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
views.createAccountRoot.realignPercentagesToParent()
views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) }
views.createAccountPasswordInput.setOnImeDoneListener { submit() }
views.createAccountInput.setOnFocusLostListener {
viewModel.handle(OnboardingAction.MaybeUpdateHomeserverFromMatrixId(views.createAccountInput.content()))
}
}
private fun setupSubmitButton() {
@ -129,6 +134,9 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
throwable.isWeakPassword() || throwable.isInvalidPassword() -> {
views.createAccountPasswordInput.error = errorFormatter.toHumanReadable(throwable)
}
throwable.isHomeserverUnavailable() -> {
views.createAccountInput.error = getString(R.string.login_error_homeserver_not_found)
}
throwable.isRegistrationDisabled() -> {
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.dialog_title_error)

View file

@ -59,6 +59,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull
private const val FRAGMENT_REGISTRATION_STAGE_TAG = "FRAGMENT_REGISTRATION_STAGE_TAG"
private const val FRAGMENT_LOGIN_TAG = "FRAGMENT_LOGIN_TAG"
private const val FRAGMENT_EDIT_HOMESERVER_TAG = "FRAGMENT_EDIT_HOMESERVER"
class FtueAuthVariant(
private val views: ActivityLoginBinding,
@ -220,10 +221,14 @@ class FtueAuthVariant(
activity.addFragmentToBackstack(
views.loginFragmentContainer,
FtueAuthCombinedServerSelectionFragment::class.java,
option = commonOption
option = commonOption,
tag = FRAGMENT_EDIT_HOMESERVER_TAG
)
}
OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack()
OnboardingViewEvents.OnHomeserverEdited -> supportFragmentManager.popBackStack(
FRAGMENT_EDIT_HOMESERVER_TAG,
FragmentManager.POP_BACK_STACK_INCLUSIVE
)
OnboardingViewEvents.OpenCombinedLogin -> onStartCombinedLogin()
is OnboardingViewEvents.DeeplinkAuthenticationFailure -> onDeeplinkedHomeserverUnavailable(viewEvents)
OnboardingViewEvents.DisplayRegistrationFallback -> displayFallbackWebDialog()

View file

@ -20,6 +20,7 @@ import im.vector.app.R
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.onboarding.ftueauth.LoginErrorParser.LoginErrorResult
import org.matrix.android.sdk.api.failure.isHomeserverUnavailable
import org.matrix.android.sdk.api.failure.isInvalidPassword
import org.matrix.android.sdk.api.failure.isInvalidUsername
import org.matrix.android.sdk.api.failure.isLoginEmailUnknown
@ -40,6 +41,9 @@ class LoginErrorParser @Inject constructor(
throwable.isInvalidPassword() && password.hasSurroundingSpaces() -> {
LoginErrorResult(throwable, passwordError = stringProvider.getString(R.string.auth_invalid_login_param_space_in_password))
}
throwable.isHomeserverUnavailable() -> {
LoginErrorResult(throwable, usernameOrIdError = stringProvider.getString(R.string.login_error_homeserver_not_found))
}
else -> {
LoginErrorResult(throwable)
}

View file

@ -150,7 +150,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:imeOptions="actionNext"
android:inputType="text"
android:inputType="textNoSuggestions"
android:maxLines="1"
android:nextFocusForward="@id/loginPasswordInput" />

View file

@ -174,7 +174,7 @@
android:layout_height="match_parent"
android:imeOptions="actionNext"
android:nextFocusForward="@id/createAccountPasswordInput"
android:inputType="text"
android:inputType="textNoSuggestions"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>

View file

@ -271,10 +271,7 @@ class OnboardingViewModelTest {
@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(isHomeserverOutdated = false, SELECTED_HOMESERVER_STATE))
givenRegistrationResultFor(RegisterAction.StartRegistration, ANY_CONTINUING_REGISTRATION_RESULT)
fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString())
givenCanSuccessfullyUpdateHomeserver(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE)
val test = viewModel.test()
viewModel.handle(OnboardingAction.HomeServerChange.EditHomeServer(A_HOMESERVER_URL))
@ -291,13 +288,45 @@ class OnboardingViewModelTest {
.finish()
}
@Test
fun `given a full matrix id, when maybe updating homeserver, then updates selected homeserver state and emits edited event`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
givenCanSuccessfullyUpdateHomeserver(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE)
val test = viewModel.test()
val fullMatrixId = "@a-user:${A_HOMESERVER_URL.removePrefix("https://")}"
viewModel.handle(OnboardingAction.MaybeUpdateHomeserverFromMatrixId(fullMatrixId))
test
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(selectedHomeserver = SELECTED_HOMESERVER_STATE) },
{ copy(isLoading = false) }
)
.assertEvents(OnboardingViewEvents.OnHomeserverEdited)
.finish()
}
@Test
fun `given a username, when maybe updating homeserver, then does nothing`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
val test = viewModel.test()
val onlyUsername = "a-username"
viewModel.handle(OnboardingAction.MaybeUpdateHomeserverFromMatrixId(onlyUsername))
test
.assertStates(initialState)
.assertNoEvents()
.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())
givenUpdatingHomeserverErrors(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE, AN_ERROR)
val test = viewModel.test()
viewModel.handle(OnboardingAction.HomeServerChange.EditHomeServer(A_HOMESERVER_URL))
@ -552,8 +581,18 @@ class OnboardingViewModelTest {
fakeRegistrationActionHandler.givenResultsFor(results)
}
private fun givenRegistrationActionErrors(action: RegisterAction, cause: Throwable) {
fakeRegistrationActionHandler.givenThrows(action, cause)
private fun givenCanSuccessfullyUpdateHomeserver(homeserverUrl: String, resultingState: SelectedHomeserverState) {
fakeHomeServerConnectionConfigFactory.givenConfigFor(homeserverUrl, A_HOMESERVER_CONFIG)
fakeStartAuthenticationFlowUseCase.givenResult(A_HOMESERVER_CONFIG, StartAuthenticationResult(isHomeserverOutdated = false, resultingState))
givenRegistrationResultFor(RegisterAction.StartRegistration, ANY_CONTINUING_REGISTRATION_RESULT)
fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString())
}
private fun givenUpdatingHomeserverErrors(homeserverUrl: String, resultingState: SelectedHomeserverState, error: Throwable) {
fakeHomeServerConnectionConfigFactory.givenConfigFor(homeserverUrl, A_HOMESERVER_CONFIG)
fakeStartAuthenticationFlowUseCase.givenResult(A_HOMESERVER_CONFIG, StartAuthenticationResult(isHomeserverOutdated = false, resultingState))
givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationActionHandler.Result.Error(error))
fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString())
}
}

View file

@ -0,0 +1,174 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.onboarding.ftueauth
import im.vector.app.R
import im.vector.app.test.fakes.FakeErrorFormatter
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.toTestString
import im.vector.app.test.fixtures.aHomeserverUnavailableError
import im.vector.app.test.fixtures.aLoginEmailUnknownError
import im.vector.app.test.fixtures.anInvalidPasswordError
import im.vector.app.test.fixtures.anInvalidUserNameError
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private const val A_VALID_PASSWORD = "11111111"
private const val A_FORMATTED_ERROR_MESSAGE = "error message"
private const val ANOTHER_FORMATTED_ERROR_MESSAGE = "error message 2"
private val AN_ERROR = RuntimeException()
class LoginErrorParserTest {
private val fakeErrorFormatter = FakeErrorFormatter()
private val fakeStringProvider = FakeStringProvider()
private val loginErrorParser = LoginErrorParser(fakeErrorFormatter, fakeStringProvider.instance)
@Test
fun `given a generic error, when parsing, then has null username and password errors`() {
val cause = RuntimeException()
val result = loginErrorParser.parse(throwable = cause, password = A_VALID_PASSWORD)
result shouldBeEqualTo LoginErrorParser.LoginErrorResult(cause, usernameOrIdError = null, passwordError = null)
}
@Test
fun `given an invalid username error, when parsing, then has username error`() {
val cause = anInvalidUserNameError()
fakeErrorFormatter.given(cause, formatsTo = A_FORMATTED_ERROR_MESSAGE)
val result = loginErrorParser.parse(throwable = cause, password = A_VALID_PASSWORD)
result shouldBeEqualTo LoginErrorParser.LoginErrorResult(
cause,
usernameOrIdError = A_FORMATTED_ERROR_MESSAGE,
passwordError = null
)
}
@Test
fun `given a homeserver unavailable error, when parsing, then has username error`() {
val cause = aHomeserverUnavailableError()
val result = loginErrorParser.parse(throwable = cause, password = A_VALID_PASSWORD)
result shouldBeEqualTo LoginErrorParser.LoginErrorResult(
cause,
usernameOrIdError = R.string.login_error_homeserver_not_found.toTestString(),
passwordError = null
)
}
@Test
fun `given a login email unknown error, when parsing, then has username error`() {
val cause = aLoginEmailUnknownError()
val result = loginErrorParser.parse(throwable = cause, password = A_VALID_PASSWORD)
result shouldBeEqualTo LoginErrorParser.LoginErrorResult(
cause,
usernameOrIdError = R.string.login_login_with_email_error.toTestString(),
passwordError = null
)
}
@Test
fun `given a password with surrounding spaces and an invalid password error, when parsing, then has password error`() {
val cause = anInvalidPasswordError()
val result = loginErrorParser.parse(throwable = cause, password = " $A_VALID_PASSWORD ")
result shouldBeEqualTo LoginErrorParser.LoginErrorResult(
cause,
usernameOrIdError = null,
passwordError = R.string.auth_invalid_login_param_space_in_password.toTestString()
)
}
@Test
fun `given an error result with no known errors, then is unknown`() {
val errorResult = LoginErrorParser.LoginErrorResult(AN_ERROR, usernameOrIdError = null, passwordError = null)
val captures = Captures(expectUnknownError = true)
errorResult.callOnMethods(captures)
captures.unknownResult shouldBeEqualTo AN_ERROR
}
@Test
fun `given an error result with only username error, then is username or id error`() {
val errorResult = LoginErrorParser.LoginErrorResult(AN_ERROR, usernameOrIdError = A_FORMATTED_ERROR_MESSAGE, passwordError = null)
val captures = Captures(expectUsernameOrIdError = true)
errorResult.callOnMethods(captures)
captures.usernameOrIdError shouldBeEqualTo A_FORMATTED_ERROR_MESSAGE
}
@Test
fun `given an error result with only password error, then is password error`() {
val errorResult = LoginErrorParser.LoginErrorResult(AN_ERROR, usernameOrIdError = null, passwordError = A_FORMATTED_ERROR_MESSAGE)
val captures = Captures(expectPasswordError = true)
errorResult.callOnMethods(captures)
captures.passwordError shouldBeEqualTo A_FORMATTED_ERROR_MESSAGE
}
@Test
fun `given an error result with username and password error, then triggers both username and password error`() {
val errorResult = LoginErrorParser.LoginErrorResult(
AN_ERROR,
usernameOrIdError = A_FORMATTED_ERROR_MESSAGE,
passwordError = ANOTHER_FORMATTED_ERROR_MESSAGE
)
val captures = Captures(expectPasswordError = true, expectUsernameOrIdError = true)
errorResult.callOnMethods(captures)
captures.usernameOrIdError shouldBeEqualTo A_FORMATTED_ERROR_MESSAGE
captures.passwordError shouldBeEqualTo ANOTHER_FORMATTED_ERROR_MESSAGE
}
}
private fun LoginErrorParser.LoginErrorResult.callOnMethods(captures: Captures) {
onUnknown(captures.onUnknown)
onUsernameOrIdError(captures.onUsernameOrIdError)
onPasswordError(captures.onPasswordError)
}
private class Captures(
val expectUnknownError: Boolean = false,
val expectUsernameOrIdError: Boolean = false,
val expectPasswordError: Boolean = false,
) {
var unknownResult: Throwable? = null
var usernameOrIdError: String? = null
var passwordError: String? = null
val onUnknown: (Throwable) -> Unit = {
if (expectUnknownError) unknownResult = it else throw IllegalStateException("Not expected to be called")
}
val onUsernameOrIdError: (String) -> Unit = {
if (expectUsernameOrIdError) usernameOrIdError = it else throw IllegalStateException("Not expected to be called")
}
val onPasswordError: (String) -> Unit = {
if (expectPasswordError) passwordError = it else throw IllegalStateException("Not expected to be called")
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import im.vector.app.core.error.ErrorFormatter
import io.mockk.every
import io.mockk.mockk
class FakeErrorFormatter : ErrorFormatter by mockk() {
fun given(cause: Throwable, formatsTo: String) {
every { toHumanReadable(cause) } returns formatsTo
}
}

View file

@ -25,4 +25,16 @@ fun a401ServerError() = Failure.ServerError(
MatrixError(MatrixError.M_UNAUTHORIZED, ""), HttpsURLConnection.HTTP_UNAUTHORIZED
)
fun anInvalidUserNameError() = Failure.ServerError(
MatrixError(MatrixError.M_INVALID_USERNAME, ""), HttpsURLConnection.HTTP_BAD_REQUEST
)
fun anInvalidPasswordError() = Failure.ServerError(
MatrixError(MatrixError.M_FORBIDDEN, "Invalid password"), HttpsURLConnection.HTTP_FORBIDDEN
)
fun aLoginEmailUnknownError() = Failure.ServerError(
MatrixError(MatrixError.M_FORBIDDEN, ""), HttpsURLConnection.HTTP_FORBIDDEN
)
fun aHomeserverUnavailableError() = Failure.NetworkConnection(UnknownHostException())