From b3cf48c20848fec3e2c1599a84b76f4416a278eb Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Fri, 3 Jul 2026 09:19:50 +0200 Subject: [PATCH 1/6] feat: make feedback menu item configurable --- .../com/wire/android/ui/home/settings/SettingsScreen.kt | 4 +++- buildSrc/src/main/kotlin/customization/FeatureConfigs.kt | 1 + default.json | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt index 51df8b776da..ebe0e34e0ba 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt @@ -159,7 +159,9 @@ fun SettingsScreenContent( if (BuildConfig.DEBUG_SCREEN_ENABLED) { add(SettingsItem.DebugSettings) } - add(SettingsItem.GiveFeedback) + if (BuildConfig.FEEDBACK_MENU_ITEM_ENABLED) { + add(SettingsItem.GiveFeedback) + } add(SettingsItem.ReportBug) add(SettingsItem.AboutApp) }, diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index 1813cc9e51a..3bf98490da5 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -81,6 +81,7 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { DEVELOPER_FEATURES_ENABLED("developer_features_enabled", ConfigType.BOOLEAN), DEVELOPMENT_API_ENABLED("development_api_enabled", ConfigType.BOOLEAN), REPORT_BUG_MENU_ITEM_ENABLED("report_bug_menu_item_enabled", ConfigType.BOOLEAN), + FEEDBACK_MENU_ITEM_ENABLED("feedback_menu_item_enabled", ConfigType.BOOLEAN), URL_SUPPORT("url_support", ConfigType.STRING), URL_RSS_RELEASE_NOTES("url_rss_release_notes", ConfigType.STRING), diff --git a/default.json b/default.json index 7dd3a2e3198..16b9abbacb5 100644 --- a/default.json +++ b/default.json @@ -123,6 +123,7 @@ "google_api_key": "AIzaSyBXtNKuX6GCKv2jDtsFImUaxCRL21DTLEQ", "fcm_project_id": "w966768976", "report_bug_menu_item_enabled": true, + "feedback_menu_item_enabled": true, "debug_screen_enabled": true, "update_app_url": "https://wire.com/en/download/", "default_backend_url_base_api": "https://prod-nginz-https.wire.com", From b9ea52dfac3499698a73ce014b8e40891e90fa0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Fri, 17 Jul 2026 15:14:21 +0200 Subject: [PATCH 2/6] feat: default backend none [WPB-26206] [WPB-26586] [WPB-26624] (#5023) Co-authored-by: Sergei Bakhtiarov --- .../android/config/ServerConfigProvider.kt | 24 +- .../wire/android/datastore/GlobalDataStore.kt | 25 ++ .../android/di/ManagedConfigurationsModule.kt | 14 +- .../emm/ManagedConfigurationsManager.kt | 18 +- .../wire/android/emm/ManagedServerConfig.kt | 4 +- .../android/navigation/NavigationUtils.kt | 2 +- .../wire/android/ui/WireActivityViewModel.kt | 28 +- .../ui/authentication/BackendConfigSetup.kt | 242 ++++++++++++++++++ .../ui/authentication/login/LoginNavArgs.kt | 10 + .../ui/authentication/login/LoginScreen.kt | 124 ++++++--- .../ui/authentication/login/LoginViewModel.kt | 14 +- .../login/email/LoginEmailState.kt | 11 +- .../login/email/LoginEmailViewModel.kt | 66 ++++- .../authentication/welcome/WelcomeScreen.kt | 15 ++ .../login/BackendSelectorDropDown.kt | 55 ++-- .../login/NewLoginContainer.kt | 6 +- .../login/NewLoginFlowState.kt | 4 + .../newauthentication/login/NewLoginScreen.kt | 116 +++++++-- .../login/NewLoginViewModel.kt | 159 ++++++++++-- .../wire/android/util/BackendSupportConfig.kt | 59 +++++ .../com/wire/android/util/CustomTabsHelper.kt | 19 +- app/src/main/res/values-de/strings.xml | 13 +- app/src/main/res/values/strings.xml | 12 +- .../emm/ManagedConfigurationsManagerTest.kt | 30 ++- .../login/NewLoginViewModelTest.kt | 192 +++++++++++++- .../kotlin/customization/FeatureConfigs.kt | 1 + .../com/wire/android/ui/common/WireDialog.kt | 7 +- .../main/res/drawable/ic_qr_code_scanner.xml | 27 ++ .../wire/android/util/CustomTabsHelperTest.kt | 61 +++++ default.json | 1 + kalium | 2 +- 31 files changed, 1217 insertions(+), 144 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/authentication/BackendConfigSetup.kt create mode 100644 app/src/main/kotlin/com/wire/android/util/BackendSupportConfig.kt create mode 100644 core/ui-common/src/main/res/drawable/ic_qr_code_scanner.xml create mode 100644 core/ui-common/src/test/kotlin/com/wire/android/util/CustomTabsHelperTest.kt diff --git a/app/src/main/kotlin/com/wire/android/config/ServerConfigProvider.kt b/app/src/main/kotlin/com/wire/android/config/ServerConfigProvider.kt index 53dd69a77ba..2b9bdebe552 100644 --- a/app/src/main/kotlin/com/wire/android/config/ServerConfigProvider.kt +++ b/app/src/main/kotlin/com/wire/android/config/ServerConfigProvider.kt @@ -26,7 +26,7 @@ import javax.inject.Singleton @Singleton class ServerConfigProvider @Inject constructor() { - fun getDefaultServerConfig(managedServerConfig: ManagedServerConfig? = null): ServerConfig.Links { + fun getDefaultServerConfigOrNull(managedServerConfig: ManagedServerConfig? = null): ServerConfig.Links? { return if (managedServerConfig != null) { with(managedServerConfig) { ServerConfig.Links( @@ -38,9 +38,12 @@ class ServerConfigProvider @Inject constructor() { website = endpoints.websiteURL, title = title, isOnPremises = true, // EMM configuration always treated as on-premises - apiProxy = null + apiProxy = null, + supportEmail = supportEmail, ) } + } else if (!BuildConfig.DEFAULT_BACKEND_ENABLED) { + null } else { ServerConfig.Links( api = BuildConfig.DEFAULT_BACKEND_URL_BASE_API, @@ -55,6 +58,23 @@ class ServerConfigProvider @Inject constructor() { ) } } + + fun getDefaultServerConfig(managedServerConfig: ManagedServerConfig? = null): ServerConfig.Links = + getDefaultServerConfigOrNull(managedServerConfig) ?: EmptyServerConfig + + companion object { + val EmptyServerConfig = ServerConfig.Links( + api = "", + accounts = "", + webSocket = "", + teams = "", + blackList = "", + website = "", + title = "", + isOnPremises = false, + apiProxy = null + ) + } } private val staticServerConfigProvider = ServerConfigProvider() diff --git a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt index b81e777bab6..9db4f92f4dd 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt @@ -65,6 +65,9 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex private fun userDoubleTapToastStatusKey(userId: String): Preferences.Key = booleanPreferencesKey("$SHOW_CALLING_DOUBLE_TAP_TOAST$userId") + + private fun backendSupportEmailKey(backendApiUrl: String): Preferences.Key = + stringPreferencesKey("backend_support_email_${backendApiUrl.sha256()}") } suspend fun clear() { @@ -213,4 +216,26 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex suspend fun setPersistentWebSocketEnforcedByMDM(enforced: Boolean) { context.dataStore.edit { it[PERSISTENT_WEBSOCKET_ENFORCED_BY_MDM] = enforced } } + + suspend fun setBackendSupportEmail(backendApiUrl: String, supportEmail: String?) { + if (backendApiUrl.isBlank()) return + + context.dataStore.edit { + val key = backendSupportEmailKey(backendApiUrl) + val normalizedSupportEmail = supportEmail?.trim().orEmpty() + if (normalizedSupportEmail.isBlank()) { + it.remove(key) + } else { + it[key] = normalizedSupportEmail + } + } + } + + suspend fun getBackendSupportEmail(backendApiUrl: String): String? = + if (backendApiUrl.isBlank()) { + null + } else { + context.dataStore.data.firstOrNull()?.get(backendSupportEmailKey(backendApiUrl)) + ?.takeIf { it.isNotBlank() } + } } diff --git a/app/src/main/kotlin/com/wire/android/di/ManagedConfigurationsModule.kt b/app/src/main/kotlin/com/wire/android/di/ManagedConfigurationsModule.kt index ab1fd85f372..965e56ca407 100644 --- a/app/src/main/kotlin/com/wire/android/di/ManagedConfigurationsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/ManagedConfigurationsModule.kt @@ -81,13 +81,25 @@ class ManagedConfigurationsModule { ): ServerConfig.Links { return if (BuildConfig.EMM_SUPPORT_ENABLED) { // Returns the current resolved server configuration links, which could be either managed or default - managedConfigurationsManager.currentServerConfig + managedConfigurationsManager.currentServerConfig ?: ServerConfigProvider.EmptyServerConfig } else { // If EMM support is disabled, always return the static default server configuration links provideServerConfigProvider().getDefaultServerConfig(null) } } + @Provides + @Named("isDefaultBackendConfigured") + fun provideIsDefaultBackendConfigured( + managedConfigurationsManager: ManagedConfigurationsManager + ): Boolean { + return if (BuildConfig.EMM_SUPPORT_ENABLED) { + managedConfigurationsManager.currentServerConfig != null + } else { + BuildConfig.DEFAULT_BACKEND_ENABLED + } + } + @Provides @Named("ssoCodeConfig") fun provideCurrentSSOCodeConfig( diff --git a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt index 66918600564..e51c580adee 100644 --- a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt @@ -22,6 +22,7 @@ import android.content.RestrictionsManager import com.wire.android.appLogger import com.wire.android.config.ServerConfigProvider import com.wire.android.datastore.GlobalDataStore +import com.wire.android.util.BackendSupportConfig import com.wire.android.util.EMPTY import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.configuration.server.ServerConfig @@ -40,7 +41,7 @@ interface ManagedConfigurationsManager { * * @see refreshServerConfig */ - val currentServerConfig: ServerConfig.Links + val currentServerConfig: ServerConfig.Links? /** * Current SSO code if provided via managed configurations, empty string otherwise. @@ -53,10 +54,10 @@ interface ManagedConfigurationsManager { * This should be called when the app starts, resumes, or when broadcast receiver triggers. * * The result indicates whether a valid config was found or if there was an error. - * Nevertheless, the config is either updated or defaulted to [ServerConfigProvider.getDefaultServerConfig()]. + * Nevertheless, the config is either updated or defaulted to [ServerConfigProvider.getDefaultServerConfigOrNull()]. * * @return result of the update attempt, either success with the config, - * default [ServerConfigProvider.getDefaultServerConfig()] if no config found or cleared, or failure with reason. + * default [ServerConfigProvider.getDefaultServerConfigOrNull()] if no config found or cleared, or failure with reason. */ suspend fun refreshServerConfig(): ServerConfigResult @@ -105,8 +106,8 @@ internal class ManagedConfigurationsManagerImpl( MutableStateFlow(runBlocking { globalDataStore.isPersistentWebSocketEnforcedByMDM().first() }) } - override val currentServerConfig: ServerConfig.Links - get() = _currentServerConfig.get() ?: serverConfigProvider.getDefaultServerConfig() + override val currentServerConfig: ServerConfig.Links? + get() = _currentServerConfig.get() ?: serverConfigProvider.getDefaultServerConfigOrNull() override val currentSSOCodeConfig: String get() = _currentSSOCodeConfig.get() @@ -116,15 +117,16 @@ internal class ManagedConfigurationsManagerImpl( override suspend fun refreshServerConfig(): ServerConfigResult = withContext(dispatchers.io()) { val managedServerConfig = getServerConfig() - val serverConfig: ServerConfig.Links = when (managedServerConfig) { + val serverConfig: ServerConfig.Links? = when (managedServerConfig) { is ServerConfigResult.Empty, - is ServerConfigResult.Failure -> serverConfigProvider.getDefaultServerConfig(null) + is ServerConfigResult.Failure -> serverConfigProvider.getDefaultServerConfigOrNull(null) - is ServerConfigResult.Success -> serverConfigProvider.getDefaultServerConfig( + is ServerConfigResult.Success -> serverConfigProvider.getDefaultServerConfigOrNull( managedServerConfig.config ) } _currentServerConfig.set(serverConfig) + serverConfig?.let { BackendSupportConfig.storeFromServerLinks(globalDataStore, it) } logger.i("Server config refreshed: $serverConfig") managedServerConfig } diff --git a/app/src/main/kotlin/com/wire/android/emm/ManagedServerConfig.kt b/app/src/main/kotlin/com/wire/android/emm/ManagedServerConfig.kt index 458ccbeebc6..fc2460a9323 100644 --- a/app/src/main/kotlin/com/wire/android/emm/ManagedServerConfig.kt +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedServerConfig.kt @@ -27,7 +27,9 @@ data class ManagedServerConfig( @SerialName("title") val title: String, @SerialName("endpoints") - val endpoints: ManagedServerLinks + val endpoints: ManagedServerLinks, + @SerialName("supportEmail") + val supportEmail: String? = null ) @Serializable diff --git a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt index a6440802928..bdeda96afff 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt @@ -127,7 +127,7 @@ val Direction.baseRoute: String fun Direction.handleNavigation(context: Context, handleOtherDirection: (Direction) -> Unit) = when (this) { is ExternalUriDirection -> CustomTabsHelper.launchUri(context, this.uri) - is ExternalUriStringResDirection -> CustomTabsHelper.launchUri(context, this.getUri(context.resources)) + is ExternalUriStringResDirection -> CustomTabsHelper.launchUrl(context, context.resources.getString(this.uriStringRes)) is IntentDirection -> context.startActivity(this.intent(context)) else -> handleOtherDirection(this) } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index fc023418c41..557dad51443 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -49,8 +49,10 @@ import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.joinConversation.JoinConversationViaCodeState import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.ThemeOption +import com.wire.android.util.BackendSupportConfig import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager +import com.wire.android.util.CustomTabsHelper import com.wire.android.util.deeplink.DeepLinkProcessor import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.deeplink.LoginType @@ -82,6 +84,7 @@ import com.wire.kalium.logic.feature.session.DoesValidSessionExistResult import com.wire.kalium.logic.feature.session.DoesValidSessionExistUseCase import com.wire.kalium.logic.feature.session.GetAllSessionsResult import com.wire.kalium.logic.feature.session.ObserveSessionsUseCase +import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.logic.feature.user.screenshotCensoring.ObserveScreenshotCensoringConfigResult import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase import com.wire.kalium.util.DateTimeUtil.toIsoDateTimeString @@ -168,6 +171,7 @@ class WireActivityViewModel @Inject constructor( observeAppThemeState() observeSelectedAccent() observeLogoutState() + observeBackendWebsiteUrl() resetNewRegistrationAnalyticsState() viewModelScope.launch(dispatchers.io()) { monitorSyncWorkUseCase() } } @@ -203,6 +207,25 @@ class WireActivityViewModel @Inject constructor( } } + private fun observeBackendWebsiteUrl() { + viewModelScope.launch(dispatchers.io()) { + observeCurrentValidUserId.collectLatest { userId -> + val serverLinks = userId?.let { + runCatching { + when (val result = coreLogic.value.getSessionScope(it).users.serverLinks()) { + is SelfServerConfigUseCase.Result.Success -> result.serverLinks.links + is SelfServerConfigUseCase.Result.Failure -> null + } + }.getOrNull() + } + serverLinks?.let(BackendSupportConfig::setCurrentBackend) + val websiteUrl = serverLinks?.website ?: managedConfigurationsManager.currentServerConfig?.website + + CustomTabsHelper.setBackendWebsiteUrl(websiteUrl) + } + } + } + private fun observeSyncState() { viewModelScope.launch(dispatchers.io()) { observeCurrentValidUserId @@ -456,7 +479,10 @@ class WireActivityViewModel @Inject constructor( private suspend fun loadServerConfig(url: String): ServerConfig.Links? = when (val result = getServerConfigUseCase.get().invoke(url)) { - is GetServerConfigResult.Success -> result.serverConfigLinks + is GetServerConfigResult.Success -> result.serverConfigLinks.also { + CustomTabsHelper.setBackendWebsiteUrl(it.website) + BackendSupportConfig.storeFromServerLinks(globalDataStore.get(), it) + } is GetServerConfigResult.Failure.Generic -> { appLogger.e("something went wrong during handling the custom server deep link: ${result.genericFailure}") null diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/BackendConfigSetup.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/BackendConfigSetup.kt new file mode 100644 index 00000000000..38205d6b2df --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/BackendConfigSetup.kt @@ -0,0 +1,242 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.authentication + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import com.wire.android.R +import com.wire.android.ui.WireActivity +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import com.wire.android.ui.common.spacers.HorizontalSpace +import com.wire.android.ui.common.spacers.VerticalSpace +import com.wire.android.ui.common.textfield.WireTextField +import com.wire.android.ui.common.textfield.WireTextFieldState +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.wireTypography +import com.wire.kalium.logic.configuration.server.ServerConfig +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets.UTF_8 +import kotlinx.coroutines.launch +import com.wire.android.ui.common.R as CommonR + +@Composable +fun MissingBackendConfigContent( + modifier: Modifier = Modifier, + showTitle: Boolean = false, + centerText: Boolean = false, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + errorText: String? = null, + isLoading: Boolean = false, + onConfigurationLinkEntered: ((String) -> Unit)? = null, +) { + val context = LocalContext.current + val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() + val noCameraAppMessage = stringResource(CommonR.string.no_camera_app) + val backendConfigTextState = remember { TextFieldState() } + val isConfigInputEmpty = backendConfigTextState.text.isBlank() + val textAlign = if (centerText) TextAlign.Center else TextAlign.Start + + Column( + modifier = modifier, + verticalArrangement = verticalArrangement, + ) { + if (showTitle) { + Text( + text = stringResource(R.string.missing_backend_config_title), + style = MaterialTheme.wireTypography.title01, + color = MaterialTheme.colorScheme.onBackground, + textAlign = textAlign, + modifier = Modifier.fillMaxWidth(), + ) + VerticalSpace.x16() + } + Text( + text = stringResource(R.string.missing_backend_config_description), + style = typography().body01, + color = colorsScheme().secondaryText, + textAlign = textAlign, + modifier = Modifier.fillMaxWidth(), + ) + VerticalSpace.x16() + WireTextField( + textState = backendConfigTextState, + placeholderText = stringResource(R.string.missing_backend_config_input_placeholder), + labelText = stringResource(R.string.missing_backend_config_input_label), + state = errorText?.let(WireTextFieldState::Error) ?: WireTextFieldState.Default, + keyboardOptions = KeyboardOptions.Default, + modifier = Modifier.testTag("backendConfigInputField"), + testTag = "backendConfigInput", + trailingIcon = { + IconButton( + onClick = { + if (!context.openExternalCamera()) { + coroutineScope.launch { + snackbarHostState.showSnackbar(noCameraAppMessage) + } + } + }, + modifier = Modifier.testTag("backendConfigCameraButton") + ) { + Icon( + imageVector = ImageVector.vectorResource(id = CommonR.drawable.ic_qr_code_scanner), + contentDescription = stringResource(R.string.content_description_backend_config_camera_button), + ) + } + }, + ) + VerticalSpace.x8() + WirePrimaryButton( + text = stringResource(R.string.missing_backend_config_button_setup), + fillMaxWidth = true, + state = if (isConfigInputEmpty || isLoading || onConfigurationLinkEntered == null) { + WireButtonState.Disabled + } else { + WireButtonState.Default + }, + onClick = { (onConfigurationLinkEntered ?: context::openBackendConfig)(backendConfigTextState.text.toString()) }, + modifier = Modifier.testTag("backendConfigContinueButton"), + ) + } +} + +@Composable +fun BackendConfigSuccessContent( + modifier: Modifier = Modifier, + onContinue: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.fillMaxWidth() + ) { + VerticalSpace.x16() + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_validation_check), + tint = colorsScheme().positive, + contentDescription = null, + modifier = Modifier.size(dimensions().spacing16x) + ) + HorizontalSpace.x8() + Text( + text = stringResource(R.string.backend_config_success_title), + style = typography().body01, + color = colorsScheme().onSurface, + ) + } + VerticalSpace.x8() + Text( + text = stringResource(R.string.backend_config_success_description), + style = typography().body01, + color = colorsScheme().secondaryText, + ) + VerticalSpace.x24() + WirePrimaryButton( + text = stringResource(R.string.label_continue), + onClick = onContinue, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .testTag("backendConfigSuccessContinueButton") + ) + } +} + +fun ServerConfig.Links.isConfigured() = api.isNotBlank() + +@Suppress("ReturnCount") +fun String.toBackendConfigUrl(): String? { + val sanitizedInput = trim().takeIf(String::isNotBlank) ?: return null + + if (!sanitizedInput.startsWith(WIRE_ACCESS_DEEPLINK_BASE)) { + return sanitizedInput + } + + return runCatching { + URI.create(sanitizedInput) + .rawQuery + ?.split('&') + ?.firstOrNull { it.substringBefore('=') == BACKEND_CONFIG_QUERY_PARAMETER } + ?.substringAfter('=', missingDelimiterValue = "") + ?.let { URLDecoder.decode(it, UTF_8.name()) } + ?.takeIf(String::isNotBlank) + }.getOrNull() +} + +fun Context.openBackendConfig(input: String) { + val sanitizedInput = input.trim() + if (sanitizedInput.isEmpty()) return + + val deepLinkUri = if (sanitizedInput.startsWith(WIRE_ACCESS_DEEPLINK_PREFIX)) { + Uri.parse(sanitizedInput) + } else { + Uri.parse("$WIRE_ACCESS_DEEPLINK_PREFIX${Uri.encode(sanitizedInput)}") + } + + startActivity( + Intent(this, WireActivity::class.java).apply { + data = deepLinkUri + } + ) +} + +fun Context.openExternalCamera(): Boolean { + return try { + startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA)) + true + } catch (_: ActivityNotFoundException) { + false + } +} + +private const val WIRE_ACCESS_DEEPLINK_BASE = "wire://access/" +private const val BACKEND_CONFIG_QUERY_PARAMETER = "config" +private const val WIRE_ACCESS_DEEPLINK_PREFIX = "$WIRE_ACCESS_DEEPLINK_BASE?$BACKEND_CONFIG_QUERY_PARAMETER=" diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginNavArgs.kt index 2fd43daec6e..ec217cc1140 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginNavArgs.kt @@ -26,6 +26,16 @@ data class LoginNavArgs( val userHandle: PreFilledUserIdentifierType.PreFilled? = null, val ssoLoginResult: DeepLinkResult.SSOLogin? = null, val loginPasswordPath: LoginPasswordPath? = null, + val ssoCodeAutoLogin: SSOCodeAutoLogin? = null, + val showBackendConfigSuccess: Boolean = false, +) + +@Serializable +data class SSOCodeAutoLogin( + val ssoCode: String, + val autoInitiateLogin: Boolean = true, + val nomadServiceUrl: String? = null, + val cookieLabel: String? = null, ) @Serializable diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt index cae108743da..59bd46f39ac 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt @@ -55,8 +55,11 @@ import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.annotation.app.WireLoginDestination import com.wire.android.navigation.style.TransitionAnimationType +import com.wire.android.ui.authentication.BackendConfigSuccessContent +import com.wire.android.ui.authentication.MissingBackendConfigContent import com.wire.android.ui.authentication.create.common.ServerTitle import com.wire.android.ui.authentication.login.email.LoginEmailScreen +import com.wire.android.ui.authentication.login.email.LoginEmailState import com.wire.android.ui.authentication.login.email.LoginEmailVerificationCodeScreen import com.wire.android.ui.authentication.login.email.LoginEmailViewModel import com.wire.android.ui.authentication.login.sso.LoginSSOScreen @@ -148,6 +151,7 @@ private fun LoginContent( } @OptIn(ExperimentalFoundationApi::class) +@Suppress("CyclomaticComplexMethod") @Composable private fun MainLoginContent( onBackPressed: () -> Unit, @@ -159,6 +163,9 @@ private fun MainLoginContent( val scope = rememberCoroutineScope() val scrollState = rememberScrollState() + val isBackendConfigured = loginEmailViewModel.isBackendConfigured + val backendConfigState = loginEmailViewModel.loginState.backendConfigState + val shouldShowBackendSetup = !isBackendConfigured || backendConfigState == LoginEmailState.BackendConfigState.Success val initialPageIndex = if (ssoLoginResult == null) LoginTabItem.EMAIL.ordinal else LoginTabItem.SSO.ordinal val pagerState = rememberPagerState( initialPage = initialPageIndex, @@ -172,9 +179,15 @@ private fun MainLoginContent( topBar = { WireCenterAlignedTopAppBar( elevation = scrollState.rememberTopBarElevationState().value, - title = stringResource(R.string.login_title), + title = stringResource( + if (!shouldShowBackendSetup) { + R.string.login_title + } else { + R.string.missing_backend_config_title + } + ), subtitleContent = { - if (loginEmailViewModel.serverConfig.isOnPremises) { + if (!shouldShowBackendSetup && loginEmailViewModel.serverConfig.isOnPremises) { ServerTitle( serverLinks = loginEmailViewModel.serverConfig, style = MaterialTheme.wireTypography.body01 @@ -184,28 +197,30 @@ private fun MainLoginContent( onNavigationPressed = onBackPressed, navigationIconType = NavigationIconType.Back(R.string.content_description_login_back_btn) ) { - WireTabRow( - tabs = LoginTabItem.values().toList(), - selectedTabIndex = pagerState.calculateCurrentTab(), - onTabChange = { + if (!shouldShowBackendSetup) { + WireTabRow( + tabs = LoginTabItem.values().toList(), + selectedTabIndex = pagerState.calculateCurrentTab(), + onTabChange = { - if (loginEmailViewModel.serverConfig.isProxyEnabled) { - if (pagerState.currentPage != LoginTabItem.SSO.ordinal) { - ssoDisabledWithProxyDialogState.show( - ssoDisabledWithProxyDialogState.savedState ?: FeatureDisabledWithProxyDialogState( - R.string.sso_not_supported_dialog_description + if (loginEmailViewModel.serverConfig.isProxyEnabled) { + if (pagerState.currentPage != LoginTabItem.SSO.ordinal) { + ssoDisabledWithProxyDialogState.show( + ssoDisabledWithProxyDialogState.savedState ?: FeatureDisabledWithProxyDialogState( + R.string.sso_not_supported_dialog_description + ) ) - ) + } + } else { + scope.launch { pagerState.animateScrollToPage(it) } } - } else { - scope.launch { pagerState.animateScrollToPage(it) } - } - }, - modifier = Modifier.padding( - start = MaterialTheme.wireDimensions.spacing16x, - end = MaterialTheme.wireDimensions.spacing16x - ), - ) + }, + modifier = Modifier.padding( + start = MaterialTheme.wireDimensions.spacing16x, + end = MaterialTheme.wireDimensions.spacing16x + ), + ) + } } }, modifier = Modifier.fillMaxHeight(), @@ -214,30 +229,55 @@ private fun MainLoginContent( val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - CompositionLocalProvider(LocalOverscrollConfiguration provides null) { - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxWidth() - .padding(internalPadding) - ) { pageIndex -> - when (LoginTabItem.values()[pageIndex]) { - LoginTabItem.EMAIL -> LoginEmailScreen(onSuccess, onRemoveDeviceNeeded, loginEmailViewModel, scrollState) - LoginTabItem.SSO -> LoginSSOScreen( - onSuccess, - onRemoveDeviceNeeded, - ssoLoginResult, -// ssoUrlConfigHolder - ) + if (!shouldShowBackendSetup) { + CompositionLocalProvider(LocalOverscrollConfiguration provides null) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .padding(internalPadding) + ) { pageIndex -> + when (LoginTabItem.values()[pageIndex]) { + LoginTabItem.EMAIL -> LoginEmailScreen(onSuccess, onRemoveDeviceNeeded, loginEmailViewModel, scrollState) + LoginTabItem.SSO -> LoginSSOScreen( + onSuccess, + onRemoveDeviceNeeded, + ssoLoginResult, + ) + } } - } - if (!pagerState.isScrollInProgress && focusedTabIndex != pagerState.currentPage) { - LaunchedEffect(Unit) { - keyboardController?.hide() - focusManager.clearFocus() - focusedTabIndex = pagerState.currentPage + if (!pagerState.isScrollInProgress && focusedTabIndex != pagerState.currentPage) { + LaunchedEffect(Unit) { + keyboardController?.hide() + focusManager.clearFocus() + focusedTabIndex = pagerState.currentPage + } } } + } else { + if (backendConfigState == LoginEmailState.BackendConfigState.Success) { + BackendConfigSuccessContent( + modifier = Modifier + .fillMaxWidth() + .padding(internalPadding) + .padding(MaterialTheme.wireDimensions.spacing16x), + onContinue = loginEmailViewModel::onBackendConfigSuccessContinue, + ) + } else { + MissingBackendConfigContent( + modifier = Modifier + .fillMaxWidth() + .padding(internalPadding) + .padding(MaterialTheme.wireDimensions.spacing16x), + errorText = if (backendConfigState == LoginEmailState.BackendConfigState.Error) { + stringResource(R.string.missing_backend_config_error) + } else { + null + }, + isLoading = backendConfigState == LoginEmailState.BackendConfigState.Loading, + onConfigurationLinkEntered = loginEmailViewModel::onBackendConfigLinkEntered, + ) + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginViewModel.kt index 82ebdd89d0d..8fb5c89bcae 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginViewModel.kt @@ -20,6 +20,9 @@ package com.wire.android.ui.authentication.login import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.di.ClientScopeProvider import com.wire.android.di.KaliumCoreLogic @@ -43,7 +46,8 @@ open class LoginViewModel( val userDataStoreProvider: UserDataStoreProvider, val coreLogic: CoreLogic, private val loginExtension: LoginViewModelExtension, - defaultServerConfig: ServerConfig.Links + defaultServerConfig: ServerConfig.Links, + isDefaultBackendConfigured: Boolean = true, ) : ViewModel() { @Inject @@ -63,7 +67,13 @@ open class LoginViewModel( ) private val loginNavArgs: LoginNavArgs = savedStateHandle.navArgs() - val serverConfig: ServerConfig.Links = loginNavArgs.loginPasswordPath?.customServerConfig ?: defaultServerConfig + var serverConfig: ServerConfig.Links by mutableStateOf(loginNavArgs.loginPasswordPath?.customServerConfig ?: defaultServerConfig) + protected set + var isBackendConfigured: Boolean by mutableStateOf( + isDefaultBackendConfigured || + loginNavArgs.loginPasswordPath?.customServerConfig?.api?.isNotBlank() == true + ) + protected set suspend fun registerClient( userId: UserId, diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailState.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailState.kt index 5bcede8d076..7ed352396de 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailState.kt @@ -23,4 +23,13 @@ data class LoginEmailState( val userIdentifierEnabled: Boolean = true, val loginEnabled: Boolean = false, val flowState: LoginState = LoginState.Default, -) + val backendConfigState: BackendConfigState = BackendConfigState.Missing, + val showInvalidCredentialsError: Boolean = false, +) { + enum class BackendConfigState { + Missing, + Loading, + Error, + Success, + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt index b4d4a755dd4..db86c0a1ddb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt @@ -28,12 +28,15 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.ClientScopeProvider import com.wire.android.di.DefaultWebSocketEnabledByDefault import com.wire.android.di.KaliumCoreLogic +import com.wire.android.ui.authentication.toBackendConfigUrl import com.wire.android.ui.authentication.login.LoginNavArgs import com.wire.android.ui.authentication.login.LoginState import com.wire.android.ui.authentication.login.LoginViewModel +import com.wire.android.ui.authentication.login.LoginViewModelExtension import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType import com.wire.android.ui.authentication.login.isProxyAuthRequired import com.wire.android.ui.authentication.login.toLoginError @@ -41,6 +44,8 @@ import com.wire.android.ui.authentication.verificationcode.VerificationCodeState import com.wire.android.ui.common.textfield.textAsFlow import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.EMPTY +import com.wire.android.util.BackendSupportConfig +import com.wire.android.util.CustomTabsHelper import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.CountdownTimer import com.wire.kalium.logic.CoreLogic @@ -56,6 +61,8 @@ import com.wire.kalium.logic.feature.auth.PersistSelfUserEmailResult import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.auth.verification.RequestSecondFactorVerificationCodeUseCase import com.wire.kalium.logic.feature.client.RegisterClientResult +import com.wire.kalium.logic.feature.server.GetServerConfigResult +import com.wire.kalium.logic.feature.server.GetServerConfigUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -81,12 +88,17 @@ class LoginEmailViewModel @Inject constructor( private val dispatchers: DispatcherProvider, defaultServerConfig: ServerConfig.Links, @DefaultWebSocketEnabledByDefault private val defaultWebSocketEnabledByDefault: Boolean, + isDefaultBackendConfigured: Boolean = true, + private val getServerConfigUseCase: Lazy? = null, + private val globalDataStore: Lazy? = null, ) : LoginViewModel( savedStateHandle, clientScopeProviderFactory, userDataStoreProvider, coreLogic, - defaultServerConfig + LoginViewModelExtension(clientScopeProviderFactory, userDataStoreProvider), + defaultServerConfig, + isDefaultBackendConfigured, ) { val loginNavArgs: LoginNavArgs = savedStateHandle.navArgs() private val preFilledUserIdentifier: PreFilledUserIdentifierType = loginNavArgs.userHandle ?: PreFilledUserIdentifierType.None @@ -136,10 +148,60 @@ class LoginEmailViewModel @Inject constructor( } } - private fun updateEmailFlowState(flowState: LoginState) { + fun onBackendConfigLinkEntered(input: String) { + viewModelScope.launch(dispatchers.io()) { + val configUrl = input.toBackendConfigUrl() + if (configUrl == null) { + withContext(dispatchers.main()) { + updateBackendConfigState(LoginEmailState.BackendConfigState.Error) + } + return@launch + } + + withContext(dispatchers.main()) { + updateBackendConfigState(LoginEmailState.BackendConfigState.Loading) + } + when (val result = getServerConfigUseCase?.value?.invoke(configUrl)) { + is GetServerConfigResult.Success -> { + CustomTabsHelper.setBackendWebsiteUrl(result.serverConfigLinks.website) + globalDataStore?.let { + BackendSupportConfig.storeFromServerLinks(it.value, result.serverConfigLinks) + } + withContext(dispatchers.main()) { + serverConfig = result.serverConfigLinks + isBackendConfigured = true + updateBackendConfigState(LoginEmailState.BackendConfigState.Success) + } + } + + is GetServerConfigResult.Failure.Generic, + null -> withContext(dispatchers.main()) { + updateBackendConfigState(LoginEmailState.BackendConfigState.Error) + } + } + } + } + + fun onBackendConfigSuccessContinue() { + updateBackendConfigState(LoginEmailState.BackendConfigState.Missing) + } + + private fun updateBackendConfigState(state: LoginEmailState.BackendConfigState) { + loginState = loginState.copy(backendConfigState = state) + } + + private fun updateEmailFlowState( + flowState: LoginState, + showInvalidCredentialsError: Boolean = when (flowState) { + LoginState.Error.DialogError.InvalidCredentialsError -> true + LoginState.Default -> loginState.showInvalidCredentialsError + else -> false + } + ) { val proxyFieldsNotEmpty = proxyIdentifierTextState.text.isNotEmpty() && proxyPasswordTextState.text.isNotEmpty() loginState = loginState.copy( flowState = flowState, + showInvalidCredentialsError = showInvalidCredentialsError, loginEnabled = userIdentifierTextState.text.isNotEmpty() && passwordTextState.text.isNotEmpty() && (!serverConfig.isProxyAuthRequired || proxyFieldsNotEmpty) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt index c34b77bd366..60afb06597a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt @@ -73,8 +73,10 @@ import com.wire.android.config.LocalCustomUiConfigurationProvider import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.authentication.MissingBackendConfigContent import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.authentication.create.common.ServerTitle +import com.wire.android.ui.authentication.isConfigured import com.wire.android.ui.authentication.login.LoginPasswordPath import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton @@ -159,6 +161,19 @@ private fun WelcomeContent( maxAccountsReachedDialogState.show(maxAccountsReachedDialogState.savedState ?: MaxAccountsReachedDialogState) } + if (!state.isConfigured()) { + MissingBackendConfigContent( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MaterialTheme.wireDimensions.welcomeButtonHorizontalPadding) + .weight(1f, true), + showTitle = true, + centerText = true, + verticalArrangement = Arrangement.Center, + ) + return@Column + } + Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_wire_logo), tint = MaterialTheme.colorScheme.onBackground, diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/BackendSelectorDropDown.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/BackendSelectorDropDown.kt index dcc17e57f5f..75589f6814e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/BackendSelectorDropDown.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/BackendSelectorDropDown.kt @@ -48,9 +48,12 @@ import com.wire.android.ui.common.WireDropDown import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.forceLowercase +import kotlinx.collections.immutable.toImmutableList @Composable -internal fun BackendSelectorDropDown() { +internal fun BackendSelectorDropDown( + onNoBackendSelected: () -> Unit = {}, +) { val context = LocalContext.current var showCustomBackendDialog by remember { mutableStateOf(false) } @@ -63,16 +66,15 @@ internal fun BackendSelectorDropDown() { ) { WireDropDown( modifier = Modifier.alpha(0.5f), - items = backendConfigs.map { it.first }, + items = backendConfigs.map { it.name }.toImmutableList(), label = null, autoUpdateSelection = false, placeholder = "Change application backend", onSelected = { index -> - val backend = backendConfigs[index].second - if (backend.isNotEmpty()) { - openConfigUrl(context, backend) - } else { - showCustomBackendDialog = true + when (val backend = backendConfigs[index]) { + BackendConfig.Custom -> showCustomBackendDialog = true + BackendConfig.NoBackend -> onNoBackendSelected() + is BackendConfig.Predefined -> openConfigUrl(context, backend.configUrl) } }, ) @@ -145,15 +147,34 @@ private fun openConfigUrl(context: Context, configUrl: String) { ) } +private sealed interface BackendConfig { + val name: String + + data class Predefined( + override val name: String, + val configUrl: String, + ) : BackendConfig + + data object Custom : BackendConfig { + override val name: String = "Custom" + } + + data object NoBackend : BackendConfig { + override val name: String = "No backend" + } +} + private val backendConfigs = listOf( - "Production" to "https://prod-nginz-https.wire.com/deeplink.json", - "Staging" to "https://staging-nginz-https.zinfra.io/deeplink.json", - "Anta" to "https://nginz-https.anta.wire.link/deeplink.json", - "Bella" to "https://nginz-https.bella.wire.link/deeplink.json", - "Chala" to "https://nginz-https.chala.wire.link/deeplink.json", - "Elna" to "https://nginz-https.elna.wire.link/deeplink.json", - "Foma" to "https://nginz-https.foma.wire.link/deeplink.json", - "Imai" to "https://nginz-https.imai.wire.link/deeplink.json", - "Fulu" to "https://nginz-https.fulu.wire.link/deeplink.json", - "Custom" to "", + BackendConfig.Predefined("Production", "https://prod-nginz-https.wire.com/deeplink.json"), + BackendConfig.Predefined("Staging", "https://staging-nginz-https.zinfra.io/deeplink.json"), + BackendConfig.Predefined("Anta", "https://nginz-https.anta.wire.link/deeplink.json"), + BackendConfig.Predefined("Bella", "https://nginz-https.bella.wire.link/deeplink.json"), + BackendConfig.Predefined("Chala", "https://nginz-https.chala.wire.link/deeplink.json"), + BackendConfig.Predefined("Elna", "https://nginz-https.elna.wire.link/deeplink.json"), + BackendConfig.Predefined("Foma", "https://nginz-https.foma.wire.link/deeplink.json"), + BackendConfig.Predefined("Imai", "https://nginz-https.imai.wire.link/deeplink.json"), + BackendConfig.Predefined("Fulu", "https://nginz-https.fulu.wire.link/deeplink.json"), + BackendConfig.Predefined("Mira", "https://nginz-https.mira.wire.link/deeplink.json"), + BackendConfig.NoBackend, + BackendConfig.Custom, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginContainer.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginContainer.kt index 4201e2ad4b5..75e1b0a7c01 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginContainer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginContainer.kt @@ -60,6 +60,8 @@ import com.wire.android.util.ui.PreviewMultipleThemes fun NewAuthContainer( header: @Composable () -> Unit = {}, contentPadding: Dp = dimensions().spacing24x, + showBackendSelector: Boolean = false, + onNoBackendSelected: () -> Unit = {}, content: @Composable () -> Unit ) { val scrollState = rememberScrollState() @@ -67,8 +69,8 @@ fun NewAuthContainer( WireScaffold( containerColor = Color.Transparent, topBar = { - if (BuildConfig.PRIVATE_BUILD) { - BackendSelectorDropDown() + if (BuildConfig.PRIVATE_BUILD && showBackendSelector) { + BackendSelectorDropDown(onNoBackendSelected = onNoBackendSelected) } }, bottomBar = { diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginFlowState.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginFlowState.kt index 5ee7f35e3f9..88acf165559 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginFlowState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginFlowState.kt @@ -26,6 +26,10 @@ import com.wire.kalium.logic.configuration.server.ServerConfig sealed class NewLoginFlowState { data object Default : NewLoginFlowState() data object Loading : NewLoginFlowState() + data object MissingBackendConfig : NewLoginFlowState() + data object LoadingBackendConfig : NewLoginFlowState() + data object BackendConfigError : NewLoginFlowState() + data object BackendConfigSuccess : NewLoginFlowState() data class CustomConfigDialog(val serverLinks: ServerConfig.Links) : NewLoginFlowState() sealed class Error : NewLoginFlowState() { sealed class TextFieldError : Error() { diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginScreen.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginScreen.kt index 431f18e39b5..85f21160786 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginScreen.kt @@ -68,6 +68,8 @@ import com.wire.android.ui.authentication.login.LoginPasswordPath import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout import com.wire.android.ui.authentication.login.toLoginDialogErrorData +import com.wire.android.ui.authentication.BackendConfigSuccessContent +import com.wire.android.ui.authentication.MissingBackendConfigContent import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton @@ -171,6 +173,9 @@ fun NewLoginScreen( onNextClicked = { viewModel.onLoginStarted() }, + onBackendConfigLinkEntered = viewModel::onBackendConfigLinkEntered, + onBackendConfigSuccessContinue = viewModel::onBackendConfigSuccessContinue, + onNoBackendSelected = viewModel::onNoBackendSelected, canNavigateBack = navigator.navController.previousBackStackEntry != null, // if there is a previous screen to navigate back to navigateBack = navigator::navigateBack, ) @@ -179,16 +184,27 @@ fun NewLoginScreen( } @OptIn(ExperimentalComposeUiApi::class) +@Suppress("CyclomaticComplexMethod") @Composable private fun LoginContent( loginEmailSSOState: NewLoginScreenState, userIdentifierState: TextFieldState, serverConfig: ServerConfig.Links, - onNextClicked: () -> Unit, canNavigateBack: Boolean, + onNextClicked: () -> Unit, + onBackendConfigLinkEntered: (String) -> Unit, + onBackendConfigSuccessContinue: () -> Unit, + onNoBackendSelected: () -> Unit = {}, navigateBack: () -> Unit, ) { + val isBackendConfigSetup = loginEmailSSOState.flowState == NewLoginFlowState.MissingBackendConfig || + loginEmailSSOState.flowState == NewLoginFlowState.LoadingBackendConfig || + loginEmailSSOState.flowState == NewLoginFlowState.BackendConfigError + val isBackendConfigSuccess = loginEmailSSOState.flowState == NewLoginFlowState.BackendConfigSuccess + val showCredentialsSubtitle = !isBackendConfigSetup && !isBackendConfigSuccess NewAuthContainer( + showBackendSelector = true, + onNoBackendSelected = onNoBackendSelected, header = { NewAuthHeader( title = { @@ -198,11 +214,17 @@ private fun LoginContent( style = typography().title01, textColor = colorsScheme().onSurface, titleResId = R.string.enterprise_login_on_prem_welcome_title, - modifier = Modifier.padding(bottom = dimensions().spacing24x), - ) - NewAuthSubtitle( - title = stringResource(id = R.string.enterprise_login_credentials_title), + modifier = if (showCredentialsSubtitle) { + Modifier.padding(bottom = dimensions().spacing24x) + } else { + Modifier + }, ) + if (showCredentialsSubtitle) { + NewAuthSubtitle( + title = stringResource(id = R.string.enterprise_login_credentials_title), + ) + } } else { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_wire_logo), @@ -213,7 +235,11 @@ private fun LoginContent( .size(dimensions().spacing120x) ) NewAuthSubtitle( - title = stringResource(R.string.enterprise_login_welcome), + title = when { + isBackendConfigSetup -> stringResource(R.string.missing_backend_config_title) + isBackendConfigSuccess -> "" + else -> stringResource(R.string.enterprise_login_welcome) + }, modifier = Modifier.padding(top = dimensions().spacing16x) ) } @@ -235,19 +261,33 @@ private fun LoginContent( testTagsAsResourceId = true } ) { - val error = when (loginEmailSSOState.flowState) { - is NewLoginFlowState.Error.TextFieldError.InvalidValue -> - stringResource(R.string.enterprise_login_error_invalid_user_identifier) + if (isBackendConfigSuccess) { + BackendConfigSuccessContent(onContinue = onBackendConfigSuccessContinue) + } else if (isBackendConfigSetup) { + MissingBackendConfigContent( + errorText = if (loginEmailSSOState.flowState == NewLoginFlowState.BackendConfigError) { + stringResource(R.string.missing_backend_config_error) + } else { + null + }, + isLoading = loginEmailSSOState.flowState == NewLoginFlowState.LoadingBackendConfig, + onConfigurationLinkEntered = onBackendConfigLinkEntered, + ) + } else { + val error = when (loginEmailSSOState.flowState) { + is NewLoginFlowState.Error.TextFieldError.InvalidValue -> + stringResource(R.string.enterprise_login_error_invalid_user_identifier) - else -> null + else -> null + } + EmailOrSSOCodeInput(userIdentifierState, error) + VerticalSpace.x8() + LoginNextButton( + loading = loginEmailSSOState.flowState is NewLoginFlowState.Loading, + enabled = loginEmailSSOState.nextEnabled, + onClick = onNextClicked, + ) } - EmailOrSSOCodeInput(userIdentifierState, error) - VerticalSpace.x8() - LoginNextButton( - loading = loginEmailSSOState.flowState is NewLoginFlowState.Loading, - enabled = loginEmailSSOState.nextEnabled, - onClick = onNextClicked, - ) } } } @@ -306,6 +346,8 @@ fun PreviewNewLoginScreen() = WireTheme { userIdentifierState = TextFieldState(), serverConfig = ServerConfig.DEFAULT.copy(isOnPremises = false), onNextClicked = {}, + onBackendConfigLinkEntered = {}, + onBackendConfigSuccessContinue = {}, canNavigateBack = false, navigateBack = {}, ) @@ -323,6 +365,46 @@ fun PreviewNewLoginScreenCustomConfig() = WireTheme { userIdentifierState = TextFieldState(), serverConfig = ServerConfig.DEFAULT.copy(isOnPremises = true), onNextClicked = {}, + onBackendConfigLinkEntered = {}, + onBackendConfigSuccessContinue = {}, + canNavigateBack = false, + navigateBack = {}, + ) + } + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewNewLoginScreenMissingBackendConfig() = WireTheme { + EdgeToEdgePreview(useDarkIcons = false) { + WireAuthBackgroundLayout { + LoginContent( + loginEmailSSOState = NewLoginScreenState(flowState = NewLoginFlowState.MissingBackendConfig), + userIdentifierState = TextFieldState(), + serverConfig = ServerConfig.DEFAULT.copy(isOnPremises = false), + onNextClicked = {}, + onBackendConfigLinkEntered = {}, + onBackendConfigSuccessContinue = {}, + canNavigateBack = false, + navigateBack = {}, + ) + } + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewNewLoginScreenBackendConfigSuccess() = WireTheme { + EdgeToEdgePreview(useDarkIcons = false) { + WireAuthBackgroundLayout { + LoginContent( + loginEmailSSOState = NewLoginScreenState(flowState = NewLoginFlowState.BackendConfigSuccess), + userIdentifierState = TextFieldState(), + serverConfig = ServerConfig.DEFAULT.copy(isOnPremises = false), + onNextClicked = {}, + onBackendConfigLinkEntered = {}, + onBackendConfigSuccessContinue = {}, canNavigateBack = false, navigateBack = {}, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt index 5ab87a88f2a..b3d2c8b7e91 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt @@ -28,6 +28,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.appLogger +import com.wire.android.config.ServerConfigProvider +import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.di.ClientScopeProvider import com.wire.android.di.DefaultWebSocketEnabledByDefault @@ -41,8 +43,11 @@ import com.wire.android.ui.authentication.login.email.LoginEmailViewModel.Compan import com.wire.android.ui.authentication.login.sso.LoginSSOViewModelExtension import com.wire.android.ui.authentication.login.sso.SSOUrlConfig import com.wire.android.ui.authentication.login.sso.ssoCodeWithPrefix +import com.wire.android.ui.authentication.toBackendConfigUrl import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.textfield.textAsFlow +import com.wire.android.util.BackendSupportConfig +import com.wire.android.util.CustomTabsHelper import com.wire.android.util.EMPTY import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.dispatchers.DispatcherProvider @@ -57,6 +62,8 @@ import com.wire.kalium.logic.feature.auth.sso.FetchSSOSettingsUseCase import com.wire.kalium.logic.feature.auth.sso.SSOInitiateLoginResult import com.wire.kalium.logic.feature.auth.sso.SSOLoginSessionResult import com.wire.kalium.logic.feature.client.RegisterClientResult +import com.wire.kalium.logic.feature.server.GetServerConfigResult +import com.wire.kalium.logic.feature.server.GetServerConfigUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -79,6 +86,9 @@ class NewLoginViewModel( private val dispatchers: DispatcherProvider, defaultServerConfig: ServerConfig.Links, defaultSSOCodeConfig: String, + private val isDefaultBackendConfigured: Boolean, + private val getServerConfigUseCase: Lazy? = null, + private val globalDataStore: Lazy? = null, ) : ActionsViewModel() { @Inject @@ -91,7 +101,10 @@ class NewLoginViewModel( userDataStoreProvider: UserDataStoreProvider, dispatchers: DispatcherProvider, defaultServerConfig: ServerConfig.Links, + getServerConfigUseCase: Lazy, + globalDataStore: Lazy, @Named("ssoCodeConfig") defaultSSOCodeConfig: String, + @Named("isDefaultBackendConfigured") isDefaultBackendConfigured: Boolean, @DefaultWebSocketEnabledByDefault defaultWebSocketEnabledByDefault: Boolean, ) : this( validateEmailOrSSOCode, @@ -103,12 +116,26 @@ class NewLoginViewModel( LoginSSOViewModelExtension(addAuthenticatedUser, coreLogic, defaultWebSocketEnabledByDefault), dispatchers, defaultServerConfig, - defaultSSOCodeConfig + defaultSSOCodeConfig, + isDefaultBackendConfigured, + getServerConfigUseCase, + globalDataStore, ) private val loginNavArgs: LoginNavArgs = savedStateHandle.navArgs() private val preFilledUserIdentifier: PreFilledUserIdentifierType = loginNavArgs.userHandle ?: PreFilledUserIdentifierType.None - var serverConfig: ServerConfig.Links by mutableStateOf(loginNavArgs.loginPasswordPath?.customServerConfig ?: defaultServerConfig) + private val customServerConfig = loginNavArgs.loginPasswordPath?.customServerConfig + private val isCustomServerConfigured = customServerConfig?.api?.isNotBlank() == true + private val isDefaultServerConfigured = isDefaultBackendConfigured && defaultServerConfig.api.isNotBlank() + private val shouldShowBackendConfigSuccess = loginNavArgs.showBackendConfigSuccess && isCustomServerConfigured + private var canUseBackend by mutableStateOf( + if (customServerConfig != null) { + isCustomServerConfigured + } else { + isDefaultServerConfigured + } + ) + var serverConfig: ServerConfig.Links by mutableStateOf(customServerConfig ?: defaultServerConfig) private set var state by mutableStateOf(NewLoginScreenState()) @@ -116,11 +143,10 @@ class NewLoginViewModel( val userIdentifierTextState: TextFieldState = TextFieldState() init { - val isCustomServerDeepLink = loginNavArgs.loginPasswordPath?.customServerConfig != null userIdentifierTextState.setTextAndPlaceCursorAtEnd( if (preFilledUserIdentifier is PreFilledUserIdentifierType.PreFilled) { preFilledUserIdentifier.userIdentifier - } else if (defaultSSOCodeConfig.isNotEmpty() && !isCustomServerDeepLink) { + } else if (defaultSSOCodeConfig.isNotEmpty() && customServerConfig == null) { defaultSSOCodeConfig.ssoCodeWithPrefix() } else { savedStateHandle[USER_IDENTIFIER_SAVED_STATE_KEY] ?: String.EMPTY @@ -131,35 +157,74 @@ class NewLoginViewModel( savedStateHandle[USER_IDENTIFIER_SAVED_STATE_KEY] = it.toString() }.collectLatest { getAndUpdateLoginFlowState { currentState: NewLoginFlowState -> - if (currentState is NewLoginFlowState.Error.TextFieldError) NewLoginFlowState.Default else currentState + if (!canUseBackend) { + when (currentState) { + NewLoginFlowState.LoadingBackendConfig, + NewLoginFlowState.BackendConfigError, + NewLoginFlowState.BackendConfigSuccess -> currentState + else -> NewLoginFlowState.MissingBackendConfig + } + } else if (currentState is NewLoginFlowState.Error.TextFieldError) { + NewLoginFlowState.Default + } else { + currentState + } } } } - // Fetch default SSO code for the server configuration + if (!canUseBackend) { + updateLoginFlowState(NewLoginFlowState.MissingBackendConfig) + } else if (shouldShowBackendConfigSuccess) { + updateLoginFlowState(NewLoginFlowState.BackendConfigSuccess) + } else if (userIdentifierTextState.text.isEmpty() && preFilledUserIdentifier is PreFilledUserIdentifierType.None) { + fetchDefaultSSOCodeIfNeeded(savedStateHandle) + } + } + + fun onBackendConfigSuccessContinue() { + updateLoginFlowState(NewLoginFlowState.Default) if (userIdentifierTextState.text.isEmpty() && preFilledUserIdentifier is PreFilledUserIdentifierType.None) { - viewModelScope.launch(dispatchers.io()) { - appLogger.d("NewLoginViewModel: Fetching default SSO code for server") - ssoExtension.fetchDefaultSSOCode( - serverConfig = serverConfig, - onAuthScopeFailure = { error -> - appLogger.e("NewLoginViewModel: Failed to create auth scope for SSO settings: $error") - }, - onFetchSSOSettingsFailure = { error -> - appLogger.e("NewLoginViewModel: Failed to fetch SSO settings: $error") - }, - onSuccess = { defaultSSOCode -> - if (defaultSSOCode != null && userIdentifierTextState.text.isEmpty()) { - appLogger.d("NewLoginViewModel: Successfully fetched default SSO code") - withContext(dispatchers.main()) { - userIdentifierTextState.setTextAndPlaceCursorAtEnd(defaultSSOCode) - savedStateHandle[USER_IDENTIFIER_SAVED_STATE_KEY] = defaultSSOCode - } - } else { - appLogger.d("NewLoginViewModel: No default SSO code configured for this server") - } + fetchDefaultSSOCodeIfNeeded() + } + } + + fun onNoBackendSelected() { + serverConfig = ServerConfigProvider.EmptyServerConfig + canUseBackend = false + CustomTabsHelper.setBackendWebsiteUrl(null) + updateLoginFlowState(NewLoginFlowState.MissingBackendConfig) + } + + fun onBackendConfigLinkEntered(input: String) { + viewModelScope.launch(dispatchers.io()) { + val configUrl = input.toBackendConfigUrl() + if (configUrl == null) { + updateLoginFlowState(NewLoginFlowState.BackendConfigError) + return@launch + } + + updateLoginFlowState(NewLoginFlowState.LoadingBackendConfig) + when (val result = getServerConfigUseCase?.value?.invoke(configUrl)) { + is GetServerConfigResult.Success -> { + CustomTabsHelper.setBackendWebsiteUrl(result.serverConfigLinks.website) + globalDataStore?.let { + BackendSupportConfig.storeFromServerLinks(it.value, result.serverConfigLinks) } - ) + withContext(dispatchers.main()) { + serverConfig = result.serverConfigLinks + canUseBackend = true + } + updateLoginFlowState(NewLoginFlowState.BackendConfigSuccess) + } + + is GetServerConfigResult.Failure.Generic, + null -> { + if (result is GetServerConfigResult.Failure.Generic) { + appLogger.e("$TAG Failed to load backend config from setup screen: ${result.genericFailure}") + } + updateLoginFlowState(NewLoginFlowState.BackendConfigError) + } } } } @@ -169,6 +234,10 @@ class NewLoginViewModel( */ fun onLoginStarted() { viewModelScope.launch(dispatchers.io()) { + if (!canUseBackend) { + updateLoginFlowState(NewLoginFlowState.MissingBackendConfig) + return@launch + } updateLoginFlowState(NewLoginFlowState.Loading) val sanitizedInput = userIdentifierTextState.text.trim().toString() when (validateEmailOrSSOCode(sanitizedInput)) { @@ -366,9 +435,43 @@ class NewLoginViewModel( val currentUserLoginInput = userIdentifierTextState.text state = state.copy( flowState = newState, - nextEnabled = newState !is NewLoginFlowState.Loading && currentUserLoginInput.isNotEmpty() + nextEnabled = newState !is NewLoginFlowState.Loading && + newState !is NewLoginFlowState.MissingBackendConfig && + newState !is NewLoginFlowState.LoadingBackendConfig && + newState !is NewLoginFlowState.BackendConfigError && + newState !is NewLoginFlowState.BackendConfigSuccess && + currentUserLoginInput.isNotEmpty() ) } + private fun fetchDefaultSSOCodeIfNeeded(savedStateHandle: SavedStateHandle? = null) { + viewModelScope.launch(dispatchers.io()) { + appLogger.d("$TAG Fetching default SSO code for server") + ssoExtension.fetchDefaultSSOCode( + serverConfig = serverConfig, + onAuthScopeFailure = { error -> + appLogger.e("$TAG Failed to create auth scope for SSO settings: $error") + }, + onFetchSSOSettingsFailure = { error -> + appLogger.e("$TAG Failed to fetch SSO settings: $error") + }, + onSuccess = { defaultSSOCode -> + if (defaultSSOCode != null && userIdentifierTextState.text.isEmpty()) { + appLogger.d("$TAG Successfully fetched default SSO code") + withContext(dispatchers.main()) { + userIdentifierTextState.setTextAndPlaceCursorAtEnd(defaultSSOCode) + savedStateHandle?.set(USER_IDENTIFIER_SAVED_STATE_KEY, defaultSSOCode) + } + } else { + appLogger.d("$TAG No default SSO code configured for this server") + } + } + ) + } + } + + companion object { + private const val TAG = "[NewLoginViewModel]" + } } private fun AutoVersionAuthScopeUseCase.Result.Failure.toLoginError() = when (this) { diff --git a/app/src/main/kotlin/com/wire/android/util/BackendSupportConfig.kt b/app/src/main/kotlin/com/wire/android/util/BackendSupportConfig.kt new file mode 100644 index 00000000000..8514eb89694 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/BackendSupportConfig.kt @@ -0,0 +1,59 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.wire.android.datastore.GlobalDataStore +import com.wire.kalium.logic.configuration.server.ServerConfig + +object BackendSupportConfig { + + @Volatile + private var currentBackendApiUrl: String? = null + + fun setCurrentBackend(serverLinks: ServerConfig.Links) { + currentBackendApiUrl = serverLinks.api.takeIf { it.isNotBlank() } + } + + suspend fun storeFromServerLinks( + globalDataStore: GlobalDataStore, + serverLinks: ServerConfig.Links + ) { + setCurrentBackend(serverLinks) + globalDataStore.setBackendSupportEmail( + backendApiUrl = serverLinks.api, + supportEmail = serverLinks.supportEmail + ) + } + + @Suppress("ReturnCount") + suspend fun resolveEmail(context: Context, staticSupportEmail: String): String? { + val staticEmail = staticSupportEmail.trim() + if (staticEmail.isNotBlank()) return staticEmail + + val backendApiUrl = currentBackendApiUrl ?: return null + return GlobalDataStore(context.applicationContext).getBackendSupportEmail(backendApiUrl) + } + + fun supportPageIntent(): Intent? = + CustomTabsHelper.resolveUrl("")?.let { + Intent(Intent.ACTION_VIEW, Uri.parse(it)) + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/CustomTabsHelper.kt b/app/src/main/kotlin/com/wire/android/util/CustomTabsHelper.kt index cad90776641..d4aa3551121 100644 --- a/app/src/main/kotlin/com/wire/android/util/CustomTabsHelper.kt +++ b/app/src/main/kotlin/com/wire/android/util/CustomTabsHelper.kt @@ -30,7 +30,19 @@ import com.wire.kalium.logger.KaliumLogLevel object CustomTabsHelper { - fun launchUrl(context: Context, url: String) = launchUri(context, Uri.parse(url)) + private const val SUPPORT_PATH = "support" + + @Volatile + private var backendWebsiteUrl: String? = null + + fun setBackendWebsiteUrl(url: String?) { + backendWebsiteUrl = url?.takeIf { it.isNotBlank() } + } + + fun launchUrl(context: Context, url: String) { + val resolvedUrl = resolveUrl(url) ?: return + launchUri(context, Uri.parse(resolvedUrl)) + } @JvmStatic fun launchUri(context: Context, uri: Uri) { @@ -47,6 +59,11 @@ object CustomTabsHelper { } } + fun resolveUrl(url: String): String? = + url.ifBlank { + backendWebsiteUrl?.trimEnd('/')?.let { "$it/$SUPPORT_PATH" } + } + fun buildCustomTabIntent(context: Context): CustomTabsIntent { val customTabsIntent = CustomTabsIntent.Builder() .setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_close)) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9e4ad1da448..012e6c50f64 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -208,7 +208,8 @@ Zurück zur Ansicht zur Team-Erstellung und Anmeldung Geben Sie Ihre E-Mail-Adresse oder Ihren Benutzernamen ein Geben Sie Ihre E-Mail-Adresse ein - gib deine E-Mail oder SSO-Code ein + Geben Sie Ihre E-Mail-Adresse oder Ihren SSO-Code ein + QR-Code scannen Geben Sie Ihr Passwort ein Geben Sie Ihren SSO-Code oder Wire E-Mail-Adresse ein Willkommen bei der Android-App, Link zur Support-Seite @@ -303,6 +304,14 @@ Du hast eine Mail bekommen Anmelden + Richten Sie Ihre App ein + Geben Sie den von Ihrer Verwaltung bereitgestellten Link ein oder scannen Sie den QR-Code. + Konfigurationslink + Link eingeben oder QR-Code scannen + Einrichten + Es ist ein Fehler aufgetreten. Überprüfen Sie den Link sowie Ihre Internetverbindung und versuchen Sie es dann erneut. + Ihre App ist eingerichtet + Im nächsten Schritt können Sie sich anmelden Passwort vergessen? max@example.com oder max.müller max@beispiel.com @@ -1237,7 +1246,7 @@ Bei Gruppenunterhaltungen kann der Gruppen-Admin diese Einstellung überschreibe Website-URL: Backend-WSURL: Ein Fehler ist aufgetreten - Die Weiterleitung zu einem lokalen Backend war nicht möglich. Sie scheinen nicht mit dem Internet verbunden zu sein.\n\nStellen Sie eine Internetverbindung her und versuchen Sie es erneut. + Es ist ein Fehler aufgetreten. Überprüfen Sie den Link sowie Ihre Internetverbindung und versuchen Sie es dann erneut. Erneut versuchen Empfang von neuen Nachrichten Text in Zwischenablage kopiert diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1af6ec313f..47ce8dde730 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,6 +252,7 @@ enter your email or username enter your email enter your email or sso code + Scan QR code enter your password enter your SSO code or Wire email Welcome to Wire\'s New Android App, link to support page @@ -1333,7 +1334,7 @@ In group conversations, the group admin can overwrite this setting. Website URL: Backend WSURL: An error occurred - Redirecting to an on-premises backend was not possible, you don’t seem to be connected to the internet.\n\nEstablish an internet connection and try again. + Something went wrong. Check the link and your connection, then try again. Try again Receiving new messages Text copied to clipboard @@ -1885,6 +1886,15 @@ In group conversations, the group admin can overwrite this setting. Keep WebSocket Connection Force persistent WebSocket connection for all users This setting is managed by your organization. + Backend configuration is required. Open a configuration link or contact your administrator. + Set up your app + To get started, enter the link or scan the QR code provided by your administration. + Configuration link + Enter link or scan QR code + Set Up + Something went wrong. Check the link and your connection, then try again. + Your app is set up + In the next step, you can log in Channels are available for team members. diff --git a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt index 9b4adb15254..1ec85b81fe7 100644 --- a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt @@ -53,16 +53,17 @@ class ManagedConfigurationsManagerTest { teamsURL = "https://teams.anta.wire.link", websiteURL = "https://wire.com" ), - title = "anta.wire.link" + title = "anta.wire.link", + supportEmail = "support@anta.wire.link" ) - val (_, manager) = Arrangement() + val (arrangement, manager) = Arrangement() .withRestrictions(mapOf(ManagedConfigurationsKeys.DEFAULT_SERVER_URLS.asKey() to validServerConfigJson)) .arrange() val result = manager.refreshServerConfig() assertInstanceOf(result) - val serverConfig = manager.currentServerConfig + val serverConfig = requireNotNull(manager.currentServerConfig) assertEquals(expected.title, serverConfig.title) assertEquals(expected.endpoints.accountsURL, serverConfig.accounts) assertEquals(expected.endpoints.backendURL, serverConfig.api) @@ -70,6 +71,8 @@ class ManagedConfigurationsManagerTest { assertEquals(expected.endpoints.blackListURL, serverConfig.blackList) assertEquals(expected.endpoints.teamsURL, serverConfig.teams) assertEquals(expected.endpoints.websiteURL, serverConfig.website) + assertEquals(expected.supportEmail, serverConfig.supportEmail) + assertEquals(expected.supportEmail, arrangement.getStoredSupportEmail(serverConfig.api)) } @Test @@ -80,7 +83,7 @@ class ManagedConfigurationsManagerTest { val result = manager.refreshServerConfig() assertInstanceOf(result) - val serverConfig = manager.currentServerConfig + val serverConfig = requireNotNull(manager.currentServerConfig) assertEquals(ServerConfigProvider().getDefaultServerConfig(), serverConfig) } @@ -92,7 +95,7 @@ class ManagedConfigurationsManagerTest { val result = manager.refreshServerConfig() assertInstanceOf(result) - val serverConfig = manager.currentServerConfig + val serverConfig = requireNotNull(manager.currentServerConfig) assertEquals(ServerConfigProvider().getDefaultServerConfig(), serverConfig) } @@ -142,7 +145,7 @@ class ManagedConfigurationsManagerTest { val result = manager.refreshServerConfig() assertInstanceOf(result) - val serverConfig = manager.currentServerConfig + val serverConfig = requireNotNull(manager.currentServerConfig) assertEquals(ServerConfigProvider().getDefaultServerConfig(), serverConfig) } @@ -194,9 +197,10 @@ class ManagedConfigurationsManagerTest { val result = manager.refreshServerConfig() assertInstanceOf(result) - val serverConfig = manager.currentServerConfig + val serverConfig = requireNotNull(manager.currentServerConfig) assertEquals("Secure Server", serverConfig.title) assertEquals("https://secure-account.wire.link", serverConfig.accounts) + assertEquals("secure-support@example.com", serverConfig.supportEmail) } @Test @@ -210,7 +214,7 @@ class ManagedConfigurationsManagerTest { val result = manager.refreshServerConfig() assertInstanceOf(result) - val serverConfig = manager.currentServerConfig + val serverConfig = requireNotNull(manager.currentServerConfig) assertEquals("General Server", serverConfig.title) assertEquals("https://general-account.wire.link", serverConfig.accounts) } @@ -226,7 +230,7 @@ class ManagedConfigurationsManagerTest { val result = manager.refreshServerConfig() assertInstanceOf(result) - val serverConfig = manager.currentServerConfig + val serverConfig = requireNotNull(manager.currentServerConfig) assertEquals(ServerConfigProvider().getDefaultServerConfig(), serverConfig) } @@ -327,6 +331,9 @@ class ManagedConfigurationsManagerTest { configParser = configParser ) } + + suspend fun getStoredSupportEmail(backendApiUrl: String): String? = + GlobalDataStore(context).getBackendSupportEmail(backendApiUrl) } companion object { @@ -340,7 +347,8 @@ class ManagedConfigurationsManagerTest { "teamsURL": "https://teams.anta.wire.link", "websiteURL": "https://wire.com" }, - "title": "anta.wire.link" + "title": "anta.wire.link", + "supportEmail": "support@anta.wire.link" } """.trimIndent() @@ -374,6 +382,7 @@ class ManagedConfigurationsManagerTest { { "0": { "title": "Secure Server", + "supportEmail": "secure-support@example.com", "endpoints": { "accountsURL": "https://secure-account.wire.link", "backendURL": "https://secure-api.wire.link", @@ -385,6 +394,7 @@ class ManagedConfigurationsManagerTest { }, "default": { "title": "General Server", + "supportEmail": "general-support@example.com", "endpoints": { "accountsURL": "https://general-account.wire.link", "backendURL": "https://general-api.wire.link", diff --git a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt index cdf9595e9cd..d72ea12b75c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension +import com.wire.android.config.ServerConfigProvider import com.wire.android.config.SnapshotExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.datastore.UserDataStoreProvider @@ -37,12 +38,15 @@ import com.wire.kalium.logic.feature.auth.sso.SSOInitiateLoginResult import com.wire.kalium.logic.feature.auth.sso.SSOLoginSessionResult import com.wire.kalium.logic.feature.auth.sso.ValidateSSOCodeUseCase.Companion.SSO_CODE_WIRE_PREFIX import com.wire.kalium.logic.feature.client.RegisterClientResult +import com.wire.kalium.logic.feature.server.GetServerConfigResult +import com.wire.kalium.logic.feature.server.GetServerConfigUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -555,6 +559,9 @@ class NewLoginViewModelTest { val validateEmailOrSSOCodeUseCase: ValidateEmailOrSSOCodeUseCase = mockk() + @MockK + lateinit var getServerConfigUseCase: GetServerConfigUseCase + init { MockKAnnotations.init(this, relaxUnitFun = true) every { @@ -739,7 +746,26 @@ class NewLoginViewModelTest { ) } + fun withEmptyCustomServerConfig() = apply { + every { + savedStateHandle.navArgs() + } returns LoginNavArgs( + loginPasswordPath = LoginPasswordPath( + customServerConfig = ServerConfigProvider.EmptyServerConfig + ) + ) + } + + fun withGetServerConfigReturning(result: GetServerConfigResult) = apply { + coEvery { getServerConfigUseCase(any()) } returns result + } + private var defaultSSOCodeConfig: String = String.EMPTY + private var isDefaultBackendConfigured: Boolean = true + + fun withDefaultBackendConfigured(configured: Boolean) = apply { + isDefaultBackendConfigured = configured + } fun arrange() = this to NewLoginViewModel( validateEmailOrSSOCodeUseCase, @@ -751,7 +777,9 @@ class NewLoginViewModelTest { loginSSOViewModelExtension, dispatchers, ServerConfig.STAGING, - defaultSSOCodeConfig + defaultSSOCodeConfig, + isDefaultBackendConfigured, + lazy { getServerConfigUseCase }, ) } @@ -771,6 +799,168 @@ class NewLoginViewModelTest { } } + @Test + fun `given default backend is not configured, when initializing view model, then block login and do not fetch default SSO code`() = + runTest(dispatchers.main()) { + val (arrangement, viewModel) = Arrangement() + .withDefaultBackendConfigured(false) + .withEmptyUserIdentifierAndNoPreFilledIdentifier() + .arrange() + + advanceUntilIdle() + + assertEquals(NewLoginFlowState.MissingBackendConfig, viewModel.state.flowState) + assertEquals(false, viewModel.state.nextEnabled) + coVerify(exactly = 0) { + arrangement.loginSSOViewModelExtension.fetchDefaultSSOCode(any(), any(), any(), any()) + } + } + + @Test + fun `given default backend is not configured, when login is started, then do not proceed to login flow`() = + runTest(dispatchers.main()) { + val (arrangement, viewModel) = Arrangement() + .withDefaultBackendConfigured(false) + .withUserIdentifierAlreadySet("user@example.com") + .arrange() + + viewModel.onLoginStarted() + advanceUntilIdle() + + assertEquals(NewLoginFlowState.MissingBackendConfig, viewModel.state.flowState) + verify(exactly = 0) { + arrangement.validateEmailOrSSOCodeUseCase(any()) + } + coVerify(exactly = 0) { + arrangement.authenticationScope.getLoginFlowForDomainUseCase(any()) + } + } + + @Test + fun `given configured backend, when no backend is selected, then switch to missing backend config`() = + runTest(dispatchers.main()) { + val (arrangement, viewModel) = Arrangement() + .withUserIdentifierAlreadySet("user@example.com") + .arrange() + + viewModel.onNoBackendSelected() + advanceUntilIdle() + viewModel.onLoginStarted() + advanceUntilIdle() + + assertEquals(ServerConfigProvider.EmptyServerConfig, viewModel.serverConfig) + assertEquals(NewLoginFlowState.MissingBackendConfig, viewModel.state.flowState) + assertEquals(false, viewModel.state.nextEnabled) + verify(exactly = 0) { + arrangement.validateEmailOrSSOCodeUseCase(any()) + } + coVerify(exactly = 0) { + arrangement.authenticationScope.getLoginFlowForDomainUseCase(any()) + } + } + + @Test + fun `given empty custom server config, when initializing view model, then block login and do not fetch default SSO code`() = + runTest(dispatchers.main()) { + val (arrangement, viewModel) = Arrangement() + .withEmptyCustomServerConfig() + .withDefaultSSOCodeConfig("default-sso-code") + .withFetchDefaultSSOCodeSuccess("default-sso-code") + .arrange() + + advanceUntilIdle() + + assertEquals(ServerConfigProvider.EmptyServerConfig, viewModel.serverConfig) + assertEquals(NewLoginFlowState.MissingBackendConfig, viewModel.state.flowState) + assertEquals(String.EMPTY, viewModel.userIdentifierTextState.text.toString()) + coVerify(exactly = 0) { + arrangement.loginSSOViewModelExtension.fetchDefaultSSOCode(any(), any(), any(), any()) + } + } + + @Test + fun `given default backend is not configured, when raw config link is entered, then load backend config and show success`() = + runTest(dispatchers.main()) { + val configUrl = "https://example.com/deeplink.json" + val serverConfig = newServerConfig(1).links + val (arrangement, viewModel) = Arrangement() + .withDefaultBackendConfigured(false) + .withGetServerConfigReturning(GetServerConfigResult.Success(serverConfig)) + .arrange() + + viewModel.onBackendConfigLinkEntered(configUrl) + advanceUntilIdle() + + assertEquals(serverConfig, viewModel.serverConfig) + assertEquals(NewLoginFlowState.BackendConfigSuccess, viewModel.state.flowState) + coVerify(exactly = 1) { + arrangement.getServerConfigUseCase(configUrl) + } + } + + @Test + fun `given backend config was loaded, when success is continued and login starts, then proceed to login flow`() = + runTest(dispatchers.main()) { + val configUrl = "https://example.com/deeplink.json" + val serverConfig = newServerConfig(1).links + val (arrangement, viewModel) = Arrangement() + .withDefaultBackendConfigured(false) + .withUserIdentifierAlreadySet("user@example.com") + .withGetServerConfigReturning(GetServerConfigResult.Success(serverConfig)) + .withEmailOrSSOCodeValidatorReturning(ValidateEmailOrSSOCodeUseCase.Result.ValidEmail) + .withAuthenticationScopeSuccess() + .withGetLoginFlowForDomainReturning(EnterpriseLoginResult.Success(LoginRedirectPath.Default)) + .arrange() + + viewModel.onBackendConfigLinkEntered(configUrl) + advanceUntilIdle() + viewModel.onBackendConfigSuccessContinue() + viewModel.onLoginStarted() + advanceUntilIdle() + + assertEquals(serverConfig, viewModel.serverConfig) + coVerify(exactly = 1) { + arrangement.authenticationScope.getLoginFlowForDomainUseCase("user@example.com") + } + } + + @Test + fun `given default backend is not configured, when mobile deeplink is entered, then load config url from deeplink`() = + runTest(dispatchers.main()) { + val configUrl = "https://example.com/deeplink.json" + val deepLink = "wire://access/?config=https%3A%2F%2Fexample.com%2Fdeeplink.json" + val serverConfig = newServerConfig(1).links + val (arrangement, viewModel) = Arrangement() + .withDefaultBackendConfigured(false) + .withGetServerConfigReturning(GetServerConfigResult.Success(serverConfig)) + .arrange() + + viewModel.onBackendConfigLinkEntered(deepLink) + advanceUntilIdle() + + assertEquals(NewLoginFlowState.BackendConfigSuccess, viewModel.state.flowState) + coVerify(exactly = 1) { + arrangement.getServerConfigUseCase(configUrl) + } + } + + @Test + fun `given default backend is not configured, when config loading fails, then show setup error`() = + runTest(dispatchers.main()) { + val (arrangement, viewModel) = Arrangement() + .withDefaultBackendConfigured(false) + .withGetServerConfigReturning(GetServerConfigResult.Failure.Generic(CoreFailure.Unknown(Throwable()))) + .arrange() + + viewModel.onBackendConfigLinkEntered("https://example.com/deeplink.json") + advanceUntilIdle() + + assertEquals(NewLoginFlowState.BackendConfigError, viewModel.state.flowState) + coVerify(exactly = 1) { + arrangement.getServerConfigUseCase(any()) + } + } + @Test fun `given user identifier already set, when initializing view model, then do not fetch default SSO code`() = runTest(dispatchers.main()) { diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index 3bf98490da5..e1d3006f740 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -98,6 +98,7 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { DEFAULT_BACKEND_URL_BLACKLIST("default_backend_url_blacklist", ConfigType.STRING), DEFAULT_BACKEND_URL_WEBSITE("default_backend_url_website", ConfigType.STRING), DEFAULT_BACKEND_TITLE("default_backend_title", ConfigType.STRING), + DEFAULT_BACKEND_ENABLED("default_backend_enabled", ConfigType.BOOLEAN), CERTIFICATE_PINNING_CONFIG("cert_pinning_config", ConfigType.MapOfStringToListOfStrings), // TODO: Add support for default proxy configs diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt index 14b609a88e4..30578f333cb 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt @@ -39,7 +39,7 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.paneTitle @@ -53,6 +53,7 @@ import androidx.compose.ui.window.DialogProperties import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton +import com.wire.android.util.CustomTabsHelper import com.wire.android.ui.common.button.WireTertiaryButton import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.theme.isTablet @@ -177,7 +178,7 @@ fun WireDialogContent( centerContent: Boolean = false, content: @Composable (() -> Unit)? = null ) { - val uriHandler = LocalUriHandler.current + val context = LocalContext.current Surface( modifier = modifier.padding(MaterialTheme.wireDimensions.dialogCardMargin), @@ -211,7 +212,7 @@ fun WireDialogContent( TextWithLinkSuffix( text = text, linkText = textSuffixLink?.linkText, - onLinkClick = { textSuffixLink?.linkUrl?.let { uriHandler.openUri(it) } }, + onLinkClick = { textSuffixLink?.linkUrl?.let { CustomTabsHelper.launchUrl(context, it) } }, modifier = Modifier.padding(bottom = MaterialTheme.wireDimensions.dialogTextsSpacing) ) } diff --git a/core/ui-common/src/main/res/drawable/ic_qr_code_scanner.xml b/core/ui-common/src/main/res/drawable/ic_qr_code_scanner.xml new file mode 100644 index 00000000000..2eba3f82480 --- /dev/null +++ b/core/ui-common/src/main/res/drawable/ic_qr_code_scanner.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/core/ui-common/src/test/kotlin/com/wire/android/util/CustomTabsHelperTest.kt b/core/ui-common/src/test/kotlin/com/wire/android/util/CustomTabsHelperTest.kt new file mode 100644 index 00000000000..f905a6ff771 --- /dev/null +++ b/core/ui-common/src/test/kotlin/com/wire/android/util/CustomTabsHelperTest.kt @@ -0,0 +1,61 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class CustomTabsHelperTest { + + @Test + fun `given empty url and backend website, when resolving url, then return backend support url`() { + CustomTabsHelper.setBackendWebsiteUrl("https://example.com") + + val result = CustomTabsHelper.resolveUrl("") + + assertEquals("https://example.com/support", result) + } + + @Test + fun `given empty url and backend website with trailing slash, when resolving url, then return backend support url`() { + CustomTabsHelper.setBackendWebsiteUrl("https://example.com/") + + val result = CustomTabsHelper.resolveUrl("") + + assertEquals("https://example.com/support", result) + } + + @Test + fun `given non empty url, when resolving url, then keep original url`() { + CustomTabsHelper.setBackendWebsiteUrl("https://example.com") + + val result = CustomTabsHelper.resolveUrl("not a valid url") + + assertEquals("not a valid url", result) + } + + @Test + fun `given empty url and no backend website, when resolving url, then return null`() { + CustomTabsHelper.setBackendWebsiteUrl(null) + + val result = CustomTabsHelper.resolveUrl("") + + assertNull(result) + } +} diff --git a/default.json b/default.json index 16b9abbacb5..71f4bb3e601 100644 --- a/default.json +++ b/default.json @@ -133,6 +133,7 @@ "default_backend_url_blacklist": "https://clientblacklist.wire.com/prod", "default_backend_url_website": "https://wire.com", "default_backend_title": "wire-production", + "default_backend_enabled": true, "cert_pinning_config": { "sha256/fnBeCwh0imI9t46Onid49IwvsB5vcf7RCvafRRdCyRE=": [ "**.prod-nginz-https.wire.com", diff --git a/kalium b/kalium index f8b1665e52b..1f31f3b41ac 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit f8b1665e52b251527d11be52d1f40ce7f78f8837 +Subproject commit 1f31f3b41acd1c9c61c119b75f4dc89edf6583c6 From 43630089121333828b4aa95b4fa77fd6837b7e93 Mon Sep 17 00:00:00 2001 From: Sergey Bakhtiarov Date: Tue, 21 Jul 2026 12:06:06 +0200 Subject: [PATCH 3/6] feat: dynamic support URLs (WPB-27031) (#5080) --- .../android/navigation/NavigationUtils.kt | 2 +- .../android/navigation/OtherDestinations.kt | 33 +++++-- .../wire/android/ui/WireActivityViewModel.kt | 8 +- .../create/email/CreateAccountEmailScreen.kt | 4 +- .../login/email/LoginEmailViewModel.kt | 2 + .../EmailAlreadyInUseClaimedDomainDialog.kt | 6 +- .../ui/e2eiEnrollment/E2EIEnrollmentScreen.kt | 4 +- .../channels/BrowseChannelsEmptyScreen.kt | 4 +- .../messagedetails/MessageDetailsScreen.kt | 6 +- .../messages/item/MessageItemComponents.kt | 6 +- .../messages/item/SystemMessageItem.kt | 32 +++--- .../search/EmptySearchQueryScreen.kt | 4 +- .../SearchConversationMessagesEmptyScreen.kt | 4 +- .../all/ConversationsEmptyContent.kt | 6 +- .../filter/ConversationFoldersSheetContent.kt | 4 +- .../home/messagecomposer/MessageComposer.kt | 4 +- .../common/CreateGroupErrorDialog.kt | 4 +- .../groupOptions/GroupOptionsScreen.kt | 4 +- .../common/LearnMoreAboutLegalHoldButton.kt | 4 +- .../login/NewLoginViewModel.kt | 3 + .../details/CreateAccountDataDetailScreen.kt | 4 +- .../settings/devices/DeviceDetailsScreen.kt | 8 +- .../android/ui/sharing/ImportMediaScreen.kt | 4 +- .../OtherUserConnectionUnverifiedWarning.kt | 38 +++++++ .../other/OtherUserDevicesScreen.kt | 4 +- app/src/main/res/values/strings.xml | 26 ----- .../android/ui/WireActivityViewModelTest.kt | 79 +++++++++++++++ .../login/NewLoginViewModelTest.kt | 14 +++ .../com/wire/android/util/SupportPage.kt | 56 +++++++++++ .../wire/android/util/SupportUrlResolver.kt | 65 ++++++++++++ .../ui-common/src/main/res/values/strings.xml | 53 +++++++++- .../android/util/SupportUrlResolverTest.kt | 98 +++++++++++++++++++ .../feature/cells/ui/CellScreenContent.kt | 10 +- .../cells/src/main/res/values/strings.xml | 3 - 34 files changed, 521 insertions(+), 85 deletions(-) create mode 100644 core/ui-common/src/main/kotlin/com/wire/android/util/SupportPage.kt create mode 100644 core/ui-common/src/main/kotlin/com/wire/android/util/SupportUrlResolver.kt create mode 100644 core/ui-common/src/test/kotlin/com/wire/android/util/SupportUrlResolverTest.kt diff --git a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt index bdeda96afff..d02fa6a6272 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt @@ -127,7 +127,7 @@ val Direction.baseRoute: String fun Direction.handleNavigation(context: Context, handleOtherDirection: (Direction) -> Unit) = when (this) { is ExternalUriDirection -> CustomTabsHelper.launchUri(context, this.uri) - is ExternalUriStringResDirection -> CustomTabsHelper.launchUrl(context, context.resources.getString(this.uriStringRes)) + is ExternalUriStringResDirection -> CustomTabsHelper.launchUrl(context, this.getUriString(context.resources)) is IntentDirection -> context.startActivity(this.intent(context)) else -> handleOtherDirection(this) } diff --git a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt index 75fb3f488b9..d91d956290e 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt @@ -26,6 +26,8 @@ import com.ramcosta.composedestinations.spec.Direction import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.util.EmailComposer +import com.wire.android.util.SupportPage +import com.wire.android.util.SupportUrlResolver import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.getUrisOfFilesInDirectory @@ -53,16 +55,33 @@ interface ExternalUriStringResDirection : Direction { override val route: String get() = "android.resource://${BuildConfig.APPLICATION_ID}/$uriStringRes" - fun getUri(resources: Resources): Uri = Uri.parse(resources.getString(uriStringRes)) + fun getUriString(resources: Resources): String = resources.getString(uriStringRes) + + fun getUri(resources: Resources): Uri = Uri.parse(getUriString(resources)) +} + +interface ExternalSupportUriStringResDirection : ExternalUriStringResDirection { + val supportPage: SupportPage + + override val uriStringRes: Int + get() = supportPage.hardcodedUrlRes + + override fun getUriString(resources: Resources): String = + SupportUrlResolver.resolve(resources, supportPage) } interface IntentDirection : Direction { fun intent(context: Context): Intent } -object SupportScreenDestination : ExternalUriStringResDirection { - override val uriStringRes: Int - get() = R.string.url_support +object SupportScreenDestination : ExternalSupportUriStringResDirection { + override val supportPage: SupportPage + get() = SupportPage.SUPPORT +} + +object ReportMisuseScreenDestination : ExternalSupportUriStringResDirection { + override val supportPage: SupportPage + get() = SupportPage.REPORT_MISUSE } data object TeamManagementScreenDestination : ExternalDirectionLess @@ -127,9 +146,9 @@ object ReportBugDestination : IntentDirection { get() = "wire-intent:report-bug" } -object WelcomeToNewAndroidAppDestination : ExternalUriStringResDirection { - override val uriStringRes: Int - get() = R.string.url_welcome_to_new_android +object WelcomeToNewAndroidAppDestination : ExternalSupportUriStringResDirection { + override val supportPage: SupportPage + get() = SupportPage.WELCOME_ANDROID } object AndroidReleaseNotesDestination : ExternalUriStringResDirection { diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index 557dad51443..66bfee347aa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -53,6 +53,7 @@ import com.wire.android.util.BackendSupportConfig import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportUrlResolver import com.wire.android.util.deeplink.DeepLinkProcessor import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.deeplink.LoginType @@ -212,16 +213,18 @@ class WireActivityViewModel @Inject constructor( observeCurrentValidUserId.collectLatest { userId -> val serverLinks = userId?.let { runCatching { - when (val result = coreLogic.value.getSessionScope(it).users.serverLinks()) { + when (val result = coreLogic.get().getSessionScope(it).users.serverLinks()) { is SelfServerConfigUseCase.Result.Success -> result.serverLinks.links is SelfServerConfigUseCase.Result.Failure -> null } }.getOrNull() } serverLinks?.let(BackendSupportConfig::setCurrentBackend) - val websiteUrl = serverLinks?.website ?: managedConfigurationsManager.currentServerConfig?.website + val websiteUrl = serverLinks?.website + ?: managedConfigurationsManager.currentServerConfig?.website CustomTabsHelper.setBackendWebsiteUrl(websiteUrl) + SupportUrlResolver.setBaseUrl(websiteUrl) } } } @@ -481,6 +484,7 @@ class WireActivityViewModel @Inject constructor( when (val result = getServerConfigUseCase.get().invoke(url)) { is GetServerConfigResult.Success -> result.serverConfigLinks.also { CustomTabsHelper.setBackendWebsiteUrl(it.website) + SupportUrlResolver.setBaseUrl(it.website) BackendSupportConfig.storeFromServerLinks(globalDataStore.get(), it) } is GetServerConfigResult.Failure.Generic -> { diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt index f3430c1b9f9..92026c185ab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt @@ -78,6 +78,8 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig @@ -209,7 +211,7 @@ private fun EmailContent( private fun EmailErrorText(error: CreateAccountEmailViewState.EmailError) { val learnMoreTag = "learn_more" val context = LocalContext.current - val learnMoreUrl = stringResource(id = R.string.url_create_account_learn_more) + val learnMoreUrl = supportUrlResource(SupportPage.CREATE_ACCOUNT) val learnMoreText = stringResource(id = R.string.label_learn_more) val annotatedText = buildAnnotatedString { append( diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt index db86c0a1ddb..86bac2a9ad5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt @@ -46,6 +46,7 @@ import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.EMPTY import com.wire.android.util.BackendSupportConfig import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportUrlResolver import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.CountdownTimer import com.wire.kalium.logic.CoreLogic @@ -164,6 +165,7 @@ class LoginEmailViewModel @Inject constructor( when (val result = getServerConfigUseCase?.value?.invoke(configUrl)) { is GetServerConfigResult.Success -> { CustomTabsHelper.setBackendWebsiteUrl(result.serverConfigLinks.website) + SupportUrlResolver.setBaseUrl(result.serverConfigLinks.website) globalDataStore?.let { BackendSupportConfig.storeFromServerLinks(it.value, result.serverConfigLinks) } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/EmailAlreadyInUseClaimedDomainDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/EmailAlreadyInUseClaimedDomainDialog.kt index 97c813e1ffa..c53eb2443aa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/EmailAlreadyInUseClaimedDomainDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/EmailAlreadyInUseClaimedDomainDialog.kt @@ -42,6 +42,8 @@ import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.common.wireDialogPropertiesBuilder import com.wire.android.ui.theme.WireTheme import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -86,12 +88,12 @@ private fun Content() { append(deletePersonalAccount) addStyledLink( - url = stringResource(id = R.string.url_change_email), + url = supportUrlResource(SupportPage.CHANGE_EMAIL), start = bullet.length, end = (bullet + changeEmail).length ) addStyledLink( - url = stringResource(id = R.string.url_delete_personal_account), + url = supportUrlResource(SupportPage.DELETE_ACCOUNT), start = (bullet + changeEmail + bullet).length, end = (bullet + changeEmail + bullet + deletePersonalAccount).length ) diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt index 45a414b1af3..89eb614f4f2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt @@ -60,6 +60,8 @@ import com.wire.android.ui.settings.devices.e2ei.E2EICertificateDetails import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult @@ -181,7 +183,7 @@ private fun E2EIEnrollmentScreenContent( } TextWithLearnMore( textAnnotatedString = text, - learnMoreLink = stringResource(R.string.url_e2ee_id_shield), + learnMoreLink = supportUrlResource(SupportPage.E2EE_IDENTITY), modifier = Modifier.padding( top = MaterialTheme.wireDimensions.dialogTextsSpacing, bottom = MaterialTheme.wireDimensions.dialogTextsSpacing, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsEmptyScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsEmptyScreen.kt index 5676a085515..5c4db5e2db5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsEmptyScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsEmptyScreen.kt @@ -40,6 +40,8 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -59,7 +61,7 @@ fun BrowseChannelsEmptyScreen(modifier: Modifier = Modifier) { textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(dimensions().spacing8x)) - val searchUrl = stringResource(id = R.string.url_create_channel_learn_more) + val searchUrl = supportUrlResource(SupportPage.CREATE_CHANNEL) Text( text = stringResource(R.string.label_learn_more_about_channels), style = MaterialTheme.wireTypography.body02.copy( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt index 3b74b3604f5..8f98291db11 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt @@ -57,6 +57,8 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.UIText import kotlinx.coroutines.launch @@ -71,7 +73,7 @@ fun MessageDetailsScreen( ) { val context = LocalContext.current - val reactionsLearnMoreUrl = stringResource(id = R.string.url_message_details_reactions_learn_more) + val reactionsLearnMoreUrl = supportUrlResource(SupportPage.REACTIONS) val onReactionsLearnMore = remember { { CustomTabsHelper.launchUrl( @@ -81,7 +83,7 @@ fun MessageDetailsScreen( } } - val readReceiptsLearnMoreUrl = stringResource(id = R.string.url_message_details_read_receipts_learn_more) + val readReceiptsLearnMoreUrl = supportUrlResource(SupportPage.READ_RECEIPTS) val onReadReceiptsLearnMore = remember { { CustomTabsHelper.launchUrl( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageItemComponents.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageItemComponents.kt index fc243278da0..f79e89e7282 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageItemComponents.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageItemComponents.kt @@ -61,6 +61,8 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.util.CustomTabsHelper import com.wire.android.util.EMPTY +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.Conversation @@ -257,7 +259,7 @@ internal fun MessageDecryptionFailure( conversationProtocol: Conversation.ProtocolInfo? ) { val context = LocalContext.current - val learnMoreUrl = stringResource(R.string.url_decryption_failure_learn_more) + val learnMoreUrl = supportUrlResource(SupportPage.DECRYPTION_FAILURE) val textAlign = if (messageStyle == MessageStyle.BUBBLE_SELF) { TextAlign.End @@ -375,7 +377,7 @@ internal fun Modifier.customizeMessageBackground( @Composable internal fun OfflineBackendsLearnMoreLink(messageStyle: MessageStyle, context: Context = LocalContext.current) { - val learnMoreUrl = stringResource(R.string.url_message_details_offline_backends_learn_more) + val learnMoreUrl = supportUrlResource(SupportPage.OFFLINE_BACKENDS) VerticalSpace.x4() Text( modifier = Modifier.clickable { CustomTabsHelper.launchUrl(context, learnMoreUrl) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt index 21117215ae8..f9afb3f493b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt @@ -62,6 +62,8 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.MarkdownTextStyle import com.wire.android.util.ui.UIText import com.wire.android.util.ui.markdownBold @@ -108,7 +110,7 @@ fun SystemMessageItem( ) { val context = LocalContext.current var expanded: Boolean by remember { mutableStateOf(initiallyExpanded) } - val learnMoreLink = learnMoreLinkResId?.let { stringResource(id = it) } + val learnMoreLink = learnMorePage?.let { supportUrlResource(it) } TextWithLinkSuffix( textStyle = MaterialTheme.wireTypography.body01, @@ -320,7 +322,7 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this is SystemMessage.MLSWrongEpochWarning -> buildContent( iconResId = R.drawable.ic_info, iconTintColor = MaterialTheme.wireColorScheme.onBackground, - learnMoreLinkResId = R.string.url_system_message_learn_more_about_mls + learnMorePage = SupportPage.MLS ) { stringResource(id = R.string.label_system_message_conversation_mls_wrong_epoch_error_handled).toMarkdownAnnotatedString() } @@ -365,10 +367,10 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this is SystemMessage.ConversationProtocolChanged -> buildContent( iconResId = R.drawable.ic_info, iconTintColor = MaterialTheme.wireColorScheme.onBackground, - learnMoreLinkResId = when (protocol) { + learnMorePage = when (protocol) { Conversation.Protocol.PROTEUS -> null - Conversation.Protocol.MIXED -> R.string.url_system_message_learn_more_about_mls - Conversation.Protocol.MLS -> R.string.url_system_message_learn_more_about_mls + Conversation.Protocol.MIXED, + Conversation.Protocol.MLS -> SupportPage.MLS }, learnMoreTextResId = when (protocol) { Conversation.Protocol.PROTEUS -> R.string.label_learn_more @@ -442,7 +444,7 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this is SystemMessage.FederationStopped -> buildContent( iconResId = R.drawable.ic_info, iconTintColor = MaterialTheme.wireColorScheme.onBackground, - learnMoreLinkResId = R.string.url_federation_support, + learnMorePage = SupportPage.FEDERATION_SUPPORT, ) { stringResource( id = when { @@ -456,7 +458,7 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this is SystemMessage.LegalHold -> buildContent( iconResId = R.drawable.ic_legal_hold, iconTintColor = MaterialTheme.wireColorScheme.error, - learnMoreLinkResId = R.string.url_legal_hold_learn_more, + learnMorePage = SupportPage.LEGAL_HOLD, ) { stringResource( id = when (this) { @@ -477,11 +479,11 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this iconResId = R.drawable.ic_info, iconTintColor = MaterialTheme.wireColorScheme.error, expandable = true, - learnMoreLinkResId = when (type) { - Type.Federation -> R.string.url_message_details_offline_backends_learn_more - Type.LegalHold -> R.string.url_legal_hold_learn_more + learnMorePage = when (type) { + Type.Federation -> SupportPage.OFFLINE_BACKENDS + Type.LegalHold -> SupportPage.LEGAL_HOLD Type.Unknown -> null - Type.MissingKeyPackages -> R.string.url_mls_learn_more + Type.MissingKeyPackages -> SupportPage.MLS } ) { expanded -> val markdownTextStyle = DefaultMarkdownTextStyle.copy( @@ -517,7 +519,7 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this iconTintColor = MaterialTheme.wireColorScheme.onPositiveVariant, backgroundColor = MaterialTheme.wireColorScheme.positiveVariant, additionalVerticalPaddings = MaterialTheme.wireDimensions.spacing12x, - learnMoreLinkResId = R.string.url_system_message_learn_more_about_e2ee + learnMorePage = SupportPage.E2EE ) { val markdownTextStyle = DefaultMarkdownTextStyle.copy( normalColor = MaterialTheme.wireColorScheme.onSurface, @@ -630,7 +632,7 @@ private fun List.limitList( @Composable private fun buildContent( expandable: Boolean = false, - @StringRes learnMoreLinkResId: Int? = null, + learnMorePage: SupportPage? = null, @StringRes learnMoreTextResId: Int = R.string.label_learn_more, @DrawableRes iconResId: Int = R.drawable.ic_info, iconTintColor: Color? = MaterialTheme.wireColorScheme.onBackground, @@ -640,7 +642,7 @@ private fun buildContent( annotatedStringBuilder: @Composable (expanded: Boolean) -> AnnotatedString, ) = SystemMessageContent( expandable = expandable, - learnMoreLinkResId = learnMoreLinkResId, + learnMorePage = learnMorePage, learnMoreTextResId = learnMoreTextResId, iconResId = iconResId, iconTintColor = iconTintColor, @@ -661,7 +663,7 @@ val DefaultMarkdownTextStyle @Stable data class SystemMessageContent( val expandable: Boolean, - @get:StringRes val learnMoreLinkResId: Int?, + val learnMorePage: SupportPage?, @get:StringRes val learnMoreTextResId: Int, @get:DrawableRes val iconResId: Int?, val iconTintColor: Color?, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/EmptySearchQueryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/EmptySearchQueryScreen.kt index 6f75e61cc98..128afe91a85 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/EmptySearchQueryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/EmptySearchQueryScreen.kt @@ -42,7 +42,9 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.SupportPage import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -51,7 +53,7 @@ fun EmptySearchQueryScreen( text: String = stringResource(R.string.label_search_people_instruction), learnMoreTextToLink: Pair = Pair( stringResource(R.string.label_learn_more_searching_user), - stringResource(R.string.url_learn_about_search) + supportUrlResource(SupportPage.SEARCH) ) ) { val context = LocalContext.current diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesEmptyScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesEmptyScreen.kt index 0d648b3a431..8a9a23f4b93 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesEmptyScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesEmptyScreen.kt @@ -38,6 +38,8 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -45,7 +47,7 @@ fun SearchConversationMessagesEmptyScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current - val searchUrl = stringResource(R.string.url_learn_about_conversation_search) + val searchUrl = supportUrlResource(SupportPage.CONVERSATION_SEARCH) Box( modifier = modifier.fillMaxSize(), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt index 8a0a78fb7f2..70f543a30f7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt @@ -42,6 +42,8 @@ import com.ramcosta.composedestinations.generated.app.destinations.BrowseChannel import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.conversation.ConversationFilter @@ -67,7 +69,7 @@ private fun EmptyContentFooter(currentFilter: ConversationFilter, navigator: Nav val context = LocalContext.current when (currentFilter) { ConversationFilter.Favorites -> { - val supportUrl = stringResource(id = R.string.url_how_to_add_favorites) + val supportUrl = supportUrlResource(SupportPage.ADD_FAVORITES) Text( text = stringResource(R.string.favorites_empty_list_how_to_label), style = MaterialTheme.wireTypography.body02.copy( @@ -81,7 +83,7 @@ private fun EmptyContentFooter(currentFilter: ConversationFilter, navigator: Nav } ConversationFilter.Channels -> { - val supportUrl = stringResource(id = R.string.url_support) // todo. change to url for channels + val supportUrl = supportUrlResource(SupportPage.SUPPORT) // todo. change to url for channels Text( text = stringResource(R.string.channels_empty_list_learn_more), style = MaterialTheme.wireTypography.body02.copy( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt index 942a22d0bcd..828af0fb518 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFoldersSheetContent.kt @@ -50,6 +50,8 @@ import com.wire.android.ui.common.typography import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.kalium.logic.data.conversation.ConversationFilter @Composable @@ -128,7 +130,7 @@ private fun EmptyFolders() { style = typography().body01, ) VerticalSpace.x16() - val supportUrl = stringResource(id = R.string.url_how_to_add_folders) + val supportUrl = supportUrlResource(SupportPage.ADD_FOLDERS) Text( text = stringResource(R.string.folders_empty_list_how_to_add), style = MaterialTheme.wireTypography.body02.copy( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt index c1f0bfb1b1f..4ac1dcddba2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt @@ -68,6 +68,8 @@ import com.wire.android.ui.home.messagecomposer.state.MessageCompositionInputSta import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.stringWithStyledArgs import com.wire.kalium.logic.data.conversation.InteractionAvailability @@ -125,7 +127,7 @@ fun MessageComposer( InteractionAvailability.LEGAL_HOLD -> DisabledInteractionMessageComposer( conversationId = conversationId, warningText = warningTextWithStyledArgs(R.string.legal_hold_system_message_interaction_disabled), - learnMoreLink = stringResource(id = R.string.url_legal_hold_learn_more), + learnMoreLink = supportUrlResource(SupportPage.LEGAL_HOLD), messageListContent = messageListContent ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/CreateGroupErrorDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/CreateGroupErrorDialog.kt index 1c2ecab7175..6ec64fb78c3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/CreateGroupErrorDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/CreateGroupErrorDialog.kt @@ -29,6 +29,8 @@ import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme import com.wire.android.util.DialogErrorStrings +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.stringWithStyledArgs @@ -63,7 +65,7 @@ fun CreateGroupErrorDialog( ), ) to DialogTextSuffixLink( linkText = stringResource(id = R.string.label_learn_more), - linkUrl = stringResource(id = R.string.url_message_details_offline_backends_learn_more) + linkUrl = supportUrlResource(SupportPage.OFFLINE_BACKENDS) ) is CreateGroupState.Error.Forbidden -> DialogErrorStrings( title = stringResource(R.string.conversation_can_not_be_created_title), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt index d483d043101..a15f90b130b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt @@ -73,6 +73,8 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.conversation.CreateConversationParam import com.wire.kalium.logic.data.id.ConversationId @@ -359,7 +361,7 @@ private fun GroupOptionState.AllowAppsOptions(onAllowServicesChanged: (Boolean) @Composable private fun GroupOptionState.EnableWireCellOptions(onEnableWireCell: (Boolean) -> Unit) { val context = LocalContext.current - val cellsLearnMoreUrl = stringResource(R.string.create_group_with_shared_drive_learn_more_url) + val cellsLearnMoreUrl = supportUrlResource(SupportPage.SHARED_DRIVE) GroupConversationOptionsItem( title = stringResource(R.string.enable_wire_cell), diff --git a/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/common/LearnMoreAboutLegalHoldButton.kt b/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/common/LearnMoreAboutLegalHoldButton.kt index ea9bf08e237..a927f9212eb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/common/LearnMoreAboutLegalHoldButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/common/LearnMoreAboutLegalHoldButton.kt @@ -31,13 +31,15 @@ import androidx.compose.ui.text.style.TextDecoration import com.wire.android.R import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.SupportPage import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes @Composable fun LearnMoreAboutLegalHoldButton(modifier: Modifier = Modifier) { val context = LocalContext.current - val learnMoreUrl = stringResource(id = R.string.url_legal_hold_learn_more) + val learnMoreUrl = supportUrlResource(SupportPage.LEGAL_HOLD) Text( text = stringResource(R.string.legal_hold_learn_more_button), style = MaterialTheme.wireTypography.body02, diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt index b3d2c8b7e91..3f6849c2d21 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt @@ -49,6 +49,7 @@ import com.wire.android.ui.common.textfield.textAsFlow import com.wire.android.util.BackendSupportConfig import com.wire.android.util.CustomTabsHelper import com.wire.android.util.EMPTY +import com.wire.android.util.SupportUrlResolver import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.common.error.CoreFailure @@ -193,6 +194,7 @@ class NewLoginViewModel( serverConfig = ServerConfigProvider.EmptyServerConfig canUseBackend = false CustomTabsHelper.setBackendWebsiteUrl(null) + SupportUrlResolver.setBaseUrl(null) updateLoginFlowState(NewLoginFlowState.MissingBackendConfig) } @@ -208,6 +210,7 @@ class NewLoginViewModel( when (val result = getServerConfigUseCase?.value?.invoke(configUrl)) { is GetServerConfigResult.Success -> { CustomTabsHelper.setBackendWebsiteUrl(result.serverConfigLinks.website) + SupportUrlResolver.setBaseUrl(result.serverConfigLinks.website) globalDataStore?.let { BackendSupportConfig.storeFromServerLinks(it.value, result.serverConfigLinks) } diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt index d9cfda364c0..4ec166c7e29 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt @@ -84,7 +84,9 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper import com.wire.android.util.EMPTY +import com.wire.android.util.SupportPage import com.wire.android.util.isHostValidForAnalytics +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig @@ -423,7 +425,7 @@ private fun TermsConditionsDialog(onDialogDismiss: () -> Unit, onContinuePressed private fun EmailErrorDetailText(error: CreateAccountDataDetailViewState.DetailsError) { val learnMoreTag = "learn_more" val context = LocalContext.current - val learnMoreUrl = stringResource(id = R.string.url_create_account_learn_more) + val learnMoreUrl = supportUrlResource(SupportPage.CREATE_ACCOUNT) val learnMoreText = stringResource(id = R.string.label_learn_more) val annotatedText = buildAnnotatedString { append( diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt index 201241f452e..4d6aa1adfb3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt @@ -83,10 +83,12 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage import com.wire.android.util.deviceDateTimeFormat import com.wire.android.util.dialogErrorStrings import com.wire.android.util.extension.formatAsFingerPrint import com.wire.android.util.extension.formatAsString +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.ClientId @@ -483,7 +485,7 @@ private fun VerificationDescription( if (isSelfClient) { DescriptionText( text = stringResource(id = R.string.label_self_client_verification_description), - leanMoreLink = stringResource(id = R.string.url_self_client_verification_learn_more) + leanMoreLink = supportUrlResource(SupportPage.CLIENT_VERIFICATION) ) } else { DescriptionText( @@ -491,7 +493,7 @@ private fun VerificationDescription( id = R.string.label_client_verification_description, userName ?: stringResource(id = R.string.unknown_user_name) ), - leanMoreLink = stringResource(id = R.string.url_self_client_verification_learn_more) + leanMoreLink = supportUrlResource(SupportPage.CLIENT_VERIFICATION) ) } @@ -504,7 +506,7 @@ private fun VerificationDescription( if (isSelfClient) { DescriptionText( text = stringResource(id = R.string.label_self_fingerprint_description), - leanMoreLink = stringResource(id = R.string.url_self_client_fingerprint_learn_more) + leanMoreLink = supportUrlResource(SupportPage.CLIENT_FINGERPRINT) ) } else { DescriptionText( diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index 868ae92ae2c..56a48f22e17 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -107,7 +107,9 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage import com.wire.android.util.extension.getActivity +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.LinkText import com.wire.android.util.ui.LinkTextData import com.wire.android.util.ui.PreviewMultipleThemes @@ -407,7 +409,7 @@ fun FileSharingRestrictedContent( modifier: Modifier = Modifier ) { val context = LocalContext.current - val learnMoreUrl = stringResource(R.string.url_file_sharing_restricted_learn_more) + val learnMoreUrl = supportUrlResource(SupportPage.FILE_SHARING_RESTRICTED) Column( horizontalAlignment = Alignment.CenterHorizontally, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionUnverifiedWarning.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionUnverifiedWarning.kt index 85d2c3eff90..1c82c5483ba 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionUnverifiedWarning.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionUnverifiedWarning.kt @@ -23,19 +23,28 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import com.wire.android.R import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage +import com.wire.android.util.supportUrlResource import com.wire.kalium.logic.data.user.ConnectionState @Composable @@ -44,6 +53,19 @@ fun OtherUserConnectionUnverifiedWarning( connectionStatus: ConnectionState, modifier: Modifier = Modifier ) { + val context = LocalContext.current + val reportMisuseUrl = supportUrlResource(SupportPage.REPORT_MISUSE) + val reportMisuseLabel = stringResource(R.string.report_misuse_screen_title) + val reportMisuseLinkText = remember(reportMisuseLabel) { + buildAnnotatedString { + append(reportMisuseLabel) + addStyle( + style = SpanStyle(textDecoration = TextDecoration.Underline), + start = 0, + end = reportMisuseLabel.length + ) + } + } Box( modifier .fillMaxWidth() @@ -58,6 +80,22 @@ fun OtherUserConnectionUnverifiedWarning( color = MaterialTheme.wireColorScheme.error, style = MaterialTheme.wireTypography.body01 ) + VerticalSpace.x8() + Text( + text = stringResource(R.string.connection_label_unverified_support_disclaimer), + textAlign = TextAlign.Center, + color = MaterialTheme.wireColorScheme.error, + style = MaterialTheme.wireTypography.body01 + ) + VerticalSpace.x8() + ClickableText( + text = reportMisuseLinkText, + style = MaterialTheme.wireTypography.body01.copy( + color = MaterialTheme.wireColorScheme.error, + textAlign = TextAlign.Center + ), + onClick = { CustomTabsHelper.launchUrl(context, reportMisuseUrl) } + ) VerticalSpace.x24() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserDevicesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserDevicesScreen.kt index a1368db2f33..cd929dba663 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserDevicesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserDevicesScreen.kt @@ -47,7 +47,9 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.SupportPage import com.wire.android.util.capitalizeFirstLetter +import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.LinkText import com.wire.android.util.ui.LinkTextData @@ -111,7 +113,7 @@ private fun OtherUserDevicesContent( onDeviceClick: (Device) -> Unit ) { val context = LocalContext.current - val supportUrl = stringResource(id = R.string.url_why_verify_conversation) + val supportUrl = supportUrlResource(SupportPage.VERIFY_CONVERSATION) LazyColumn( state = lazyListState, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47ce8dde730..fb4ef5dae14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,38 +265,12 @@ http://maps.google.com/maps?z=%1d&q=loc:%2f+%2f https://www.wire.com - https://support.wire.com https://wire.com/legal https://wire.com/privacy-policy#:~:text=We%20process%20individual%20data%20about,%C2%A7%201%20a)%20GDPR). - https://support.wire.com/hc/articles/115004082129 - https://support.wire.com/hc/articles/207948115 - https://support.wire.com/hc/categories/4719917054365 - https://support.wire.com/hc/articles/4406404582673 - https://support.wire.com/hc/articles/115001426529 - https://support.wire.com/hc/articles/203121850 - https://support.wire.com/hc/articles/360002018278 - https://support.wire.com/hc/articles/9357718008093 - https://support.wire.com/hc/articles/12434725011485 - https://support.wire.com/hc/articles/212053645 - https://support.wire.com/hc/articles/360000665277 - https://support.wire.com/hc/articles/207692235 - https://support.wire.com/hc/articles/207693005 - https://support.wire.com/hc/articles/12434725011485 - https://support.wire.com/hc/articles/6655706999581 - https://support.wire.com/hc/articles/207859815 - https://support.wire.com/hc/articles/360002855557 - https://support.wire.com/hc/articles/360002855817 https://wire.com/pricing https://teams.wire.com/login https://wire.com/en/enterprise https://teams.wire.com/register/email - https://support.wire.com/hc/articles/25732551379101 - https://support.wire.com/hc/articles/203762940 - https://support.wire.com/hc/articles/207555795 - https://support.wire.com/hc/articles/10898523878173 - https://support.wire.com/hc/articles/9211300150685 - https://support.wire.com/hc/articles/360000296158 - https://support.wire.com/hc/articles/32207749480477 Vault Channels diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 345713379d7..e238ba98b72 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -49,6 +49,8 @@ import com.wire.android.ui.joinConversation.JoinConversationViaCodeState import com.wire.android.ui.theme.ThemeOption import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager +import com.wire.android.util.SupportPage +import com.wire.android.util.SupportUrlResolver import com.wire.android.util.deeplink.DeepLinkProcessor import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.newServerConfig @@ -82,6 +84,7 @@ import com.wire.kalium.logic.feature.session.DoesValidSessionExistUseCase import com.wire.kalium.logic.feature.session.GetAllSessionsResult import com.wire.kalium.logic.feature.session.ObserveSessionsUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase +import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.logic.feature.user.screenshotCensoring.ObserveScreenshotCensoringConfigResult import com.wire.kalium.logic.feature.user.screenshotCensoring.ObserveScreenshotCensoringConfigUseCase import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase @@ -105,6 +108,7 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -113,6 +117,11 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class) class WireActivityViewModelTest { + @AfterEach + fun tearDown() { + SupportUrlResolver.setBaseUrl(null) + } + @Test fun `given Intent is null, when currentSession is present, then initialAppState is LOGGED_IN`() = runTest { val (_, viewModel) = Arrangement() @@ -749,6 +758,48 @@ class WireActivityViewModelTest { } } + @Test + fun `given session changes, when resolving support url, then use current session website`() = runTest { + val firstSession = AccountInfo.Valid(UserId("user1", "domain1")) + val secondSession = AccountInfo.Valid(UserId("user2", "domain2")) + val firstServerConfig = newServerConfig(1) + val secondServerConfig = newServerConfig(2) + val currentSessionFlow = MutableStateFlow(firstSession) + Arrangement() + .withCurrentSessionFlow(currentSessionFlow.map { CurrentSessionResult.Success(it) }) + .withServerConfigForUser(firstSession.userId, firstServerConfig) + .withServerConfigForUser(secondSession.userId, secondServerConfig) + .arrange() + advanceUntilIdle() + assertEquals( + "${firstServerConfig.links.website.trimEnd('/')}/support/search", + SupportUrlResolver.resolve("", SupportPage.SEARCH) + ) + + currentSessionFlow.emit(secondSession) + advanceUntilIdle() + + assertEquals( + "${secondServerConfig.links.website.trimEnd('/')}/support/search", + SupportUrlResolver.resolve("", SupportPage.SEARCH) + ) + } + + @Test + fun `given managed server and no current session, when resolving support url, then use managed server website`() = runTest { + val managedServerConfig = newServerConfig(4).links + Arrangement() + .withNoCurrentSession() + .withManagedServerConfig(managedServerConfig) + .arrange() + advanceUntilIdle() + + assertEquals( + "${managedServerConfig.website.trimEnd('/')}/support/search", + SupportUrlResolver.resolve("", SupportPage.SEARCH) + ) + } + @Test fun `given app theme change, when observing it, then update state with theme option`() = runTest { val (_, viewModel) = Arrangement() @@ -850,6 +901,7 @@ class WireActivityViewModelTest { every { observeSelfUserFactory.create(any()).observeSelfUser } returns observeSelfUserUseCase coEvery { observeSelfUserUseCase() } returns flowOf(SELF_USER) every { managedConfigurationsManager.persistentWebSocketEnforcedByMDM } returns persistentWebSocketEnforcedByMDMFlow + every { managedConfigurationsManager.currentServerConfig } returns null coEvery { coreLogic.getGlobalScope().isCrossBackendLoginBlocked(any()) } returns false @@ -978,6 +1030,33 @@ class WireActivityViewModelTest { coEvery { currentSessionFlow() } returns result } + fun withCurrentSession(result: CurrentSessionResult): Arrangement = apply { + coEvery { coreLogic.getGlobalScope().session.currentSession() } returns result + } + + fun withAccountSwitchResult(result: SwitchAccountResult): Arrangement = apply { + coEvery { switchAccount(SwitchAccountParam.TryToSwitchToNextAccount) } returns result + } + + fun verifyTryToSwitchToNextAccount() { + coVerify(exactly = 1) { switchAccount(SwitchAccountParam.TryToSwitchToNextAccount) } + } + + fun withServerConfigForUser(userId: UserId, serverConfig: ServerConfig): Arrangement = apply { + coEvery { coreLogic.getSessionScope(userId).users.serverLinks() } returns + SelfServerConfigUseCase.Result.Success(serverConfig) + } + + fun withManagedServerConfig(serverConfig: ServerConfig.Links): Arrangement = apply { + every { managedConfigurationsManager.currentServerConfig } returns serverConfig + } + + fun withE2EIRequiredDuringLogin(required: Boolean): Arrangement = apply { + coEvery { + observeIfE2EIRequiredDuringLoginUseCaseProviderFactory.create(any()).observeIfE2EIIsRequiredDuringLogin() + } returns flowOf(required) + } + fun withObserveSessionsFlow(result: Flow): Arrangement = apply { coEvery { observeSessionsUseCase() } returns result } diff --git a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt index d72ea12b75c..1295509e059 100644 --- a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt @@ -21,6 +21,8 @@ import com.wire.android.ui.authentication.login.sso.SSOUrlConfig import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.newauthentication.login.ValidateEmailOrSSOCodeUseCase.Result.ValidEmail import com.wire.android.util.EMPTY +import com.wire.android.util.SupportPage +import com.wire.android.util.SupportUrlResolver import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.deeplink.SSOFailureCodes import com.wire.android.util.newServerConfig @@ -51,6 +53,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertInstanceOf import org.junit.jupiter.api.extension.ExtendWith @@ -61,6 +64,11 @@ import org.junit.jupiter.api.extension.ExtendWith class NewLoginViewModelTest { private val dispatchers = TestDispatcherProvider() + @AfterEach + fun tearDown() { + SupportUrlResolver.setBaseUrl(null) + } + @Test fun `given onLoginStarted is called, when valid input is SSO, then proceed to SSO flow`() = runTest(dispatchers.main()) { val (arrangement, viewModel) = Arrangement() @@ -842,6 +850,7 @@ class NewLoginViewModelTest { val (arrangement, viewModel) = Arrangement() .withUserIdentifierAlreadySet("user@example.com") .arrange() + SupportUrlResolver.setBaseUrl("https://previous.example") viewModel.onNoBackendSelected() advanceUntilIdle() @@ -851,6 +860,7 @@ class NewLoginViewModelTest { assertEquals(ServerConfigProvider.EmptyServerConfig, viewModel.serverConfig) assertEquals(NewLoginFlowState.MissingBackendConfig, viewModel.state.flowState) assertEquals(false, viewModel.state.nextEnabled) + assertEquals("/support/search", SupportUrlResolver.resolve("", SupportPage.SEARCH)) verify(exactly = 0) { arrangement.validateEmailOrSSOCodeUseCase(any()) } @@ -893,6 +903,10 @@ class NewLoginViewModelTest { assertEquals(serverConfig, viewModel.serverConfig) assertEquals(NewLoginFlowState.BackendConfigSuccess, viewModel.state.flowState) + assertEquals( + "${serverConfig.website.trimEnd('/')}/support/search", + SupportUrlResolver.resolve("", SupportPage.SEARCH) + ) coVerify(exactly = 1) { arrangement.getServerConfigUseCase(configUrl) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/SupportPage.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/SupportPage.kt new file mode 100644 index 00000000000..db5852fb98c --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/SupportPage.kt @@ -0,0 +1,56 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import androidx.annotation.StringRes +import com.wire.android.ui.common.R + +enum class SupportPage( + val path: String, + @StringRes val hardcodedUrlRes: Int +) { + SUPPORT("", R.string.url_support), + REPORT_MISUSE("report_misuse", R.string.url_report_misuse), + CREATE_ACCOUNT("create_account", R.string.url_create_account_learn_more), + DECRYPTION_FAILURE("decryption_failure", R.string.url_decryption_failure_learn_more), + FEDERATION_SUPPORT("federation_support", R.string.url_federation_support), + FILE_SHARING_RESTRICTED("file_sharing_restricted", R.string.url_file_sharing_restricted_learn_more), + CONVERSATION_SEARCH("conversation_search", R.string.url_learn_about_conversation_search), + OFFLINE_BACKENDS("offline_backends", R.string.url_message_details_offline_backends_learn_more), + MLS("mls", R.string.url_mls_learn_more), + REACTIONS("reactions", R.string.url_message_details_reactions_learn_more), + READ_RECEIPTS("read_receipts", R.string.url_message_details_read_receipts_learn_more), + CLIENT_FINGERPRINT("client_fingerprint", R.string.url_self_client_fingerprint_learn_more), + CLIENT_VERIFICATION("client_verification", R.string.url_self_client_verification_learn_more), + WELCOME_ANDROID("welcome_android", R.string.url_welcome_to_new_android), + VERIFY_CONVERSATION("verify_conversation", R.string.url_why_verify_conversation), + ADD_FAVORITES("add_favorites", R.string.url_how_to_add_favorites), + ADD_FOLDERS("add_folders", R.string.url_how_to_add_folders), + CREATE_CHANNEL("create_channel", R.string.url_create_channel_learn_more), + CHANGE_EMAIL("change_email", R.string.url_change_email), + DELETE_ACCOUNT("delete_account", R.string.url_delete_personal_account), + E2EE("e2ee", R.string.url_system_message_learn_more_about_e2ee), + E2EE_IDENTITY("e2ee_identity", R.string.url_e2ee_id_shield), + ADD_APPS("add_apps", R.string.url_how_to_add_apps), + SHARED_DRIVE("shared_drive", R.string.create_group_with_shared_drive_learn_more_url), + CALL_QUALITY("call_quality", R.string.url_call_network_quality_learn_more), + LEGAL_HOLD("legal_hold", R.string.url_legal_hold_learn_more), + SEARCH("search", R.string.url_learn_about_search), + CELLS_CONVERSATION("cells_conversation", R.string.empty_screen_learn_more_link_conversation), + CELLS_ALL_FILES("cells_all_files", R.string.empty_screen_learn_more_link_all_files_screen) +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/SupportUrlResolver.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/SupportUrlResolver.kt new file mode 100644 index 00000000000..8838d11190f --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/SupportUrlResolver.kt @@ -0,0 +1,65 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import android.content.res.Resources +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +/** + * Resolves support page URLs using a bundled URL when one is defined, otherwise building one from + * the website URL configured in deeplinks. + * + * Set the server URL after obtaining the server configuration, then call [resolve] or [supportUrlResource] + * with the required [SupportPage]. + */ +object SupportUrlResolver { + @Volatile + private var baseUrl: String = "" + + fun setBaseUrl(baseUrl: String?) { + this.baseUrl = baseUrl?.trim()?.trimEnd('/').orEmpty() + } + + fun resolve( + hardcodedUrl: String, + page: SupportPage + ): String = hardcodedUrl.takeIf { it.isNotBlank() } ?: buildDynamicUrl(baseUrl, page) + + fun resolve( + resources: Resources, + page: SupportPage + ): String = resolve(resources.getString(page.hardcodedUrlRes), page) + + fun resolve( + resources: Resources, + @StringRes hardcodedUrlRes: Int, + page: SupportPage + ): String = resolve(resources.getString(hardcodedUrlRes), page) + + private fun buildDynamicUrl(baseUrl: String, page: SupportPage): String { + val supportUrl = "${baseUrl.trim().trimEnd('/')}/support" + return if (page.path.isEmpty()) supportUrl else "$supportUrl/${page.path}" + } +} + +@Composable +fun supportUrlResource( + page: SupportPage +): String = SupportUrlResolver.resolve(stringResource(id = page.hardcodedUrlRes), page) diff --git a/core/ui-common/src/main/res/values/strings.xml b/core/ui-common/src/main/res/values/strings.xml index b2d87b464d1..2f13365159f 100644 --- a/core/ui-common/src/main/res/values/strings.xml +++ b/core/ui-common/src/main/res/values/strings.xml @@ -23,9 +23,56 @@ Hide password Please wait until the app is synchronized Please wait until the Internet connection is restored - Back button - Go Back - Close button + Back + Right arrow + Go back + Deleted + Blocked + Name not available + Add contact + open link + https://support.wire.com + https://support.wire.com/hc/articles/202857164 + https://support.wire.com/hc/articles/115004082129 + https://support.wire.com/hc/articles/207948115 + https://support.wire.com/hc/categories/4719917054365 + https://support.wire.com/hc/articles/4406404582673 + https://support.wire.com/hc/articles/115001426529 + https://support.wire.com/hc/articles/9357718008093 + https://support.wire.com/hc/articles/12434725011485 + https://support.wire.com/hc/articles/212053645 + https://support.wire.com/hc/articles/360000665277 + https://support.wire.com/hc/articles/207692235 + https://support.wire.com/hc/articles/207693005 + https://support.wire.com/hc/articles/6655706999581 + https://support.wire.com/hc/articles/207859815 + https://support.wire.com/hc/articles/360002855557 + https://support.wire.com/hc/articles/360002855817 + https://support.wire.com/hc/articles/25732551379101 + https://support.wire.com/hc/articles/203762940 + https://support.wire.com/hc/articles/207555795 + https://support.wire.com/hc/articles/10898523878173 + https://support.wire.com/hc/articles/9211300150685 + https://support.wire.com/hc/articles/360000296158 + https://support.wire.com/hc/articles/32207749480477 + https://support.wire.com/hc/articles/34087740781213 + https://support.wire.com/hc/articles/360002018278 + https://support.wire.com/hc/articles/32207745256221 + https://support.wire.com/hc/articles/32207800433309 + Learn more about legal hold + Connecting not possible + You can not connect to this user due to legal hold. + Connection request sent + Your backend does not federate with the backend of %1$s. You can not connect with them. + Connection request could not be sent + Connection request could not be canceled + Connection request canceled + You ignored %1$s\'s request + Connection request could not be accepted + Connection request accepted + Connection request could not be ignored + User could not be unblocked + Close Close Main navigation Drop down arrow diff --git a/core/ui-common/src/test/kotlin/com/wire/android/util/SupportUrlResolverTest.kt b/core/ui-common/src/test/kotlin/com/wire/android/util/SupportUrlResolverTest.kt new file mode 100644 index 00000000000..0badf98c075 --- /dev/null +++ b/core/ui-common/src/test/kotlin/com/wire/android/util/SupportUrlResolverTest.kt @@ -0,0 +1,98 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test + +class SupportUrlResolverTest { + + @AfterEach + fun tearDown() { + SupportUrlResolver.setBaseUrl(null) + } + + @Test + fun givenHardcodedUrl_whenResolving_thenReturnHardcodedUrl() { + val hardcodedUrl = "https://support.wire.com/hc/articles/123" + SupportUrlResolver.setBaseUrl("https://custom.example") + + val result = SupportUrlResolver.resolve( + hardcodedUrl = hardcodedUrl, + page = SupportPage.FEDERATION_SUPPORT + ) + + assertEquals(hardcodedUrl, result) + } + + @Test + fun givenBlankHardcodedUrl_whenResolving_thenReturnDynamicUrl() { + SupportUrlResolver.setBaseUrl("https://custom.example") + + val result = SupportUrlResolver.resolve( + hardcodedUrl = "", + page = SupportPage.FEDERATION_SUPPORT + ) + + assertEquals("https://custom.example/support/federation_support", result) + } + + @Test + fun givenWhitespaceHardcodedUrl_whenResolving_thenReturnDynamicUrl() { + SupportUrlResolver.setBaseUrl("https://custom.example") + + val result = SupportUrlResolver.resolve( + hardcodedUrl = " ", + page = SupportPage.SEARCH + ) + + assertEquals("https://custom.example/support/search", result) + } + + @Test + fun givenBaseUrlWithTrailingSlash_whenResolving_thenReturnDynamicUrlWithSingleSlash() { + SupportUrlResolver.setBaseUrl(" https://custom.example/ ") + + val result = SupportUrlResolver.resolve( + hardcodedUrl = "", + page = SupportPage.CALL_QUALITY + ) + + assertEquals("https://custom.example/support/call_quality", result) + } + + @Test + fun givenSupportHomePage_whenResolving_thenReturnDynamicSupportPageUrl() { + SupportUrlResolver.setBaseUrl("https://custom.example/") + + val result = SupportUrlResolver.resolve(hardcodedUrl = "", page = SupportPage.SUPPORT) + + assertEquals("https://custom.example/support", result) + } + + @Test + fun givenBaseUrlChanges_whenResolving_thenUseLatestBaseUrl() { + SupportUrlResolver.setBaseUrl("https://first.example") + SupportUrlResolver.setBaseUrl("https://second.example") + + val result = SupportUrlResolver.resolve(hardcodedUrl = "", page = SupportPage.SEARCH) + + assertEquals("https://second.example/support/search", result) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index 98d3f37b23c..30615bedbd9 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt @@ -61,12 +61,14 @@ import com.wire.android.feature.cells.ui.publiclink.PublicLinkScreenData import com.wire.android.feature.cells.ui.recyclebin.RestoreConfirmationDialog import com.wire.android.feature.cells.ui.recyclebin.RestoreParentFolderConfirmationDialog import com.wire.android.feature.cells.ui.recyclebin.UnableToRestoreDialog +import com.wire.android.util.SupportPage import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.supportUrlResource import com.wire.kalium.cells.domain.paging.FileListLoadError import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -301,14 +303,14 @@ private fun EmptyScreen( if (!isFiltering && !isRecycleBin && !isAllFiles) { LearnMoreLink( textRes = R.string.empty_screen_learn_more_conversation, - urlRes = R.string.empty_screen_learn_more_link_conversation, + page = SupportPage.CELLS_CONVERSATION, ) } if (isAllFiles) { LearnMoreLink( textRes = R.string.empty_screen_learn_more, - urlRes = R.string.empty_screen_learn_more_link_all_files_screen, + page = SupportPage.CELLS_ALL_FILES, ) } @@ -323,12 +325,12 @@ private fun EmptyScreen( @Composable private fun LearnMoreLink( @StringRes textRes: Int, - @StringRes urlRes: Int, + page: SupportPage, ) { val context = LocalContext.current.applicationContext val onlineEditor = remember(context) { OnlineEditor(context) } - val url = stringResource(urlRes) + val url = supportUrlResource(page) Text( text = stringResource(textRes), style = MaterialTheme.wireTypography.body01, diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 99c20f57613..03dab49afed 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -16,9 +16,6 @@ ~ along with this program. If not, see http://www.gnu.org/licenses/. --> - https://support.wire.com/hc/articles/32207745256221 - https://support.wire.com/hc/articles/32207800433309 - Retry Error The list of files couldn\'t be loaded. Please try again. From a76dfb25424d374e8fdb0eb028d0463514a06212 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Fri, 14 Aug 2026 14:32:50 +0200 Subject: [PATCH 4/6] fix: adapt gov backport to 4.22 --- .../ui/authentication/login/email/LoginEmailViewModel.kt | 8 +++++--- .../ui/newauthentication/login/NewLoginViewModel.kt | 5 +++-- app/src/main/res/values/strings.xml | 2 ++ .../com/wire/android/ui/WireActivityViewModelTest.kt | 3 +++ .../ui/newauthentication/login/NewLoginViewModelTest.kt | 3 ++- .../kotlin/com/wire/android/util/CustomTabsHelperTest.kt | 0 .../main/kotlin/com/wire/android/ui/common/WireDialog.kt | 7 +++---- core/ui-common/src/main/res/values/strings.xml | 2 ++ 8 files changed, 20 insertions(+), 10 deletions(-) rename {core/ui-common => app}/src/test/kotlin/com/wire/android/util/CustomTabsHelperTest.kt (100%) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt index 86bac2a9ad5..f008a0ef945 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt @@ -65,6 +65,7 @@ import com.wire.kalium.logic.feature.client.RegisterClientResult import com.wire.kalium.logic.feature.server.GetServerConfigResult import com.wire.kalium.logic.feature.server.GetServerConfigUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult +import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -76,6 +77,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +import javax.inject.Named @Suppress("LongParameterList", "ComplexMethod", "TooManyFunctions") @HiltViewModel @@ -89,7 +91,7 @@ class LoginEmailViewModel @Inject constructor( private val dispatchers: DispatcherProvider, defaultServerConfig: ServerConfig.Links, @DefaultWebSocketEnabledByDefault private val defaultWebSocketEnabledByDefault: Boolean, - isDefaultBackendConfigured: Boolean = true, + @Named("isDefaultBackendConfigured") isDefaultBackendConfigured: Boolean = true, private val getServerConfigUseCase: Lazy? = null, private val globalDataStore: Lazy? = null, ) : LoginViewModel( @@ -162,12 +164,12 @@ class LoginEmailViewModel @Inject constructor( withContext(dispatchers.main()) { updateBackendConfigState(LoginEmailState.BackendConfigState.Loading) } - when (val result = getServerConfigUseCase?.value?.invoke(configUrl)) { + when (val result = getServerConfigUseCase?.get()?.invoke(configUrl)) { is GetServerConfigResult.Success -> { CustomTabsHelper.setBackendWebsiteUrl(result.serverConfigLinks.website) SupportUrlResolver.setBaseUrl(result.serverConfigLinks.website) globalDataStore?.let { - BackendSupportConfig.storeFromServerLinks(it.value, result.serverConfigLinks) + BackendSupportConfig.storeFromServerLinks(it.get(), result.serverConfigLinks) } withContext(dispatchers.main()) { serverConfig = result.serverConfigLinks diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt index 3f6849c2d21..b14f0a43b64 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt @@ -65,6 +65,7 @@ import com.wire.kalium.logic.feature.auth.sso.SSOLoginSessionResult import com.wire.kalium.logic.feature.client.RegisterClientResult import com.wire.kalium.logic.feature.server.GetServerConfigResult import com.wire.kalium.logic.feature.server.GetServerConfigUseCase +import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -207,12 +208,12 @@ class NewLoginViewModel( } updateLoginFlowState(NewLoginFlowState.LoadingBackendConfig) - when (val result = getServerConfigUseCase?.value?.invoke(configUrl)) { + when (val result = getServerConfigUseCase?.get()?.invoke(configUrl)) { is GetServerConfigResult.Success -> { CustomTabsHelper.setBackendWebsiteUrl(result.serverConfigLinks.website) SupportUrlResolver.setBaseUrl(result.serverConfigLinks.website) globalDataStore?.let { - BackendSupportConfig.storeFromServerLinks(it.value, result.serverConfigLinks) + BackendSupportConfig.storeFromServerLinks(it.get(), result.serverConfigLinks) } withContext(dispatchers.main()) { serverConfig = result.serverConfigLinks diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb4ef5dae14..75576a3abe5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -282,6 +282,7 @@ App Settings Give Feedback Report Bug + Report misuse About This App Privacy Policy Legal @@ -1138,6 +1139,7 @@ Ignore Get certainty about the identity of %s\'s before connecting. Please verify the person\'s identity before accepting the connection request. + Wire\'s Support team will never reach out to you in the app. Wire can\'t find this person You may not have permission with this account or the person may not be on Wire. diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index e238ba98b72..a8f5b73700c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -37,6 +37,8 @@ import com.wire.android.di.ObserveSelfUserUseCaseProvider import com.wire.android.di.ObserveSyncStateUseCaseProvider import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.feature.AccountSwitchUseCase +import com.wire.android.feature.SwitchAccountParam +import com.wire.android.feature.SwitchAccountResult import com.wire.android.framework.TestClient import com.wire.android.framework.TestUser import com.wire.android.framework.TestUser.SELF_USER @@ -56,6 +58,7 @@ import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.newServerConfig import com.wire.kalium.common.error.NetworkFailure import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.auth.PersistentWebSocketStatus import com.wire.kalium.logic.data.call.Call diff --git a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt index 1295509e059..4edd79cfadd 100644 --- a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt @@ -42,6 +42,7 @@ import com.wire.kalium.logic.feature.auth.sso.ValidateSSOCodeUseCase.Companion.S import com.wire.kalium.logic.feature.client.RegisterClientResult import com.wire.kalium.logic.feature.server.GetServerConfigResult import com.wire.kalium.logic.feature.server.GetServerConfigUseCase +import dagger.Lazy import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -787,7 +788,7 @@ class NewLoginViewModelTest { ServerConfig.STAGING, defaultSSOCodeConfig, isDefaultBackendConfigured, - lazy { getServerConfigUseCase }, + Lazy { getServerConfigUseCase }, ) } diff --git a/core/ui-common/src/test/kotlin/com/wire/android/util/CustomTabsHelperTest.kt b/app/src/test/kotlin/com/wire/android/util/CustomTabsHelperTest.kt similarity index 100% rename from core/ui-common/src/test/kotlin/com/wire/android/util/CustomTabsHelperTest.kt rename to app/src/test/kotlin/com/wire/android/util/CustomTabsHelperTest.kt diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt index 30578f333cb..8923ca77e01 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt @@ -39,7 +39,7 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.paneTitle @@ -53,7 +53,6 @@ import androidx.compose.ui.window.DialogProperties import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton -import com.wire.android.util.CustomTabsHelper import com.wire.android.ui.common.button.WireTertiaryButton import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.theme.isTablet @@ -178,7 +177,7 @@ fun WireDialogContent( centerContent: Boolean = false, content: @Composable (() -> Unit)? = null ) { - val context = LocalContext.current + val uriHandler = LocalUriHandler.current Surface( modifier = modifier.padding(MaterialTheme.wireDimensions.dialogCardMargin), @@ -212,7 +211,7 @@ fun WireDialogContent( TextWithLinkSuffix( text = text, linkText = textSuffixLink?.linkText, - onLinkClick = { textSuffixLink?.linkUrl?.let { CustomTabsHelper.launchUrl(context, it) } }, + onLinkClick = { textSuffixLink?.linkUrl?.let(uriHandler::openUri) }, modifier = Modifier.padding(bottom = MaterialTheme.wireDimensions.dialogTextsSpacing) ) } diff --git a/core/ui-common/src/main/res/values/strings.xml b/core/ui-common/src/main/res/values/strings.xml index 2f13365159f..3087d0ce346 100644 --- a/core/ui-common/src/main/res/values/strings.xml +++ b/core/ui-common/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ Hide password Please wait until the app is synchronized Please wait until the Internet connection is restored + No camera app found to complete this action. Back Right arrow Go back @@ -38,6 +39,7 @@ https://support.wire.com/hc/categories/4719917054365 https://support.wire.com/hc/articles/4406404582673 https://support.wire.com/hc/articles/115001426529 + https://support.wire.com/hc/articles/203121850 https://support.wire.com/hc/articles/9357718008093 https://support.wire.com/hc/articles/12434725011485 https://support.wire.com/hc/articles/212053645 From 5805d8b42e7137521db2c996f5c585aff3b9524c Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Fri, 14 Aug 2026 14:58:57 +0200 Subject: [PATCH 5/6] fix: suppress large backport test class --- .../kotlin/com/wire/android/ui/WireActivityViewModelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index a8f5b73700c..9a85fa9003c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -115,7 +115,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -@Suppress("MaxLineLength") +@Suppress("MaxLineLength", "LargeClass") @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) class WireActivityViewModelTest { From 510501141298df5884d057f3374c2d0f45c92c9d Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Wed, 19 Aug 2026 21:40:57 +0200 Subject: [PATCH 6/6] chore: update Kalium 4.22 reference --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 1f31f3b41ac..e3921ccd6c2 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 1f31f3b41acd1c9c61c119b75f4dc89edf6583c6 +Subproject commit e3921ccd6c244b221193deae1e88e41a1066e88f