Merge pull request #5691 from vector-im/feature/adm/ftue-server-selection

FTUE - Server selection
This commit is contained in:
Adam Brown 2022-04-08 14:00:12 +01:00 committed by GitHub
commit bd3e98078c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 878 additions and 176 deletions

1
changelog.d/2396.wip Normal file
View File

@ -0,0 +1 @@
Adds a new homeserver selection screen when creating an account

View File

@ -3,4 +3,5 @@
<!-- This file contains url values-->
<string name="threads_learn_more_url" translatable="false">https://element.io/help#threads</string>
<string name="ftue_ems_url">https://element.io/ems</string>
</resources>

View File

@ -18,3 +18,5 @@ package im.vector.app.core.extensions
inline fun <reified T> List<T>.nextOrNull(index: Int) = getOrNull(index + 1)
inline fun <reified T> List<T>.prevOrNull(index: Int) = getOrNull(index - 1)
fun <T> List<T>.containsAllItems(vararg items: T) = this.containsAll(items.toList())

View File

@ -19,8 +19,8 @@ package im.vector.app.core.extensions
/**
* Ex: "https://matrix.org/" -> "matrix.org"
*/
fun String?.toReducedUrl(): String {
fun String?.toReducedUrl(keepSchema: Boolean = false): String {
return (this ?: "")
.substringAfter("://")
.run { if (keepSchema) this else substringAfter("://") }
.trim { it == '/' }
}

View File

@ -29,7 +29,14 @@ sealed interface OnboardingAction : VectorViewModelAction {
data class OnIAlreadyHaveAnAccount(val resetLoginConfig: Boolean, val onboardingFlow: OnboardingFlow) : OnboardingAction
data class UpdateServerType(val serverType: ServerType) : OnboardingAction
data class UpdateHomeServer(val homeServerUrl: String) : OnboardingAction
sealed interface HomeServerChange : OnboardingAction {
val homeServerUrl: String
data class SelectHomeServer(override val homeServerUrl: String) : HomeServerChange
data class EditHomeServer(override val homeServerUrl: String) : HomeServerChange
}
data class UpdateUseCase(val useCase: FtueUseCase) : OnboardingAction
object ResetUseCase : OnboardingAction
data class UpdateSignMode(val signMode: SignMode) : OnboardingAction

View File

@ -37,8 +37,10 @@ sealed class OnboardingViewEvents : VectorViewEvents {
object OpenUseCaseSelection : OnboardingViewEvents()
object OpenServerSelection : OnboardingViewEvents()
object OpenCombinedRegister : OnboardingViewEvents()
object EditServerSelection : OnboardingViewEvents()
data class OnServerSelectionDone(val serverType: ServerType) : OnboardingViewEvents()
object OnLoginFlowRetrieved : OnboardingViewEvents()
object OnHomeserverEdited : OnboardingViewEvents()
data class OnSignModeSelected(val signMode: SignMode) : OnboardingViewEvents()
object OnForgetPasswordClicked : OnboardingViewEvents()
object OnResetPasswordSendThreePidDone : OnboardingViewEvents()

View File

@ -47,7 +47,6 @@ import kotlinx.coroutines.launch
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
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.login.LoginWizard
import org.matrix.android.sdk.api.auth.registration.FlowResult
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
@ -75,6 +74,7 @@ class OnboardingViewModel @AssistedInject constructor(
private val uriFilenameResolver: UriFilenameResolver,
private val registrationActionHandler: RegistrationActionHandler,
private val directLoginUseCase: DirectLoginUseCase,
private val startAuthenticationFlowUseCase: StartAuthenticationFlowUseCase,
private val vectorOverrides: VectorOverrides
) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) {
@ -107,6 +107,7 @@ class OnboardingViewModel @AssistedInject constructor(
private var currentHomeServerConnectionConfig: HomeServerConnectionConfig? = null
private val matrixOrgUrl = stringProvider.getString(R.string.matrix_org_server_url).ensureTrailingSlash()
private val defaultHomeserverUrl = matrixOrgUrl
private val registrationWizard: RegistrationWizard
get() = authenticationService.getRegistrationWizard()
@ -139,7 +140,7 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.UpdateServerType -> handleUpdateServerType(action)
is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action)
is OnboardingAction.InitWith -> handleInitWith(action)
is OnboardingAction.UpdateHomeServer -> handleUpdateHomeserver(action).also { lastAction = action }
is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action.homeServerUrl) }
is OnboardingAction.LoginOrRegister -> handleLoginOrRegister(action).also { lastAction = action }
is OnboardingAction.Register -> handleRegisterWith(action).also { lastAction = action }
is OnboardingAction.LoginWithToken -> handleLoginWithToken(action)
@ -161,25 +162,30 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
private fun withAction(action: OnboardingAction, block: (OnboardingAction) -> Unit) {
lastAction = action
block(action)
}
private fun handleSplashAction(resetConfig: Boolean, onboardingFlow: OnboardingFlow) {
if (resetConfig) {
loginConfig = null
}
setState { copy(onboardingFlow = onboardingFlow) }
val configUrl = loginConfig?.homeServerUrl?.takeIf { it.isNotEmpty() }
if (configUrl != null) {
// Use config from uri
val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(configUrl)
if (homeServerConnectionConfig == null) {
// Url is invalid, in this case, just use the regular flow
Timber.w("Url from config url was invalid: $configUrl")
continueToPageAfterSplash(onboardingFlow)
} else {
getLoginFlow(homeServerConnectionConfig, ServerType.Other)
return when (val config = loginConfig.toHomeserverConfig()) {
null -> continueToPageAfterSplash(onboardingFlow)
else -> startAuthenticationFlow(config, ServerType.Other)
}
}
private fun LoginConfig?.toHomeserverConfig(): HomeServerConnectionConfig? {
return this?.homeServerUrl?.takeIf { it.isNotEmpty() }?.let { url ->
homeServerConnectionConfigFactory.create(url).also {
if (it == null) {
Timber.w("Url from config url was invalid: $url")
}
}
} else {
continueToPageAfterSplash(onboardingFlow)
}
}
@ -200,10 +206,10 @@ class OnboardingViewModel @AssistedInject constructor(
// It happens when we get the login flow, or during direct authentication.
// So alter the homeserver config and retrieve again the login flow
when (val finalLastAction = lastAction) {
is OnboardingAction.UpdateHomeServer -> {
is OnboardingAction.HomeServerChange.SelectHomeServer -> {
currentHomeServerConnectionConfig
?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) }
?.let { getLoginFlow(it) }
?.let { startAuthenticationFlow(it) }
}
is OnboardingAction.LoginOrRegister ->
handleDirectLogin(
@ -291,24 +297,16 @@ class OnboardingViewModel @AssistedInject constructor(
currentJob = null
when (action) {
OnboardingAction.ResetHomeServerType -> {
setState {
copy(
serverType = ServerType.Unknown
)
}
OnboardingAction.ResetHomeServerType -> {
setState { copy(serverType = ServerType.Unknown) }
}
OnboardingAction.ResetHomeServerUrl -> {
OnboardingAction.ResetHomeServerUrl -> {
viewModelScope.launch {
authenticationService.reset()
setState {
copy(
isLoading = false,
homeServerUrlFromUser = null,
homeServerUrl = null,
loginMode = LoginMode.Unknown,
serverType = ServerType.Unknown,
loginModeSupportedTypes = emptyList()
selectedHomeserver = SelectedHomeserverState(),
)
}
}
@ -318,8 +316,6 @@ class OnboardingViewModel @AssistedInject constructor(
copy(
isLoading = false,
signMode = SignMode.Unknown,
loginMode = LoginMode.Unknown,
loginModeSupportedTypes = emptyList()
)
}
}
@ -358,10 +354,7 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleUpdateUseCase(action: OnboardingAction.UpdateUseCase) {
setState { copy(useCase = action.useCase) }
when (vectorFeatures.isOnboardingCombinedRegisterEnabled()) {
true -> {
handle(OnboardingAction.UpdateHomeServer(matrixOrgUrl))
OnboardingViewEvents.OpenCombinedRegister
}
true -> handle(OnboardingAction.HomeServerChange.SelectHomeServer(defaultHomeserverUrl))
false -> _viewEvents.post(OnboardingViewEvents.OpenServerSelection)
}
}
@ -381,7 +374,7 @@ class OnboardingViewModel @AssistedInject constructor(
ServerType.Unknown -> Unit /* Should not happen */
ServerType.MatrixOrg ->
// Request login flow here
handle(OnboardingAction.UpdateHomeServer(matrixOrgUrl))
handle(OnboardingAction.HomeServerChange.SelectHomeServer(matrixOrgUrl))
ServerType.EMS,
ServerType.Other -> _viewEvents.post(OnboardingViewEvents.OnServerSelectionDone(action.serverType))
}
@ -571,7 +564,7 @@ class OnboardingViewModel @AssistedInject constructor(
}
private fun handleWebLoginSuccess(action: OnboardingAction.WebLoginSuccess) = withState { state ->
val homeServerConnectionConfigFinal = homeServerConnectionConfigFactory.create(state.homeServerUrl)
val homeServerConnectionConfigFinal = homeServerConnectionConfigFactory.create(state.selectedHomeserver.upstreamUrl)
if (homeServerConnectionConfigFinal == null) {
// Should not happen
@ -588,93 +581,77 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
private fun handleUpdateHomeserver(action: OnboardingAction.UpdateHomeServer) {
val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl)
private fun handleHomeserverChange(homeserverUrl: String) {
val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(homeserverUrl)
if (homeServerConnectionConfig == null) {
// This is invalid
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig")))
} else {
getLoginFlow(homeServerConnectionConfig)
startAuthenticationFlow(homeServerConnectionConfig)
}
}
private fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig,
serverTypeOverride: ServerType? = null) {
private fun startAuthenticationFlow(homeServerConnectionConfig: HomeServerConnectionConfig, serverTypeOverride: ServerType? = null) {
currentHomeServerConnectionConfig = homeServerConnectionConfig
currentJob = viewModelScope.launch {
authenticationService.cancelPendingLoginOrRegistration()
setState { copy(isLoading = true) }
setState {
copy(
isLoading = true,
// If user has entered https://matrix.org, ensure that server type is ServerType.MatrixOrg
// It is also useful to set the value again in the case of a certificate error on matrix.org
serverType = if (homeServerConnectionConfig.homeServerUri.toString() == matrixOrgUrl) {
ServerType.MatrixOrg
} else {
serverTypeOverride ?: serverType
runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold(
onSuccess = {
rememberHomeServer(homeServerConnectionConfig.homeServerUri.toString())
if (it.isHomeserverOutdated) {
_viewEvents.post(OnboardingViewEvents.OutdatedHomeserver)
}
)
}
val data = try {
authenticationService.getLoginFlow(homeServerConnectionConfig)
} catch (failure: Throwable) {
setState {
copy(
isLoading = false,
// If we were trying to retrieve matrix.org login flow, also reset the serverType
serverType = if (serverType == ServerType.MatrixOrg) ServerType.Unknown else serverType
)
}
_viewEvents.post(OnboardingViewEvents.Failure(failure))
null
}
data ?: return@launch
// Valid Homeserver, add it to the history.
// Note: we add what the user has input, data.homeServerUrlBase can be different
rememberHomeServer(homeServerConnectionConfig.homeServerUri.toString())
val loginMode = when {
// SSO login is taken first
data.supportedLoginTypes.contains(LoginFlowTypes.SSO) &&
data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(data.ssoIdentityProviders)
data.supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(data.ssoIdentityProviders)
data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password
else -> LoginMode.Unsupported
}
setState {
copy(
isLoading = false,
homeServerUrlFromUser = homeServerConnectionConfig.homeServerUri.toString(),
homeServerUrl = data.homeServerUrl,
loginMode = loginMode,
loginModeSupportedTypes = data.supportedLoginTypes.toList()
)
}
if ((loginMode == LoginMode.Password && !data.isLoginAndRegistrationSupported) ||
data.isOutdatedHomeserver) {
// Notify the UI
_viewEvents.post(OnboardingViewEvents.OutdatedHomeserver)
}
withState {
if (loginMode.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)
setState {
copy(
serverType = alignServerTypeAfterSubmission(homeServerConnectionConfig, serverTypeOverride),
selectedHomeserver = it.selectedHomeserver,
isLoading = false,
)
}
onAuthenticationStartedSuccess()
},
onFailure = {
setState { copy(isLoading = false) }
_viewEvents.post(OnboardingViewEvents.Failure(it))
}
)
}
}
/**
* If user has entered https://matrix.org, ensure that server type is ServerType.MatrixOrg
* It is also useful to set the value again in the case of a certificate error on matrix.org
**/
private fun OnboardingViewState.alignServerTypeAfterSubmission(config: HomeServerConnectionConfig, serverTypeOverride: ServerType?): ServerType {
return if (config.homeServerUri.toString() == matrixOrgUrl) {
ServerType.MatrixOrg
} else {
serverTypeOverride ?: serverType
}
}
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)
}
else -> _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved)
}
}
}

View File

@ -40,26 +40,17 @@ data class OnboardingViewState(
val signMode: SignMode = SignMode.Unknown,
@PersistState
val resetPasswordEmail: String? = null,
@PersistState
val homeServerUrlFromUser: String? = null,
// Can be modified after a Wellknown request
@PersistState
val homeServerUrl: String? = null,
// For SSO session recovery
@PersistState
val deviceId: String? = null,
// Network result
@PersistState
val loginMode: LoginMode = LoginMode.Unknown,
// Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable
@PersistState
val loginModeSupportedTypes: List<String> = emptyList(),
val knownCustomHomeServersUrls: List<String> = emptyList(),
val isForceLoginFallbackEnabled: Boolean = false,
@PersistState
val selectedHomeserver: SelectedHomeserverState = SelectedHomeserverState(),
@PersistState
val personalizationState: PersonalizationState = PersonalizationState()
) : MavericksState
@ -70,6 +61,15 @@ enum class OnboardingFlow {
SignInSignUp
}
@Parcelize
data class SelectedHomeserverState(
val description: String? = null,
val userFacingUrl: String? = null,
val upstreamUrl: String? = null,
val preferredLoginMode: LoginMode = LoginMode.Unknown,
val supportedLoginTypes: List<String> = emptyList(),
) : Parcelable
@Parcelize
data class PersonalizationState(
val supportsChangingDisplayName: Boolean = false,

View File

@ -0,0 +1,72 @@
/*
* 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
import im.vector.app.R
import im.vector.app.core.extensions.containsAllItems
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.ensureTrailingSlash
import im.vector.app.features.login.LoginMode
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.LoginFlowResult
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import javax.inject.Inject
class StartAuthenticationFlowUseCase @Inject constructor(
private val authenticationService: AuthenticationService,
private val stringProvider: StringProvider
) {
suspend fun execute(config: HomeServerConnectionConfig): StartAuthenticationResult {
authenticationService.cancelPendingLoginOrRegistration()
val authFlow = authenticationService.getLoginFlow(config)
val preferredLoginMode = authFlow.findPreferredLoginMode()
val selection = createSelectedHomeserver(authFlow, config, preferredLoginMode)
val isOutdated = (preferredLoginMode == LoginMode.Password && !authFlow.isLoginAndRegistrationSupported) || authFlow.isOutdatedHomeserver
return StartAuthenticationResult(isOutdated, selection)
}
private fun createSelectedHomeserver(
authFlow: LoginFlowResult,
config: HomeServerConnectionConfig,
preferredLoginMode: LoginMode
) = SelectedHomeserverState(
description = when (config.homeServerUri.toString()) {
matrixOrgUrl() -> stringProvider.getString(R.string.ftue_auth_create_account_matrix_dot_org_server_description)
else -> null
},
userFacingUrl = config.homeServerUri.toString(),
upstreamUrl = authFlow.homeServerUrl,
preferredLoginMode = preferredLoginMode,
supportedLoginTypes = authFlow.supportedLoginTypes
)
private fun matrixOrgUrl() = stringProvider.getString(R.string.matrix_org_server_url).ensureTrailingSlash()
private fun LoginFlowResult.findPreferredLoginMode() = when {
supportedLoginTypes.containsAllItems(LoginFlowTypes.SSO, LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(ssoIdentityProviders)
supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(ssoIdentityProviders)
supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password
else -> LoginMode.Unsupported
}
data class StartAuthenticationResult(
val isHomeserverOutdated: Boolean,
val selectedHomeserver: SelectedHomeserverState
)
}

View File

@ -37,7 +37,7 @@ abstract class AbstractSSOFtueAuthFragment<VB : ViewBinding> : AbstractFtueAuthF
override fun onStart() {
super.onStart()
val hasSSO = withState(viewModel) { it.loginMode.hasSso() }
val hasSSO = withState(viewModel) { it.selectedHomeserver.preferredLoginMode.hasSso() }
if (hasSSO) {
val packageName = CustomTabsClient.getPackageName(requireContext(), null)
@ -67,7 +67,7 @@ abstract class AbstractSSOFtueAuthFragment<VB : ViewBinding> : AbstractFtueAuthF
override fun onStop() {
super.onStop()
val hasSSO = withState(viewModel) { it.loginMode.hasSso() }
val hasSSO = withState(viewModel) { it.selectedHomeserver.preferredLoginMode.hasSso() }
if (hasSSO) {
customTabsServiceConnection?.let { requireContext().unbindService(it) }
customTabsServiceConnection = null
@ -88,7 +88,7 @@ abstract class AbstractSSOFtueAuthFragment<VB : ViewBinding> : AbstractFtueAuthF
private fun prefetchIfNeeded() {
withState(viewModel) { state ->
if (state.loginMode.hasSso() && state.loginMode.ssoIdentityProviders().isNullOrEmpty()) {
if (state.selectedHomeserver.preferredLoginMode.hasSso() && state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders().isNullOrEmpty()) {
// in this case we can prefetch (not other cases for privacy concerns)
viewModel.getSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,

View File

@ -77,7 +77,7 @@ class FtueAuthCaptchaFragment @Inject constructor(
val mime = "text/html"
val encoding = "utf-8"
val homeServerUrl = state.homeServerUrl ?: error("missing url of homeserver")
val homeServerUrl = state.selectedHomeserver.upstreamUrl ?: error("missing url of homeserver")
views.loginCaptchaWevView.loadDataWithBaseURL(homeServerUrl, html, mime, encoding, null)
views.loginCaptchaWevView.requestLayout()

View File

@ -36,12 +36,13 @@ 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.toReducedUrl
import im.vector.app.databinding.FragmentFtueSignUpCombinedBinding
import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.SSORedirectRouterActivity
import im.vector.app.features.login.ServerType
import im.vector.app.features.login.SocialLoginButtonsView
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
@ -64,8 +65,10 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupSubmitButton()
views.createAccountRoot.realignPercentagesToParent()
views.editServerButton.debouncedClicks {
viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection))
}
views.createAccountPasswordInput.editText().setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
@ -164,6 +167,9 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
setupUi(state)
setupAutoFill()
views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl()
views.selectedServerDescription.text = state.selectedHomeserver.description
if (state.isLoading) {
// Ensure password is hidden
views.createAccountPasswordInput.editText().hidePassword()
@ -171,8 +177,8 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
}
private fun setupUi(state: OnboardingViewState) {
when (state.loginMode) {
is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.loginMode.ssoIdentityProviders)
when (state.selectedHomeserver.preferredLoginMode) {
is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders)
else -> hideSsoProviders()
}
}
@ -201,6 +207,6 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
views.createAccountPasswordInput.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD)
}
}
}
private fun OnboardingViewState.isNumericOnlyUserIdForbidden() = serverType == ServerType.MatrixOrg
private fun OnboardingViewState.isNumericOnlyUserIdForbidden() = selectedHomeserver.userFacingUrl == getString(R.string.matrix_org_server_url)
}

View File

@ -0,0 +1,82 @@
/*
* 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 android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import im.vector.app.R
import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.editText
import im.vector.app.core.extensions.realignPercentagesToParent
import im.vector.app.core.extensions.toReducedUrl
import im.vector.app.core.utils.ensureProtocol
import im.vector.app.core.utils.ensureTrailingSlash
import im.vector.app.core.utils.openUrlInExternalBrowser
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 javax.inject.Inject
class FtueAuthCombinedServerSelectionFragment @Inject constructor() : AbstractFtueAuthFragment<FragmentFtueServerSelectionCombinedBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueServerSelectionCombinedBinding {
return FragmentFtueServerSelectionCombinedBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupViews()
}
private fun setupViews() {
views.chooseServerRoot.realignPercentagesToParent()
views.chooseServerToolbar.setNavigationOnClickListener {
viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnBack))
}
views.chooseServerInput.editText?.setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_DONE -> {
updateServerUrl()
}
}
false
}
views.chooseServerGetInTouch.debouncedClicks { openUrlInExternalBrowser(requireContext(), getString(R.string.ftue_ems_url)) }
views.chooseServerSubmit.debouncedClicks { updateServerUrl() }
}
private fun updateServerUrl() {
viewModel.handle(OnboardingAction.HomeServerChange.EditHomeServer(views.chooseServerInput.content().ensureProtocol().ensureTrailingSlash()))
}
override fun resetViewModel() {
// do nothing
}
override fun updateWithState(state: OnboardingViewState) {
if (views.chooseServerInput.content().isEmpty()) {
val userUrlInput = state.selectedHomeserver.userFacingUrl?.toReducedUrlKeepingSchemaIfInsecure()
views.chooseServerInput.editText().setText(userUrlInput)
}
}
private fun String.toReducedUrlKeepingSchemaIfInsecure() = toReducedUrl(keepSchema = this.startsWith("http://"))
}

View File

@ -184,7 +184,7 @@ class FtueAuthLoginFragment @Inject constructor() : AbstractSSOFtueAuthFragment<
ServerType.MatrixOrg -> {
views.loginServerIcon.isVisible = true
views.loginServerIcon.setImageResource(R.drawable.ic_logo_matrix_org)
views.loginTitle.text = getString(resId, state.homeServerUrlFromUser.toReducedUrl())
views.loginTitle.text = getString(resId, state.selectedHomeserver.userFacingUrl.toReducedUrl())
views.loginNotice.text = getString(R.string.login_server_matrix_org_text)
}
ServerType.EMS -> {
@ -195,16 +195,16 @@ class FtueAuthLoginFragment @Inject constructor() : AbstractSSOFtueAuthFragment<
}
ServerType.Other -> {
views.loginServerIcon.isVisible = false
views.loginTitle.text = getString(resId, state.homeServerUrlFromUser.toReducedUrl())
views.loginTitle.text = getString(resId, state.selectedHomeserver.userFacingUrl.toReducedUrl())
views.loginNotice.text = getString(R.string.login_server_other_text)
}
ServerType.Unknown -> Unit /* Should not happen */
}
views.loginPasswordNotice.isVisible = false
if (state.loginMode is LoginMode.SsoAndPassword) {
if (state.selectedHomeserver.preferredLoginMode is LoginMode.SsoAndPassword) {
views.loginSocialLoginContainer.isVisible = true
views.loginSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders?.sorted()
views.loginSocialLoginButtons.ssoIdentityProviders = state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders?.sorted()
views.loginSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener {
override fun onProviderSelected(id: String?) {
viewModel.getSsoUrl(

View File

@ -59,7 +59,7 @@ class FtueAuthResetPasswordFragment @Inject constructor() : AbstractFtueAuthFrag
}
private fun setupUi(state: OnboardingViewState) {
views.resetPasswordTitle.text = getString(R.string.login_reset_password_on, state.homeServerUrlFromUser.toReducedUrl())
views.resetPasswordTitle.text = getString(R.string.login_reset_password_on, state.selectedHomeserver.userFacingUrl.toReducedUrl())
}
private fun setupSubmitButton() {

View File

@ -139,7 +139,7 @@ class FtueAuthServerUrlFormFragment @Inject constructor() : AbstractFtueAuthFrag
}
else -> {
views.loginServerUrlFormHomeServerUrl.setText(serverUrl, false /* to avoid completion dialog flicker*/)
viewModel.handle(OnboardingAction.UpdateHomeServer(serverUrl))
viewModel.handle(OnboardingAction.HomeServerChange.SelectHomeServer(serverUrl))
}
}
}

View File

@ -20,6 +20,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.DrawableRes
import androidx.core.view.isVisible
import com.airbnb.mvrx.withState
import im.vector.app.R
@ -55,32 +56,30 @@ class FtueAuthSignUpSignInSelectionFragment @Inject constructor() : AbstractSSOF
views.loginSignupSigninSignIn.setOnClickListener { signIn() }
}
private fun setupUi(state: OnboardingViewState) {
private fun render(state: OnboardingViewState) {
when (state.serverType) {
ServerType.MatrixOrg -> {
views.loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_matrix_org)
views.loginSignupSigninServerIcon.isVisible = true
views.loginSignupSigninTitle.text = getString(R.string.login_connect_to, state.homeServerUrlFromUser.toReducedUrl())
views.loginSignupSigninText.text = getString(R.string.login_server_matrix_org_text)
}
ServerType.EMS -> {
views.loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_element_matrix_services)
views.loginSignupSigninServerIcon.isVisible = true
views.loginSignupSigninTitle.text = getString(R.string.login_connect_to_modular)
views.loginSignupSigninText.text = state.homeServerUrlFromUser.toReducedUrl()
}
ServerType.Other -> {
views.loginSignupSigninServerIcon.isVisible = false
views.loginSignupSigninTitle.text = getString(R.string.login_server_other_title)
views.loginSignupSigninText.text = getString(R.string.login_connect_to, state.homeServerUrlFromUser.toReducedUrl())
}
ServerType.MatrixOrg -> renderServerInformation(
icon = R.drawable.ic_logo_matrix_org,
title = getString(R.string.login_connect_to, state.selectedHomeserver.userFacingUrl.toReducedUrl()),
subtitle = getString(R.string.login_server_matrix_org_text)
)
ServerType.EMS -> renderServerInformation(
icon = R.drawable.ic_logo_element_matrix_services,
title = getString(R.string.login_connect_to_modular),
subtitle = state.selectedHomeserver.userFacingUrl.toReducedUrl()
)
ServerType.Other -> renderServerInformation(
icon = null,
title = getString(R.string.login_server_other_title),
subtitle = getString(R.string.login_connect_to, state.selectedHomeserver.userFacingUrl.toReducedUrl())
)
ServerType.Unknown -> Unit /* Should not happen */
}
when (state.loginMode) {
when (state.selectedHomeserver.preferredLoginMode) {
is LoginMode.SsoAndPassword -> {
views.loginSignupSigninSignInSocialLoginContainer.isVisible = true
views.loginSignupSigninSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders()?.sorted()
views.loginSignupSigninSocialLoginButtons.ssoIdentityProviders = state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders()?.sorted()
views.loginSignupSigninSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener {
override fun onProviderSelected(id: String?) {
viewModel.getSsoUrl(
@ -100,8 +99,16 @@ class FtueAuthSignUpSignInSelectionFragment @Inject constructor() : AbstractSSOF
}
}
private fun renderServerInformation(@DrawableRes icon: Int?, title: String, subtitle: String) {
icon?.let { views.loginSignupSigninServerIcon.setImageResource(it) }
views.loginSignupSigninServerIcon.isVisible = icon != null
views.loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_matrix_org)
views.loginSignupSigninTitle.text = title
views.loginSignupSigninText.text = subtitle
}
private fun setupButtons(state: OnboardingViewState) {
when (state.loginMode) {
when (state.selectedHomeserver.preferredLoginMode) {
is LoginMode.Sso -> {
// change to only one button that is sign in with sso
views.loginSignupSigninSubmit.text = getString(R.string.login_signin_sso)
@ -115,7 +122,7 @@ class FtueAuthSignUpSignInSelectionFragment @Inject constructor() : AbstractSSOF
}
private fun submit() = withState(viewModel) { state ->
if (state.loginMode is LoginMode.Sso) {
if (state.selectedHomeserver.preferredLoginMode is LoginMode.Sso) {
viewModel.getSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
@ -136,7 +143,7 @@ class FtueAuthSignUpSignInSelectionFragment @Inject constructor() : AbstractSSOF
}
override fun updateWithState(state: OnboardingViewState) {
setupUi(state)
render(state)
setupButtons(state)
}
}

View File

@ -233,6 +233,14 @@ class FtueAuthVariant(
OnboardingViewEvents.OnChooseProfilePicture -> onChooseProfilePicture()
OnboardingViewEvents.OnPersonalizationComplete -> onPersonalizationComplete()
OnboardingViewEvents.OnBack -> activity.popBackstack()
OnboardingViewEvents.EditServerSelection -> {
activity.addFragmentToBackstack(
views.loginFragmentContainer,
FtueAuthCombinedServerSelectionFragment::class.java,
option = commonOption
)
}
OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack()
}
}
@ -299,18 +307,18 @@ class FtueAuthVariant(
private fun handleSignInSelected(state: OnboardingViewState) {
if (isForceLoginFallbackEnabled) {
onLoginModeNotSupported(state.loginModeSupportedTypes)
onLoginModeNotSupported(state.selectedHomeserver.supportedLoginTypes)
} else {
disambiguateLoginMode(state)
}
}
private fun disambiguateLoginMode(state: OnboardingViewState) = when (state.loginMode) {
private fun disambiguateLoginMode(state: OnboardingViewState) = when (state.selectedHomeserver.preferredLoginMode) {
LoginMode.Unknown,
is LoginMode.Sso -> error("Developer error")
is LoginMode.SsoAndPassword,
LoginMode.Password -> openAuthLoginFragmentWithTag(FRAGMENT_LOGIN_TAG)
LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes)
LoginMode.Unsupported -> onLoginModeNotSupported(state.selectedHomeserver.supportedLoginTypes)
}
private fun openAuthLoginFragmentWithTag(tag: String) {
@ -331,7 +339,7 @@ class FtueAuthVariant(
private fun handleSignInWithMatrixId(state: OnboardingViewState) {
if (isForceLoginFallbackEnabled) {
onLoginModeNotSupported(state.loginModeSupportedTypes)
onLoginModeNotSupported(state.selectedHomeserver.supportedLoginTypes)
} else {
openAuthLoginFragmentWithTag(FRAGMENT_LOGIN_TAG)
}

View File

@ -116,7 +116,7 @@ class FtueAuthTermsFragment @Inject constructor(
}
override fun updateWithState(state: OnboardingViewState) {
policyController.homeServer = state.homeServerUrlFromUser.toReducedUrl()
policyController.homeServer = state.selectedHomeserver.userFacingUrl.toReducedUrl()
renderState()
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="70dp"
android:height="70dp"
android:viewportWidth="70"
android:viewportHeight="70">
<path
android:pathData="M47.25,36.75H22.75C20.825,36.75 19.25,38.325 19.25,40.25V47.25C19.25,49.175 20.825,50.75 22.75,50.75H47.25C49.175,50.75 50.75,49.175 50.75,47.25V40.25C50.75,38.325 49.175,36.75 47.25,36.75ZM26.25,47.25C24.325,47.25 22.75,45.675 22.75,43.75C22.75,41.825 24.325,40.25 26.25,40.25C28.175,40.25 29.75,41.825 29.75,43.75C29.75,45.675 28.175,47.25 26.25,47.25ZM47.25,19.25H22.75C20.825,19.25 19.25,20.825 19.25,22.75V29.75C19.25,31.675 20.825,33.25 22.75,33.25H47.25C49.175,33.25 50.75,31.675 50.75,29.75V22.75C50.75,20.825 49.175,19.25 47.25,19.25ZM26.25,29.75C24.325,29.75 22.75,28.175 22.75,26.25C22.75,24.325 24.325,22.75 26.25,22.75C28.175,22.75 29.75,24.325 29.75,26.25C29.75,28.175 28.175,29.75 26.25,29.75Z"
android:fillColor="#ff0000"/>
</vector>

View File

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="50dp"
android:height="50dp"
android:viewportWidth="50"
android:viewportHeight="50">
<path
android:pathData="M20.43,11.649C20.43,10.64 21.249,9.822 22.26,9.822C29.114,9.822 34.671,15.366 34.671,22.206C34.671,23.215 33.851,24.033 32.84,24.033C31.828,24.033 31.008,23.215 31.008,22.206C31.008,17.385 27.092,13.476 22.26,13.476C21.249,13.476 20.43,12.658 20.43,11.649Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M38.347,20.379C39.359,20.379 40.178,21.197 40.178,22.206C40.178,29.045 34.622,34.59 27.768,34.59C26.757,34.59 25.937,33.772 25.937,32.763C25.937,31.754 26.757,30.936 27.768,30.936C32.6,30.936 36.516,27.027 36.516,22.206C36.516,21.197 37.336,20.379 38.347,20.379Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M29.6,38.352C29.6,39.361 28.78,40.18 27.768,40.18C20.915,40.18 15.359,34.635 15.359,27.795C15.359,26.786 16.178,25.968 17.19,25.968C18.201,25.968 19.021,26.786 19.021,27.795C19.021,32.617 22.937,36.525 27.768,36.525C28.78,36.525 29.6,37.343 29.6,38.352Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M11.653,29.622C10.642,29.622 9.822,28.804 9.822,27.795C9.822,20.956 15.378,15.411 22.232,15.411C23.243,15.411 24.063,16.229 24.063,17.238C24.063,18.247 23.243,19.065 22.232,19.065C17.401,19.065 13.484,22.974 13.484,27.795C13.484,28.804 12.664,29.622 11.653,29.622Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,257 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/LoginFormScrollView"
android:layout_height="match_parent"
android:background="?android:colorBackground"
android:fillViewport="true"
android:paddingTop="0dp"
android:paddingBottom="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/chooseServerRoot"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/chooseServerGutterStart"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_start_percent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/chooseServerGutterEnd"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_end_percent" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/chooseServerToolbar"
style="@style/Widget.Vector.Toolbar.Settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
app:layout_constraintBottom_toTopOf="@id/chooseServerHeaderIcon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
app:layout_constraintVertical_chainStyle="packed"
app:navigationIcon="@drawable/ic_close_24dp" />
<ImageView
android:id="@+id/chooseServerHeaderIcon"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:adjustViewBounds="true"
android:background="@drawable/circle"
android:backgroundTint="?colorSecondary"
android:contentDescription="@null"
android:src="@drawable/ic_choose_server"
app:layout_constraintBottom_toTopOf="@id/chooseServerHeaderTitle"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintHeight_percent="0.10"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseServerToolbar"
app:tint="@color/palette_white" />
<TextView
android:id="@+id/chooseServerHeaderTitle"
style="@style/Widget.Vector.TextView.Title.Medium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="@string/ftue_auth_choose_server_title"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/chooseServerHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseServerHeaderIcon" />
<TextView
android:id="@+id/chooseServerHeaderSubtitle"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_auth_choose_server_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseServerHeaderTitle" />
<Space
android:id="@+id/titleContentSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/chooseServerInput"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/chooseServerHeaderSubtitle" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/chooseServerInput"
style="@style/Widget.Vector.TextInputLayout.Username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/ftue_auth_choose_server_entry_hint"
app:layout_constraintBottom_toTopOf="@id/chooseServerEntryFooter"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/titleContentSpacing">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="match_parent"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/chooseServerEntryFooter"
style="@style/Widget.Vector.TextView.Micro"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/ftue_auth_choose_server_entry_footer"
app:layout_constraintBottom_toTopOf="@id/actionSpacing"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseServerInput" />
<Space
android:id="@+id/actionSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/chooseServerSubmit"
app:layout_constraintHeight_percent="0.02"
app:layout_constraintTop_toBottomOf="@id/chooseServerEntryFooter" />
<Button
android:id="@+id/chooseServerSubmit"
style="@style/Widget.Vector.Button.Login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/login_signup_submit"
android:textAllCaps="true"
app:layout_constraintBottom_toTopOf="@id/chooseServerEmsContainer"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/actionSpacing" />
<Space
android:id="@+id/submitSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/emsTopSpacing"
app:layout_constraintHeight_percent="0.02"
app:layout_constraintTop_toBottomOf="@id/chooseServerSubmit" />
<View
android:id="@+id/chooseServerEmsContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/rounded_rect_shape_8"
android:backgroundTint="?vctr_system"
app:layout_constraintBottom_toBottomOf="@id/emsCtaSpacing"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toTopOf="@id/emsTopSpacing"
app:layout_constraintVertical_bias="0.0" />
<Space
android:id="@+id/emsTopSpacing"
android:layout_width="match_parent"
android:layout_height="24dp"
app:layout_constraintBottom_toTopOf="@id/chooseServerEmsIcon"
app:layout_constraintTop_toBottomOf="@id/chooseServerSubmit"
app:layout_constraintVertical_bias="1"
app:layout_constraintVertical_chainStyle="packed" />
<ImageView
android:id="@+id/chooseServerEmsIcon"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginBottom="22dp"
android:adjustViewBounds="true"
android:background="@drawable/circle"
android:backgroundTint="@color/palette_ems"
android:contentDescription="@null"
android:src="@drawable/ic_ems_logo"
app:layout_constraintBottom_toTopOf="@id/chooseServerEmsTitle"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintHeight_percent="0.08"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/emsTopSpacing"
app:layout_constraintVertical_bias="1"
app:tint="@color/palette_white" />
<TextView
android:id="@+id/chooseServerEmsTitle"
style="@style/Widget.Vector.TextView.HeadlineMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp"
android:gravity="center"
android:text="@string/ftue_auth_choose_server_ems_title"
app:layout_constraintBottom_toTopOf="@id/chooseServerEmsSubtitle"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseServerEmsIcon" />
<TextView
android:id="@+id/chooseServerEmsSubtitle"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:autoLink="web"
android:gravity="center"
android:text="@string/ftue_auth_choose_server_ems_subtitle"
app:layout_constraintBottom_toTopOf="@id/chooseServerGetInTouch"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseServerEmsTitle" />
<Button
android:id="@+id/chooseServerGetInTouch"
style="@style/Widget.Vector.Button.Login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:backgroundTint="@color/palette_ems"
android:text="@string/ftue_auth_choose_server_ems_cta"
android:textAllCaps="true"
app:layout_constraintBottom_toTopOf="@id/emsCtaSpacing"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseServerEmsSubtitle" />
<Space
android:id="@+id/emsCtaSpacing"
android:layout_width="match_parent"
android:layout_height="16dp"
app:layout_constraintBottom_toTopOf="@id/footerSpacing"
app:layout_constraintTop_toBottomOf="@id/chooseServerGetInTouch" />
<Space
android:id="@+id/footerSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/emsCtaSpacing" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -109,7 +109,6 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/ftue_auth_create_account_matrix_dot_org_server_name"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/selectedServerDescription"
app:layout_constraintEnd_toStartOf="@id/editServerButton"
@ -122,7 +121,6 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/ftue_auth_create_account_matrix_dot_org_server_description"
android:textColor="?vctr_content_tertiary"
app:layout_constraintBottom_toTopOf="@id/serverSelectionSpacing"
app:layout_constraintEnd_toStartOf="@id/editServerButton"
@ -139,7 +137,6 @@
android:paddingEnd="12dp"
android:text="@string/ftue_auth_create_account_edit_server_selection"
android:textAllCaps="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/selectedServerDescription"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintTop_toTopOf="@id/chooseYourServerHeader" />
@ -176,6 +173,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:imeOptions="actionNext"
android:nextFocusForward="@id/createAccountPasswordInput"
android:inputType="text"
android:maxLines="1" />

View File

@ -17,7 +17,15 @@
<string name="ftue_auth_create_account_choose_server_header">Choose your server to store your data</string>
<string name="ftue_auth_create_account_sso_section_header">Or</string>
<string name="ftue_auth_create_account_matrix_dot_org_server_description">Join millions for free on the largest public server</string>
<string name="ftue_auth_create_account_matrix_dot_org_server_name">matrix.org</string>
<string name="ftue_auth_create_account_edit_server_selection">Edit</string>
<string name="ftue_auth_choose_server_title">Choose your server</string>
<string name="ftue_auth_choose_server_subtitle">What is the address of your server? Server is like a home for all your data.</string>
<string name="ftue_auth_choose_server_entry_hint">Server URL</string>
<string name="ftue_auth_choose_server_entry_footer">You can only connect to a server that has already been set up</string>
<string name="ftue_auth_choose_server_ems_title">Want to host your own server?</string>
<string name="ftue_auth_choose_server_ems_subtitle">Element Matrix Services (EMS) is a robust and reliable hosting service for fast, secure and real time communication. Find out how on <a href="${ftue_ems_url}">element.io/ems</a></string>
<string name="ftue_auth_choose_server_ems_cta">Get in touch</string>
</resources>

View File

@ -18,8 +18,10 @@ package im.vector.app.features.onboarding
import android.net.Uri
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.ReAuthHelper
import im.vector.app.features.login.SignMode
import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeAnalyticsTracker
import im.vector.app.test.fakes.FakeAuthenticationService
@ -30,6 +32,7 @@ import im.vector.app.test.fakes.FakeHomeServerHistoryService
import im.vector.app.test.fakes.FakeRegisterActionHandler
import im.vector.app.test.fakes.FakeRegistrationWizard
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeStartAuthenticationFlowUseCase
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeUri
import im.vector.app.test.fakes.FakeUriFilenameResolver
@ -41,6 +44,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.registration.FlowResult
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
@ -58,6 +62,9 @@ private val A_HOMESERVER_CAPABILITIES = aHomeServerCapabilities(canChangeDisplay
private val AN_IGNORED_FLOW_RESULT = FlowResult(missingStages = emptyList(), completedStages = emptyList())
private val ANY_CONTINUING_REGISTRATION_RESULT = RegistrationResult.FlowResponse(AN_IGNORED_FLOW_RESULT)
private val A_LOGIN_OR_REGISTER_ACTION = OnboardingAction.LoginOrRegister("@a-user:id.org", "a-password", "a-device-name")
private const val A_HOMESERVER_URL = "https://edited-homeserver.org"
private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance)
private val SELECTED_HOMESERVER_STATE = SelectedHomeserverState(preferredLoginMode = LoginMode.Password)
class OnboardingViewModelTest {
@ -74,6 +81,9 @@ class OnboardingViewModelTest {
private val fakeRegisterActionHandler = FakeRegisterActionHandler()
private val fakeDirectLoginUseCase = FakeDirectLoginUseCase()
private val fakeVectorFeatures = FakeVectorFeatures()
private val fakeHomeServerConnectionConfigFactory = FakeHomeServerConnectionConfigFactory()
private val fakeStartAuthenticationFlowUseCase = FakeStartAuthenticationFlowUseCase()
private val fakeHomeServerHistoryService = FakeHomeServerHistoryService()
lateinit var viewModel: OnboardingViewModel
@ -224,6 +234,25 @@ class OnboardingViewModelTest {
.finish()
}
@Test
fun `given when editing homeserver, then updates selected homeserver state and emits edited event`() = runTest {
val test = viewModel.test()
fakeHomeServerConnectionConfigFactory.givenConfigFor(A_HOMESERVER_URL, A_HOMESERVER_CONFIG)
fakeStartAuthenticationFlowUseCase.givenResult(A_HOMESERVER_CONFIG, StartAuthenticationResult(false, SELECTED_HOMESERVER_STATE))
fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString())
viewModel.handle(OnboardingAction.HomeServerChange.EditHomeServer(A_HOMESERVER_URL))
test
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(isLoading = false, selectedHomeserver = SELECTED_HOMESERVER_STATE) },
)
.assertEvents(OnboardingViewEvents.OnHomeserverEdited)
.finish()
}
@Test
fun `given personalisation enabled, when registering account, then updates state and emits account created event`() = runTest {
fakeVectorFeatures.givenPersonalisationEnabled()
@ -383,15 +412,16 @@ class OnboardingViewModelTest {
fakeContext.instance,
fakeAuthenticationService,
fakeActiveSessionHolder.instance,
FakeHomeServerConnectionConfigFactory().instance,
fakeHomeServerConnectionConfigFactory.instance,
ReAuthHelper(),
FakeStringProvider().instance,
FakeHomeServerHistoryService(),
fakeHomeServerHistoryService,
fakeVectorFeatures,
FakeAnalyticsTracker(),
fakeUriFilenameResolver.instance,
fakeRegisterActionHandler.instance,
fakeDirectLoginUseCase.instance,
fakeStartAuthenticationFlowUseCase.instance,
FakeVectorOverrides()
)
}

View File

@ -0,0 +1,157 @@
/*
* 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
import im.vector.app.R
import im.vector.app.features.login.LoginMode
import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult
import im.vector.app.test.fakes.FakeAuthenticationService
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeUri
import im.vector.app.test.fakes.toTestString
import io.mockk.coVerifyOrder
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.LoginFlowResult
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
private const val MATRIX_ORG_URL = "https://any-value.org/"
private const val A_DECLARED_HOMESERVER_URL = "https://foo.bar"
private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(homeServerUri = FakeUri().instance)
private val SSO_IDENTITY_PROVIDERS = emptyList<SsoIdentityProvider>()
class StartAuthenticationFlowUseCaseTest {
private val fakeAuthenticationService = FakeAuthenticationService()
private val fakeStringProvider = FakeStringProvider()
private val useCase = StartAuthenticationFlowUseCase(fakeAuthenticationService, fakeStringProvider.instance)
@Before
fun setUp() {
fakeAuthenticationService.expectedCancelsPendingLogin()
}
@Test
fun `given empty login result when starting authentication flow then returns empty result`() = runTest {
val loginResult = aLoginResult()
fakeAuthenticationService.givenLoginFlow(A_HOMESERVER_CONFIG, loginResult)
val result = useCase.execute(A_HOMESERVER_CONFIG)
result shouldBeEqualTo expectedResult()
verifyClearsAndThenStartsLogin(A_HOMESERVER_CONFIG)
}
@Test
fun `given login supports SSO and Password when starting authentication flow then prefers SsoAndPassword`() = runTest {
val supportedLoginTypes = listOf(LoginFlowTypes.SSO, LoginFlowTypes.PASSWORD)
val loginResult = aLoginResult(supportedLoginTypes = supportedLoginTypes)
fakeAuthenticationService.givenLoginFlow(A_HOMESERVER_CONFIG, loginResult)
val result = useCase.execute(A_HOMESERVER_CONFIG)
result shouldBeEqualTo expectedResult(
supportedLoginTypes = supportedLoginTypes,
preferredLoginMode = LoginMode.SsoAndPassword(SSO_IDENTITY_PROVIDERS),
)
verifyClearsAndThenStartsLogin(A_HOMESERVER_CONFIG)
}
@Test
fun `given login supports SSO when starting authentication flow then prefers Sso`() = runTest {
val supportedLoginTypes = listOf(LoginFlowTypes.SSO)
val loginResult = aLoginResult(supportedLoginTypes = supportedLoginTypes)
fakeAuthenticationService.givenLoginFlow(A_HOMESERVER_CONFIG, loginResult)
val result = useCase.execute(A_HOMESERVER_CONFIG)
result shouldBeEqualTo expectedResult(
supportedLoginTypes = supportedLoginTypes,
preferredLoginMode = LoginMode.Sso(SSO_IDENTITY_PROVIDERS),
)
verifyClearsAndThenStartsLogin(A_HOMESERVER_CONFIG)
}
@Test
fun `given login supports Password when starting authentication flow then prefers Password`() = runTest {
val supportedLoginTypes = listOf(LoginFlowTypes.PASSWORD)
val loginResult = aLoginResult(supportedLoginTypes = supportedLoginTypes)
fakeAuthenticationService.givenLoginFlow(A_HOMESERVER_CONFIG, loginResult)
val result = useCase.execute(A_HOMESERVER_CONFIG)
result shouldBeEqualTo expectedResult(
supportedLoginTypes = supportedLoginTypes,
preferredLoginMode = LoginMode.Password,
)
verifyClearsAndThenStartsLogin(A_HOMESERVER_CONFIG)
}
@Test
fun `given matrix dot org url when starting authentication flow then provides description`() = runTest {
val matrixOrgConfig = HomeServerConnectionConfig(homeServerUri = FakeUri(MATRIX_ORG_URL).instance)
fakeStringProvider.given(R.string.matrix_org_server_url, result = MATRIX_ORG_URL)
fakeAuthenticationService.givenLoginFlow(matrixOrgConfig, aLoginResult())
val result = useCase.execute(matrixOrgConfig)
result shouldBeEqualTo expectedResult(
description = R.string.ftue_auth_create_account_matrix_dot_org_server_description.toTestString(),
homeserverSourceUrl = MATRIX_ORG_URL
)
verifyClearsAndThenStartsLogin(matrixOrgConfig)
}
private fun aLoginResult(
supportedLoginTypes: List<String> = emptyList()
) = LoginFlowResult(
supportedLoginTypes = supportedLoginTypes,
ssoIdentityProviders = SSO_IDENTITY_PROVIDERS,
isLoginAndRegistrationSupported = true,
homeServerUrl = A_DECLARED_HOMESERVER_URL,
isOutdatedHomeserver = false
)
private fun expectedResult(
isHomeserverOutdated: Boolean = false,
description: String? = null,
preferredLoginMode: LoginMode = LoginMode.Unsupported,
supportedLoginTypes: List<String> = emptyList(),
homeserverSourceUrl: String = A_HOMESERVER_CONFIG.homeServerUri.toString()
) = StartAuthenticationResult(
isHomeserverOutdated,
SelectedHomeserverState(
description = description,
userFacingUrl = homeserverSourceUrl,
upstreamUrl = A_DECLARED_HOMESERVER_URL,
preferredLoginMode = preferredLoginMode,
supportedLoginTypes = supportedLoginTypes
)
)
private fun verifyClearsAndThenStartsLogin(homeServerConnectionConfig: HomeServerConnectionConfig) {
coVerifyOrder {
fakeAuthenticationService.cancelPendingLoginOrRegistration()
fakeAuthenticationService.getLoginFlow(homeServerConnectionConfig)
}
}
}

View File

@ -22,6 +22,7 @@ import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.LoginFlowResult
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.auth.wellknown.WellknownResult
@ -35,10 +36,18 @@ class FakeAuthenticationService : AuthenticationService by mockk() {
every { isRegistrationStarted } returns started
}
fun givenLoginFlow(config: HomeServerConnectionConfig, result: LoginFlowResult) {
coEvery { getLoginFlow(config) } returns result
}
fun expectReset() {
coJustRun { reset() }
}
fun expectedCancelsPendingLogin() {
coJustRun { cancelPendingLoginOrRegistration() }
}
fun givenWellKnown(matrixId: String, config: HomeServerConnectionConfig?, result: WellknownResult) {
coEvery { getWellKnownData(matrixId, config) } returns result
}
@ -52,6 +61,6 @@ class FakeAuthenticationService : AuthenticationService by mockk() {
}
fun givenDirectAuthenticationThrows(config: HomeServerConnectionConfig, matrixId: String, password: String, deviceName: String, cause: Throwable) {
coEvery { directAuthentication(config, matrixId, password, deviceName) } throws cause
coEvery { directAuthentication(config, matrixId, password, deviceName) } throws cause
}
}

View File

@ -17,9 +17,14 @@
package im.vector.app.test.fakes
import im.vector.app.features.login.HomeServerConnectionConfigFactory
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
class FakeHomeServerConnectionConfigFactory {
val instance: HomeServerConnectionConfigFactory = mockk()
fun givenConfigFor(url: String, config: HomeServerConnectionConfig) {
every { instance.create(url) } returns config
}
}

View File

@ -16,9 +16,13 @@
package im.vector.app.test.fakes
import io.mockk.justRun
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
class FakeHomeServerHistoryService : HomeServerHistoryService by mockk() {
override fun getKnownServersUrls() = emptyList<String>()
fun expectUrlToBeAdded(url: String) {
justRun { addHomeServerToHistory(url) }
}
}

View File

@ -0,0 +1,32 @@
/*
* 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.features.onboarding.StartAuthenticationFlowUseCase
import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult
import io.mockk.coEvery
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
class FakeStartAuthenticationFlowUseCase {
val instance = mockk<StartAuthenticationFlowUseCase>()
fun givenResult(config: HomeServerConnectionConfig, result: StartAuthenticationResult) {
coEvery { instance.execute(config) } returns result
}
}

View File

@ -21,7 +21,6 @@ import io.mockk.every
import io.mockk.mockk
class FakeStringProvider {
val instance = mockk<StringProvider>()
init {
@ -29,6 +28,10 @@ class FakeStringProvider {
"test-${args[0]}"
}
}
fun given(id: Int, result: String) {
every { instance.getString(id) } returns result
}
}
fun Int.toTestString() = "test-$this"

View File

@ -25,7 +25,10 @@ class FakeUri(contentEquals: String? = null) {
val instance = mockk<Uri>()
init {
contentEquals?.let { givenEquals(it) }
contentEquals?.let {
givenEquals(it)
every { instance.toString() } returns it
}
}
fun givenNonHierarchical() {