diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index a5daeddc7ac..54d783fc85d 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3641,4 +3641,10 @@ Message in %s Message in room Room/Space + + You can no longer create an account with %1$s using this app + Download %1$s to use %2$s for your account or choose a different homeserver. + Download %1$s + Faster, more secure, and packed with powerful collaboration tools. + diff --git a/library/ui-styles/src/main/res/values/colors.xml b/library/ui-styles/src/main/res/values/colors.xml index 9d8645a7076..5e9bf9062cb 100644 --- a/library/ui-styles/src/main/res/values/colors.xml +++ b/library/ui-styles/src/main/res/values/colors.xml @@ -158,4 +158,25 @@ #EEF8F4 #1D292A + + + #FFF7F6 + #3E0000 + + + #FFC5BC + #710000 + + + #D51928 + #FD3E3C + + + #D51928 + #FD3E3C + + + #1B1D22 + #EBEEF2 + diff --git a/library/ui-styles/src/main/res/values/theme_dark.xml b/library/ui-styles/src/main/res/values/theme_dark.xml index 9afa14caed0..39ec830abda 100644 --- a/library/ui-styles/src/main/res/values/theme_dark.xml +++ b/library/ui-styles/src/main/res/values/theme_dark.xml @@ -55,6 +55,13 @@ ?vctr_system ?vctr_notice_secondary + + @color/vctr_bg_critical_subtle_dark + @color/vctr_border_critical_subtle_dark + @color/vctr_icon_critical_primary_dark + @color/vctr_text_critical_primary_dark + @color/vctr_text_primary_dark + @color/element_accent_dark @color/element_accent_dark diff --git a/library/ui-styles/src/main/res/values/theme_light.xml b/library/ui-styles/src/main/res/values/theme_light.xml index 23782ee34b2..bd397d1bc68 100644 --- a/library/ui-styles/src/main/res/values/theme_light.xml +++ b/library/ui-styles/src/main/res/values/theme_light.xml @@ -55,6 +55,13 @@ ?vctr_system ?vctr_notice_secondary + + @color/vctr_bg_critical_subtle_light + @color/vctr_border_critical_subtle_light + @color/vctr_icon_critical_primary_light + @color/vctr_text_critical_primary_light + @color/vctr_text_primary_light + @color/element_accent_light @color/element_accent_light diff --git a/vector-config/src/main/java/im/vector/app/config/Config.kt b/vector-config/src/main/java/im/vector/app/config/Config.kt index 1bd21dd08e8..5e0196767f2 100644 --- a/vector-config/src/main/java/im/vector/app/config/Config.kt +++ b/vector-config/src/main/java/im/vector/app/config/Config.kt @@ -97,4 +97,15 @@ object Config { val ER_DEBUG_ANALYTICS_CONFIG = DEBUG_ANALYTICS_CONFIG.copy(sentryEnvironment = "element-r") val SHOW_UNVERIFIED_SESSIONS_ALERT_AFTER_MILLIS = 7.days.inWholeMilliseconds // 1 Week + + /** + * Sunsetting the application. + * Fork maintainers can use this to inform users about their new application if any. Note that you probably also want + * to replace the resource `replacement_app_icon` too. + */ + val sunsetConfig: SunsetConfig = SunsetConfig.Enabled( + learnMoreLink = "https://element.io/app-for-productivity", + replacementApplicationName = "Element X", + replacementApplicationId = "io.element.android.x", + ) } diff --git a/vector-config/src/main/java/im/vector/app/config/SunsetConfig.kt b/vector-config/src/main/java/im/vector/app/config/SunsetConfig.kt new file mode 100644 index 00000000000..f012ac32a9f --- /dev/null +++ b/vector-config/src/main/java/im/vector/app/config/SunsetConfig.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 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.config + +sealed interface SunsetConfig { + /** + * Sunsetting the application is disabled. + */ + data object Disabled : SunsetConfig + + /** + * Sunsetting the application is enabled and can be configured by implementing this class. + */ + data class Enabled( + /** + * The URL target to learn more. + */ + val learnMoreLink: String, + + /** + * The replacement application ID. + * Example: for Element application, the replacement application ID is the id of Element X: "Element X". + */ + val replacementApplicationName: String, + + /** + * The replacement application ID. + * Example: for Element App, the replacement application ID is the id of Element X: "io.element.android.x". + */ + val replacementApplicationId: String, + ) : SunsetConfig +} diff --git a/vector/src/main/java/im/vector/app/core/utils/ExternalApplicationsUtil.kt b/vector/src/main/java/im/vector/app/core/utils/ExternalApplicationsUtil.kt index 82271ca23c1..4db340a7230 100644 --- a/vector/src/main/java/im/vector/app/core/utils/ExternalApplicationsUtil.kt +++ b/vector/src/main/java/im/vector/app/core/utils/ExternalApplicationsUtil.kt @@ -32,6 +32,7 @@ import androidx.core.app.ShareCompat import androidx.core.content.FileProvider import androidx.core.content.getSystemService import im.vector.app.R +import im.vector.app.core.resources.BuildMeta import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.themes.ThemeUtils import im.vector.lib.strings.CommonStrings @@ -367,13 +368,21 @@ private fun addToGallery(savedFile: File, mediaMimeType: String?, context: Conte } /** - * Open the play store to the provided application Id, default to this app. + * Open the play store or the F-Droid to the provided application Id, default to this app. */ -fun openPlayStore(activity: Activity, appId: String) { +fun openApplicationStore( + activity: Activity, + buildMeta: BuildMeta, + appId: String = buildMeta.applicationId, +) { try { activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$appId"))) } catch (activityNotFoundException: ActivityNotFoundException) { - activity.safeStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$appId"))) + if (buildMeta.flavorDescription == "FDroid") { + activity.safeStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://f-droid.org/packages/$appId"))) + } else { + activity.safeStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$appId"))) + } } } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/MasSupportRequiredException.kt b/vector/src/main/java/im/vector/app/features/onboarding/MasSupportRequiredException.kt new file mode 100644 index 00000000000..ca6090bd055 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/MasSupportRequiredException.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 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 + +class MasSupportRequiredException : Exception("Please use replacement app") diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt index 27f1727641c..8ed6272d507 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt @@ -12,6 +12,8 @@ import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.config.Config +import im.vector.app.config.SunsetConfig import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory @@ -761,7 +763,13 @@ class OnboardingViewModel @AssistedInject constructor( } OnboardingFlow.SignUp -> { updateSignMode(SignMode.SignUp) - internalRegisterAction(RegisterAction.StartRegistration) + if (authResult.selectedHomeserver.hasOidcCompatibilityFlow && Config.sunsetConfig is SunsetConfig.Enabled) { + // Navigate to the screen to create an account, it will show the error + setState { copy(isLoading = false) } + _viewEvents.post(OnboardingViewEvents.OpenCombinedRegister) + } else { + internalRegisterAction(RegisterAction.StartRegistration) + } } OnboardingFlow.SignInSignUp, null -> { @@ -775,9 +783,17 @@ class OnboardingViewModel @AssistedInject constructor( private suspend fun onHomeServerEdited(config: HomeServerConnectionConfig, serverTypeOverride: ServerType?, authResult: StartAuthenticationResult) { when (awaitState().onboardingFlow) { - OnboardingFlow.SignUp -> internalRegisterAction(RegisterAction.StartRegistration) { - updateServerSelection(config, serverTypeOverride, authResult) - _viewEvents.post(OnboardingViewEvents.OnHomeserverEdited) + OnboardingFlow.SignUp -> { + if (authResult.selectedHomeserver.hasOidcCompatibilityFlow && Config.sunsetConfig is SunsetConfig.Enabled) { + // An error is displayed now + setState { copy(isLoading = false) } + _viewEvents.post(OnboardingViewEvents.Failure(MasSupportRequiredException())) + } else { + internalRegisterAction(RegisterAction.StartRegistration) { + updateServerSelection(config, serverTypeOverride, authResult) + _viewEvents.post(OnboardingViewEvents.OnHomeserverEdited) + } + } } OnboardingFlow.SignIn -> { updateServerSelection(config, serverTypeOverride, authResult) @@ -924,7 +940,10 @@ private fun LoginMode.supportsSignModeScreen(): Boolean { return when (this) { LoginMode.Password, is LoginMode.SsoAndPassword -> true - is LoginMode.Sso, + is LoginMode.Sso -> { + // In this case, an error will be displayed in the next screen + hasOidcCompatibilityFlow + } LoginMode.Unknown, LoginMode.Unsupported -> false } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt index 5a971514b68..9b322b626b9 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt @@ -12,6 +12,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.TextView import androidx.autofill.HintConstants import androidx.core.text.isDigitsOnly import androidx.core.view.isVisible @@ -19,6 +20,9 @@ import androidx.lifecycle.lifecycleScope import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.config.Config +import im.vector.app.config.SunsetConfig import im.vector.app.core.extensions.clearErrorOnChange import im.vector.app.core.extensions.content import im.vector.app.core.extensions.editText @@ -31,6 +35,9 @@ 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.core.resources.BuildMeta +import im.vector.app.core.utils.openApplicationStore +import im.vector.app.core.utils.openUrlInChromeCustomTab import im.vector.app.databinding.FragmentFtueCombinedRegisterBinding import im.vector.app.features.login.LoginMode import im.vector.app.features.login.SSORedirectRouterActivity @@ -52,12 +59,14 @@ import org.matrix.android.sdk.api.failure.isRegistrationDisabled import org.matrix.android.sdk.api.failure.isUsernameInUse import org.matrix.android.sdk.api.failure.isWeakPassword import reactivecircus.flowbinding.android.widget.textChanges +import javax.inject.Inject private const val MINIMUM_PASSWORD_LENGTH = 8 @AndroidEntryPoint class FtueAuthCombinedRegisterFragment : AbstractSSOFtueAuthFragment() { + @Inject lateinit var buildMeta: BuildMeta override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueCombinedRegisterBinding { return FragmentFtueCombinedRegisterBinding.inflate(inflater, container, false) @@ -181,7 +190,8 @@ class FtueAuthCombinedRegisterFragment : } private fun setupUi(state: OnboardingViewState) { - views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl() + val serverName = state.selectedHomeserver.userFacingUrl.toReducedUrl() + views.selectedServerName.text = serverName if (state.isLoading) { // Ensure password is hidden @@ -201,6 +211,47 @@ class FtueAuthCombinedRegisterFragment : is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode) else -> hideSsoProviders() } + + (Config.sunsetConfig as? SunsetConfig.Enabled)?.let { config -> + val isMasSupportRequired = state.selectedHomeserver.hasOidcCompatibilityFlow + views.serverSelectionSpacing.isVisible = !isMasSupportRequired + views.serverSelectionDivider.isVisible = !isMasSupportRequired + views.chooseServerCardErrorMas.isVisible = isMasSupportRequired + views.chooseServerCardDownloadReplacementApp.isVisible = isMasSupportRequired + + if (isMasSupportRequired) { + views.chooseServerCardErrorMas.findViewById(R.id.view_card_error_title).text = + getString(CommonStrings.error_mas_not_supported_title, serverName) + views.chooseServerCardErrorMas.findViewById(R.id.view_card_error_subtitle).text = + getString( + CommonStrings.error_mas_not_supported_subtitle, + config.replacementApplicationName, + serverName, + ) + views.chooseServerCardDownloadReplacementApp.findViewById(R.id.view_download_replacement_app_title).text = + getString(CommonStrings.view_download_replacement_app_title, config.replacementApplicationName) + + views.chooseServerCardDownloadReplacementApp.debouncedClicks { + openApplicationStore( + activity = requireActivity(), + buildMeta = buildMeta, + appId = config.replacementApplicationId, + ) + } + views.chooseServerCardDownloadReplacementApp.findViewById(R.id.view_download_replacement_app_learn_more)?.debouncedClicks { + openUrlInChromeCustomTab( + context = requireContext(), + session = null, + url = config.learnMoreLink, + ) + } + + // Disable form + views.createAccountInput.isEnabled = false + views.createAccountPasswordInput.isEnabled = false + views.createAccountSubmit.isEnabled = false + } + } } private fun renderSsoProviders(deviceId: String?, loginMode: LoginMode) { diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt index 3f34c6ea686..e43de86c13c 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt @@ -11,7 +11,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.config.Config +import im.vector.app.config.SunsetConfig import im.vector.app.core.extensions.associateContentStateWith import im.vector.app.core.extensions.clearErrorOnChange import im.vector.app.core.extensions.content @@ -20,20 +25,26 @@ import im.vector.app.core.extensions.realignPercentagesToParent import im.vector.app.core.extensions.setOnImeDoneListener import im.vector.app.core.extensions.showKeyboard import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.core.resources.BuildMeta import im.vector.app.core.utils.ensureProtocol import im.vector.app.core.utils.ensureTrailingSlash +import im.vector.app.core.utils.openApplicationStore +import im.vector.app.core.utils.openUrlInChromeCustomTab import im.vector.app.core.utils.openUrlInExternalBrowser import im.vector.app.databinding.FragmentFtueServerSelectionCombinedBinding +import im.vector.app.features.onboarding.MasSupportRequiredException import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingFlow import im.vector.app.features.onboarding.OnboardingViewEvents import im.vector.app.features.onboarding.OnboardingViewState import im.vector.lib.strings.CommonStrings import org.matrix.android.sdk.api.failure.isHomeserverUnavailable +import javax.inject.Inject @AndroidEntryPoint class FtueAuthCombinedServerSelectionFragment : AbstractFtueAuthFragment() { + @Inject lateinit var buildMeta: BuildMeta override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueServerSelectionCombinedBinding { return FragmentFtueServerSelectionCombinedBinding.inflate(inflater, container, false) @@ -57,6 +68,22 @@ class FtueAuthCombinedServerSelectionFragment : } views.chooseServerGetInTouch.debouncedClicks { openUrlInExternalBrowser(requireContext(), getString(im.vector.app.config.R.string.ftue_ems_url)) } views.chooseServerSubmit.debouncedClicks { updateServerUrl() } + (Config.sunsetConfig as? SunsetConfig.Enabled)?.let { config -> + views.chooseServerCardDownloadReplacementApp.debouncedClicks { + openApplicationStore( + activity = requireActivity(), + buildMeta = buildMeta, + appId = config.replacementApplicationId, + ) + } + views.chooseServerCardDownloadReplacementApp.findViewById(R.id.view_download_replacement_app_learn_more)?.debouncedClicks { + openUrlInChromeCustomTab( + context = requireContext(), + session = null, + url = config.learnMoreLink, + ) + } + } views.chooseServerInput.clearErrorOnChange(viewLifecycleOwner) } @@ -89,10 +116,30 @@ class FtueAuthCombinedServerSelectionFragment : } override fun onError(throwable: Throwable) { + val isMasSupportRequiredException = throwable is MasSupportRequiredException views.chooseServerInput.error = when { throwable.isHomeserverUnavailable() -> getString(CommonStrings.login_error_homeserver_not_found) + isMasSupportRequiredException -> " " else -> errorFormatter.toHumanReadable(throwable) } + views.chooseServerCardErrorMas.isVisible = isMasSupportRequiredException + views.chooseServerCardDownloadReplacementApp.isVisible = isMasSupportRequiredException + if (isMasSupportRequiredException) { + views.chooseServerSubmit.isEnabled = false + } + val config = Config.sunsetConfig + if (throwable is MasSupportRequiredException && config is SunsetConfig.Enabled) { + views.chooseServerCardErrorMas.findViewById(R.id.view_card_error_title).text = + getString(CommonStrings.error_mas_not_supported_title, views.chooseServerInput.content()) + views.chooseServerCardErrorMas.findViewById(R.id.view_card_error_subtitle).text = + getString( + CommonStrings.error_mas_not_supported_subtitle, + config.replacementApplicationName, + views.chooseServerInput.content(), + ) + views.chooseServerCardDownloadReplacementApp.findViewById(R.id.view_download_replacement_app_title).text = + getString(CommonStrings.view_download_replacement_app_title, config.replacementApplicationName) + } } private fun String.toReducedUrlKeepingSchemaIfInsecure() = toReducedUrl(keepSchema = this.startsWith("http://")) diff --git a/vector/src/main/res/drawable-mdpi/replacement_app_icon.png b/vector/src/main/res/drawable-mdpi/replacement_app_icon.png new file mode 100644 index 00000000000..db4e68bbc22 Binary files /dev/null and b/vector/src/main/res/drawable-mdpi/replacement_app_icon.png differ diff --git a/vector/src/main/res/drawable-xhdpi/replacement_app_icon.png b/vector/src/main/res/drawable-xhdpi/replacement_app_icon.png new file mode 100644 index 00000000000..d8e0864606e Binary files /dev/null and b/vector/src/main/res/drawable-xhdpi/replacement_app_icon.png differ diff --git a/vector/src/main/res/drawable-xxhdpi/replacement_app_icon.png b/vector/src/main/res/drawable-xxhdpi/replacement_app_icon.png new file mode 100644 index 00000000000..589de41ed37 Binary files /dev/null and b/vector/src/main/res/drawable-xxhdpi/replacement_app_icon.png differ diff --git a/vector/src/main/res/drawable/card_background.xml b/vector/src/main/res/drawable/card_background.xml new file mode 100644 index 00000000000..61cefc68ed4 --- /dev/null +++ b/vector/src/main/res/drawable/card_background.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/vector/src/main/res/drawable/card_background_error.xml b/vector/src/main/res/drawable/card_background_error.xml new file mode 100644 index 00000000000..ee43a083397 --- /dev/null +++ b/vector/src/main/res/drawable/card_background_error.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/vector/src/main/res/drawable/ic_error.xml b/vector/src/main/res/drawable/ic_error.xml new file mode 100644 index 00000000000..7ff2b529080 --- /dev/null +++ b/vector/src/main/res/drawable/ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/layout/fragment_ftue_combined_login.xml b/vector/src/main/res/layout/fragment_ftue_combined_login.xml index a589ec6f5a2..b7a5fb459d6 100644 --- a/vector/src/main/res/layout/fragment_ftue_combined_login.xml +++ b/vector/src/main/res/layout/fragment_ftue_combined_login.xml @@ -83,7 +83,8 @@ app:layout_constraintBottom_toTopOf="@id/selectedServerDescription" app:layout_constraintEnd_toStartOf="@id/editServerButton" app:layout_constraintStart_toStartOf="@id/loginGutterStart" - app:layout_constraintTop_toBottomOf="@id/chooseYourServerHeader" /> + app:layout_constraintTop_toBottomOf="@id/chooseYourServerHeader" + tools:text="server.org" /> + app:layout_constraintVertical_bias="0" + app:layout_constraintVertical_chainStyle="packed" /> + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/chooseServerCardDownloadReplacementApp"> + android:maxLines="1" + android:nextFocusForward="@id/createAccountPasswordInput" /> diff --git a/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml b/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml index f1944e25ada..8430ff2dcdf 100644 --- a/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml +++ b/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml @@ -98,7 +98,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:hint="@string/ftue_auth_choose_server_entry_hint" - app:layout_constraintBottom_toTopOf="@id/actionSpacing" + app:layout_constraintBottom_toTopOf="@id/chooseServerCardErrorMas" app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd" app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart" app:layout_constraintTop_toBottomOf="@id/titleContentSpacing"> @@ -112,13 +112,51 @@ + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/chooseServerCardDownloadReplacementApp" />