From 317807e4f05a4e422d258eb1e0744710a8845f12 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Fri, 3 Jul 2026 09:03:56 +0200 Subject: [PATCH 1/3] feat: support apps without a default backend --- .../wire/android/datastore/GlobalDataStore.kt | 25 ++ .../android/di/AuthServerConfigProvider.kt | 52 ++++- .../android/navigation/NavigationUtils.kt | 2 +- .../android/navigation/OtherDestinations.kt | 17 +- .../wire/android/ui/WireActivityViewModel.kt | 30 ++- .../ui/authentication/BackendConfigSetup.kt | 215 ++++++++++++++++++ .../ui/authentication/login/LoginScreen.kt | 146 ++++++++---- .../ui/authentication/login/LoginViewModel.kt | 12 + .../authentication/welcome/WelcomeScreen.kt | 64 ++++-- .../welcome/WelcomeScreenState.kt | 3 +- .../welcome/WelcomeViewModel.kt | 9 + .../wire/android/util/BackendSupportConfig.kt | 102 +++++++++ .../com/wire/android/util/CustomTabsHelper.kt | 9 +- app/src/main/res/values/strings.xml | 7 + .../kotlin/customization/FeatureConfigs.kt | 1 + .../com/wire/android/ui/common/WireDialog.kt | 7 +- .../wire/android/util/SupportUrlResolver.kt | 47 ++++ .../main/res/drawable/ic_qr_code_scanner.xml | 27 +++ .../android/util/SupportUrlResolverTest.kt | 70 ++++++ default.json | 1 + 20 files changed, 763 insertions(+), 83 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/kotlin/com/wire/android/util/SupportUrlResolver.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/SupportUrlResolverTest.kt 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 b624778d100..b22137963d2 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt @@ -70,6 +70,9 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex private fun userLastMigrationAppVersion(userId: String): Preferences.Key = intPreferencesKey("migration_app_version_$userId") + + private fun backendSupportEmailKey(backendApiUrl: String): Preferences.Key = + stringPreferencesKey("backend_support_email_${backendApiUrl.sha256()}") } suspend fun clear() { @@ -238,4 +241,26 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex suspend fun setEnterToSend(enabled: Boolean) { context.dataStore.edit { it[ENTER_TO_SENT] = enabled } } + + 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/AuthServerConfigProvider.kt b/app/src/main/kotlin/com/wire/android/di/AuthServerConfigProvider.kt index 09249c122b4..c363fd06d30 100644 --- a/app/src/main/kotlin/com/wire/android/di/AuthServerConfigProvider.kt +++ b/app/src/main/kotlin/com/wire/android/di/AuthServerConfigProvider.kt @@ -28,27 +28,57 @@ import javax.inject.Singleton @Singleton class AuthServerConfigProvider @Inject constructor() { // todo check with soft logout - private val defaultBackendConfigs = ServerConfig.Links( - api = BuildConfig.DEFAULT_BACKEND_URL_BASE_API, - accounts = BuildConfig.DEFAULT_BACKEND_URL_ACCOUNTS, - webSocket = BuildConfig.DEFAULT_BACKEND_URL_BASE_WEBSOCKET, - teams = BuildConfig.DEFAULT_BACKEND_URL_TEAM_MANAGEMENT, - blackList = BuildConfig.DEFAULT_BACKEND_URL_BLACKLIST, - website = BuildConfig.DEFAULT_BACKEND_URL_WEBSITE, - title = BuildConfig.DEFAULT_BACKEND_TITLE, - isOnPremises = false, - apiProxy = null - ) + private val defaultBackendConfigs = if (BuildConfig.DEFAULT_BACKEND_ENABLED) { + ServerConfig.Links( + api = BuildConfig.DEFAULT_BACKEND_URL_BASE_API, + accounts = BuildConfig.DEFAULT_BACKEND_URL_ACCOUNTS, + webSocket = BuildConfig.DEFAULT_BACKEND_URL_BASE_WEBSOCKET, + teams = BuildConfig.DEFAULT_BACKEND_URL_TEAM_MANAGEMENT, + blackList = BuildConfig.DEFAULT_BACKEND_URL_BLACKLIST, + website = BuildConfig.DEFAULT_BACKEND_URL_WEBSITE, + title = BuildConfig.DEFAULT_BACKEND_TITLE, + isOnPremises = false, + apiProxy = null + ) + } else { + EmptyServerConfig + } private val _authServer: MutableStateFlow = MutableStateFlow(defaultBackendConfigs) val authServer: StateFlow = _authServer + private val _backendConfigSuccessVisible: MutableStateFlow = MutableStateFlow(false) + val backendConfigSuccessVisible: StateFlow = _backendConfigSuccessVisible + fun updateAuthServer(serverLinks: ServerConfig.Links) { _authServer.value = serverLinks } + fun updateAuthServerFromBackendConfiguration(serverLinks: ServerConfig.Links) { + updateAuthServer(serverLinks) + _backendConfigSuccessVisible.value = true + } + fun updateAuthServer(serverConfig: ServerConfig) { _authServer.value = serverConfig.links } + fun dismissBackendConfigSuccess() { + _backendConfigSuccessVisible.value = false + } + fun defaultServerLinks() = defaultBackendConfigs + + companion object { + val EmptyServerConfig = ServerConfig.Links( + api = "", + accounts = "", + webSocket = "", + teams = "", + blackList = "", + website = "", + title = "", + isOnPremises = false, + apiProxy = null + ) + } } 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 9ba8c69233c..b3ff8ae7dcb 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt @@ -109,7 +109,7 @@ fun String.getBaseRoute(): 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/navigation/OtherDestinations.kt b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt index 1ed00dd158e..eb1b47f50b2 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt @@ -25,6 +25,7 @@ import androidx.annotation.StringRes import com.ramcosta.composedestinations.spec.Direction import com.wire.android.BuildConfig import com.wire.android.R +import com.wire.android.util.BackendSupportConfig import com.wire.android.util.EmailComposer import com.wire.android.util.LogFileWriter import com.wire.android.util.getDeviceIdString @@ -32,6 +33,7 @@ import com.wire.android.util.getGitBuildId import com.wire.android.util.getUrisOfFilesInDirectory import com.wire.android.util.multipleFileSharingIntent import com.wire.android.util.sha256 +import kotlinx.coroutines.runBlocking interface ExternalUriDirection : Direction { val uri: Uri @@ -96,11 +98,24 @@ object GiveFeedbackDestination : IntentDirection { } object ReportBugDestination : IntentDirection { + @Suppress("ReturnCount") override fun intent(context: Context): Intent { + val supportEmail = runBlocking { + BackendSupportConfig.resolveEmail(context, context.getString(R.string.send_bug_report_email)) + } + if (supportEmail == null) { + BackendSupportConfig.supportPageIntent()?.let { return it } + context.getString(R.string.url_support).takeIf { it.isNotBlank() }?.let { + return Intent(Intent.ACTION_VIEW, Uri.parse(it)) + } + } + val dir = LogFileWriter.logsDirectory(context) val logsUris = context.getUrisOfFilesInDirectory(dir) val intent = context.multipleFileSharingIntent(logsUris) - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.send_bug_report_email))) + supportEmail?.let { + intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(it)) + } intent.putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.send_bug_report_subject)) intent.putExtra( Intent.EXTRA_TEXT, 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 ddcf9d3f0b4..913a3eb06dd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -45,8 +45,10 @@ import com.wire.android.ui.common.dialogs.CustomServerDialogState import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.joinConversation.JoinConversationViaCodeState 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.dispatchers.DispatcherProvider @@ -75,6 +77,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.GetSessionsUseCase +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 @@ -151,6 +154,7 @@ class WireActivityViewModel @Inject constructor( observeScreenshotCensoringConfigState() observeAppThemeState() observeLogoutState() + observeBackendWebsiteUrl() } private suspend fun shouldEnrollToE2ei(): Boolean = observeCurrentValidUserId.first()?.let { @@ -168,6 +172,25 @@ class WireActivityViewModel @Inject constructor( } } + private fun observeBackendWebsiteUrl() { + viewModelScope.launch(dispatchers.io()) { + observeCurrentValidUserId.collect { userId -> + val serverLinks = userId?.let { + runCatching { + when (val result = coreLogic.getSessionScope(it).users.serverLinks()) { + is SelfServerConfigUseCase.Result.Success -> result.serverLinks.links + is SelfServerConfigUseCase.Result.Failure -> null + } + }.getOrNull() + } ?: authServerConfigProvider.get().authServer.value + BackendSupportConfig.setCurrentBackend(serverLinks) + val websiteUrl = serverLinks.website + + CustomTabsHelper.setBackendWebsiteUrl(websiteUrl) + } + } + } + private fun observeSyncState() { viewModelScope.launch(dispatchers.io()) { observeCurrentValidUserId @@ -348,7 +371,7 @@ class WireActivityViewModel @Inject constructor( if (backendDialogState is CustomServerDetailsDialogState) { viewModelScope.launch { authServerConfigProvider.get() - .updateAuthServer(backendDialogState.serverLinks) + .updateAuthServerFromBackendConfiguration(backendDialogState.serverLinks) dismissCustomBackendDialog() if (checkNumberOfSessions() >= BuildConfig.MAX_ACCOUNTS) { globalAppState = globalAppState.copy(maxAccountDialog = true) @@ -422,7 +445,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.storeFromConfigUrl(globalDataStore.get(), it, url) + } 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..20e5a288b9c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/BackendConfigSetup.kt @@ -0,0 +1,215 @@ +/* + * 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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +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.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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.snackbar.LocalSnackbarHostState +import com.wire.android.ui.common.spacers.VerticalSpace +import com.wire.android.ui.common.textfield.WireTextField +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireTypography +import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.android.ui.common.R as CommonR +import kotlinx.coroutines.launch + +@Composable +fun MissingBackendConfigContent( + modifier: Modifier = Modifier, + showTitle: Boolean = false, + centerText: Boolean = false, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, +) { + val context = LocalContext.current + val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() + val noCameraAppMessage = stringResource(R.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), + keyboardOptions = KeyboardOptions.Default, + modifier = Modifier.testTag("backendConfigInput"), + 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.label_continue), + fillMaxWidth = true, + state = if (isConfigInputEmpty) WireButtonState.Disabled else WireButtonState.Default, + onClick = { context.openBackendConfig(backendConfigTextState.text.toString()) }, + modifier = Modifier.testTag("backendConfigContinueButton"), + ) + } +} + +@Composable +fun BackendConfigSuccessContent( + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.Center, + onContinue: () -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = verticalArrangement, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check_tick), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.positive, + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(R.string.backend_config_success_title), + style = typography().body01, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + } + VerticalSpace.x32() + Text( + text = stringResource(R.string.backend_config_success_description), + style = typography().body01, + color = colorsScheme().secondaryText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + VerticalSpace.x32() + WirePrimaryButton( + text = stringResource(R.string.label_continue), + fillMaxWidth = true, + onClick = onContinue, + modifier = Modifier.testTag("backendConfigSuccessContinueButton"), + ) + } +} + +fun ServerConfig.Links.isConfigured() = api.isNotBlank() + +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_PREFIX = "wire://access/?config=" 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 bc620fa6d00..e1daec6ad1f 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 @@ -23,12 +23,14 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.MaterialTheme @@ -55,7 +57,10 @@ import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination 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.ServerTitle +import com.wire.android.ui.authentication.isConfigured import com.wire.android.ui.authentication.login.email.LoginEmailScreen import com.wire.android.ui.authentication.login.email.LoginEmailVerificationCodeScreen import com.wire.android.ui.authentication.login.email.LoginEmailViewModel @@ -153,6 +158,8 @@ private fun MainLoginContent( val scope = rememberCoroutineScope() val scrollState = rememberScrollState() + val isBackendConfigured = loginEmailViewModel.serverConfig.isConfigured() + val showBackendConfigSuccess = isBackendConfigured && loginEmailViewModel.isBackendConfigSuccessVisible val initialPageIndex = if (ssoLoginResult == null) LoginTabItem.EMAIL.ordinal else LoginTabItem.SSO.ordinal val pagerState = rememberPagerState( initialPage = initialPageIndex, @@ -164,43 +171,27 @@ private fun MainLoginContent( WireScaffold( topBar = { - WireCenterAlignedTopAppBar( - elevation = scrollState.rememberTopBarElevationState().value, - title = stringResource(R.string.login_title), - subtitleContent = { - if (loginEmailViewModel.serverConfig.isOnPremises) { - ServerTitle( - serverLinks = loginEmailViewModel.serverConfig, - style = MaterialTheme.wireTypography.body01 - ) - } - }, + LoginTopBar( + scrollState = scrollState, + isBackendConfigured = isBackendConfigured, + showBackendConfigSuccess = showBackendConfigSuccess, + loginEmailViewModel = loginEmailViewModel, + pagerState = pagerState, onNavigationPressed = onBackPressed, - navigationIconType = NavigationIconType.Back(R.string.content_description_login_back_btn) - ) { - 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 - ) + onTabChange = { tabIndex -> + 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) } + ) } - }, - modifier = Modifier.padding( - start = MaterialTheme.wireDimensions.spacing16x, - end = MaterialTheme.wireDimensions.spacing16x - ), - ) - } + } else { + scope.launch { pagerState.animateScrollToPage(tabIndex) } + } + }, + ) }, modifier = Modifier.fillMaxHeight(), ) { internalPadding -> @@ -208,25 +199,86 @@ private fun MainLoginContent( val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - CompositionLocalProvider(LocalOverscrollConfiguration provides null) { - HorizontalPager( - state = pagerState, + if (showBackendConfigSuccess) { + BackendConfigSuccessContent( modifier = Modifier .fillMaxWidth() .padding(internalPadding) - ) { pageIndex -> - when (LoginTabItem.values()[pageIndex]) { - LoginTabItem.EMAIL -> LoginEmailScreen(onSuccess, onRemoveDeviceNeeded, loginEmailViewModel, scrollState) - LoginTabItem.SSO -> LoginSSOScreen(onSuccess, onRemoveDeviceNeeded, ssoLoginResult) + .padding(MaterialTheme.wireDimensions.spacing16x), + onContinue = loginEmailViewModel::dismissBackendConfigSuccess, + ) + } else if (isBackendConfigured) { + 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 { + MissingBackendConfigContent( + modifier = Modifier + .fillMaxWidth() + .padding(internalPadding) + .padding(MaterialTheme.wireDimensions.spacing16x), + ) + } + } +} + +@Composable +private fun LoginTopBar( + scrollState: ScrollState, + isBackendConfigured: Boolean, + showBackendConfigSuccess: Boolean, + loginEmailViewModel: LoginEmailViewModel, + pagerState: PagerState, + onNavigationPressed: () -> Unit, + onTabChange: (Int) -> Unit, +) { + WireCenterAlignedTopAppBar( + elevation = scrollState.rememberTopBarElevationState().value, + title = stringResource( + if (isBackendConfigured) { + R.string.login_title + } else { + R.string.missing_backend_config_title + } + ), + subtitleContent = { + if (isBackendConfigured && loginEmailViewModel.serverConfig.isOnPremises) { + ServerTitle( + serverLinks = loginEmailViewModel.serverConfig, + style = MaterialTheme.wireTypography.body01 + ) + } + }, + onNavigationPressed = onNavigationPressed, + navigationIconType = NavigationIconType.Back(R.string.content_description_login_back_btn) + ) { + if (isBackendConfigured && !showBackendConfigSuccess) { + WireTabRow( + tabs = LoginTabItem.values().toList(), + selectedTabIndex = pagerState.calculateCurrentTab(), + onTabChange = onTabChange, + modifier = Modifier.padding( + start = MaterialTheme.wireDimensions.spacing16x, + end = MaterialTheme.wireDimensions.spacing16x + ), + ) } } } 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 0750cd50fce..216902a2bba 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 @@ -53,12 +53,24 @@ open class LoginViewModel @Inject constructor( var serverConfig: ServerConfig.Links by mutableStateOf(authServerConfigProvider.authServer.value) private set + var isBackendConfigSuccessVisible: Boolean by mutableStateOf(authServerConfigProvider.backendConfigSuccessVisible.value) + private set + init { viewModelScope.launch { authServerConfigProvider.authServer.collect { serverConfig = it } } + viewModelScope.launch { + authServerConfigProvider.backendConfigSuccessVisible.collect { + isBackendConfigSuccessVisible = it + } + } + } + + fun dismissBackendConfigSuccess() { + authServerConfigProvider.dismissBackendConfigSuccess() } suspend fun registerClient( 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 09ef0f1bf31..f6c7a1610a4 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,7 +73,10 @@ import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.authentication.BackendConfigSuccessContent +import com.wire.android.ui.authentication.MissingBackendConfigContent import com.wire.android.ui.authentication.ServerTitle +import com.wire.android.ui.authentication.isConfigured import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.dialogs.FeatureDisabledWithProxyDialogContent @@ -111,9 +114,11 @@ fun WelcomeScreen( viewModel: WelcomeViewModel = hiltViewModel() ) { WelcomeContent( - viewModel.state.isThereActiveSession, - viewModel.state.maxAccountsReached, - viewModel.state.links, + state = viewModel.state, + onBackendConfigSuccessContinue = { + viewModel.dismissBackendConfigSuccess() + navigator.navigate(NavigationCommand(LoginScreenDestination())) + }, navigator::navigateBack, navigator::navigate ) @@ -122,9 +127,8 @@ fun WelcomeScreen( @OptIn(ExperimentalComposeUiApi::class) @Composable private fun WelcomeContent( - isThereActiveSession: Boolean, - maxAccountsReached: Boolean, - state: ServerConfig.Links, + state: WelcomeScreenState, + onBackendConfigSuccessContinue: () -> Unit, navigateBack: () -> Unit, navigate: (NavigationCommand) -> Unit ) { @@ -132,7 +136,7 @@ private fun WelcomeContent( val createPersonalAccountDisabledWithProxyDialogState = rememberVisibilityState() val context = LocalContext.current WireScaffold(topBar = { - if (isThereActiveSession) { + if (state.isThereActiveSession) { WireCenterAlignedTopAppBar( elevation = dimensions().spacing0x, title = "", @@ -151,18 +155,42 @@ private fun WelcomeContent( ) { val maxAccountsReachedDialogState = rememberVisibilityState() MaxAccountsReachedDialog(dialogState = maxAccountsReachedDialogState) { navigateBack() } - if (maxAccountsReached) { + if (state.maxAccountsReached) { maxAccountsReachedDialogState.show(maxAccountsReachedDialogState.savedState ?: MaxAccountsReachedDialogState) } + if (state.isBackendConfigSuccessVisible) { + BackendConfigSuccessContent( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MaterialTheme.wireDimensions.welcomeButtonHorizontalPadding) + .weight(1f, true), + onContinue = onBackendConfigSuccessContinue, + ) + return@Column + } + + if (!state.links.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, contentDescription = null ) - if (state.isOnPremises) { - ServerTitle(serverLinks = state, modifier = Modifier.padding(top = dimensions().spacing16x)) + if (state.links.isOnPremises) { + ServerTitle(serverLinks = state.links, modifier = Modifier.padding(top = dimensions().spacing16x)) } WelcomeCarousel(modifier = Modifier.weight(1f, true)) @@ -181,18 +209,18 @@ private fun WelcomeContent( FeatureDisabledWithProxyDialogContent( dialogState = enterpriseDisabledWithProxyDialogState, onActionButtonClicked = { - CustomTabsHelper.launchUrl(context, state.teams) + CustomTabsHelper.launchUrl(context, state.links.teams) } ) FeatureDisabledWithProxyDialogContent(dialogState = createPersonalAccountDisabledWithProxyDialogState) if (LocalCustomUiConfigurationProvider.current.isAccountCreationAllowed) { CreateEnterpriseAccountButton { - if (state.isProxyEnabled()) { + if (state.links.isProxyEnabled()) { enterpriseDisabledWithProxyDialogState.show( enterpriseDisabledWithProxyDialogState.savedState ?: FeatureDisabledWithProxyDialogState( R.string.create_team_not_supported_dialog_description, - state.teams + state.links.teams ) ) } else { @@ -206,7 +234,7 @@ private fun WelcomeContent( WelcomeFooter( modifier = Modifier.padding(horizontal = MaterialTheme.wireDimensions.welcomeTextHorizontalPadding), onPrivateAccountClick = { - if (state.isProxyEnabled()) { + if (state.links.isProxyEnabled()) { createPersonalAccountDisabledWithProxyDialogState.show( createPersonalAccountDisabledWithProxyDialogState.savedState ?: FeatureDisabledWithProxyDialogState( R.string.create_personal_account_not_supported_dialog_description @@ -391,11 +419,11 @@ private fun shouldJumpToEnd(previousPage: Int, currentPage: Int, lastPage: Int): fun PreviewWelcomeScreen() { WireTheme { WelcomeContent( - isThereActiveSession = false, - maxAccountsReached = false, - state = ServerConfig.DEFAULT, + state = WelcomeScreenState(ServerConfig.DEFAULT), + onBackendConfigSuccessContinue = {}, navigateBack = {}, - navigate = {}) + navigate = {} + ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreenState.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreenState.kt index be789a9f58a..375f6511e65 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreenState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreenState.kt @@ -22,5 +22,6 @@ import com.wire.kalium.logic.configuration.server.ServerConfig data class WelcomeScreenState( val links: ServerConfig.Links, val isThereActiveSession: Boolean = false, - val maxAccountsReached: Boolean = false + val maxAccountsReached: Boolean = false, + val isBackendConfigSuccessVisible: Boolean = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModel.kt index cfd88d5a67b..d963c323e12 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModel.kt @@ -53,6 +53,15 @@ class WelcomeViewModel @Inject constructor( state = state.copy(links = it) } } + viewModelScope.launch { + authServerConfigProvider.backendConfigSuccessVisible.collect { + state = state.copy(isBackendConfigSuccessVisible = it) + } + } + } + + fun dismissBackendConfigSuccess() { + authServerConfigProvider.dismissBackendConfigSuccess() } private fun checkNumberOfSessions() { 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..f44c0bf304e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/BackendSupportConfig.kt @@ -0,0 +1,102 @@ +/* + * 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.appLogger +import com.wire.android.datastore.GlobalDataStore +import com.wire.kalium.logic.configuration.server.ServerConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.HttpURLConnection +import java.net.URL + +object BackendSupportConfig { + + private const val CONNECT_TIMEOUT_MILLIS = 5_000 + private const val READ_TIMEOUT_MILLIS = 5_000 + + private val json = Json { ignoreUnknownKeys = true } + + @Volatile + private var currentBackendApiUrl: String? = null + + fun setCurrentBackend(serverLinks: ServerConfig.Links) { + currentBackendApiUrl = serverLinks.api.takeIf { it.isNotBlank() } + } + + suspend fun storeFromConfigUrl( + globalDataStore: GlobalDataStore, + serverLinks: ServerConfig.Links, + configUrl: String + ) { + setCurrentBackend(serverLinks) + globalDataStore.setBackendSupportEmail( + backendApiUrl = serverLinks.api, + supportEmail = fetchSupportEmail(configUrl) + ) + } + + suspend fun resolveEmail(context: Context, staticSupportEmail: String): String? { + val staticEmail = staticSupportEmail.trim() + return when { + staticEmail.isNotBlank() -> staticEmail + currentBackendApiUrl != null -> currentBackendApiUrl?.let { + GlobalDataStore(context.applicationContext).getBackendSupportEmail(it) + } + else -> null + } + } + + fun supportPageIntent(): Intent? = + SupportUrlResolver.resolveUrl("")?.let { + Intent(Intent.ACTION_VIEW, Uri.parse(it)) + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun fetchSupportEmail(configUrl: String): String? = withContext(Dispatchers.IO) { + try { + val connection = URL(configUrl).openConnection() as HttpURLConnection + connection.connectTimeout = CONNECT_TIMEOUT_MILLIS + connection.readTimeout = READ_TIMEOUT_MILLIS + try { + connection.inputStream.bufferedReader().use { reader -> + json.decodeFromString(SupportConfig.serializer(), reader.readText()).supportEmail + ?.trim() + ?.takeIf { it.isNotBlank() } + } + } finally { + connection.disconnect() + } + } catch (exception: Exception) { + appLogger.w("Failed to read backend support email from config", exception) + null + } + } + + @Serializable + private data class SupportConfig( + @SerialName("supportEmail") + val supportEmail: String? = null + ) +} 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 a3b56ce2870..6f6565e0966 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,14 @@ import com.wire.kalium.logger.KaliumLogLevel object CustomTabsHelper { - fun launchUrl(context: Context, url: String) = launchUri(context, Uri.parse(url)) + fun setBackendWebsiteUrl(url: String?) { + SupportUrlResolver.setBackendWebsiteUrl(url) + } + + fun launchUrl(context: Context, url: String) { + val resolvedUrl = SupportUrlResolver.resolveUrl(url) ?: return + launchUri(context, Uri.parse(resolvedUrl)) + } @JvmStatic fun launchUri(context: Context, uri: Uri) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad71fa9cc88..856b01a87ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,6 +252,7 @@ Close new team creation and login view Go back to new team creation and login view enter your email or username + Open camera to scan backend configuration QR code enter your password enter your SSO code or Wire email Welcome to Wire\'s New Android App, link to support page @@ -360,6 +361,12 @@ Are you sure you want to close the application and cancel the ongoing migration? Log in + Set up backend configuration + Your app has no backend configured. Ask your administrator for a configuration link or QR code. + Configuration link + https://example.com/deeplink.json + Configuration successful + You can now log in Forgot password? jane@example.com or jane.doe EMAIL OR USERNAME diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index 5907a31e89a..ad10eaeafb6 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -92,6 +92,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 c16829ab2df..2bcb0a15aeb 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 @@ -58,6 +58,7 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator 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.SupportUrlResolver @Stable fun wireDialogPropertiesBuilder( @@ -207,7 +208,11 @@ fun WireDialogContent( TextWithLinkSuffix( text = text, linkText = textSuffixLink?.linkText, - onLinkClick = { textSuffixLink?.linkUrl?.let { uriHandler.openUri(it) } }, + onLinkClick = { + textSuffixLink?.linkUrl + ?.let(SupportUrlResolver::resolveUrl) + ?.let(uriHandler::openUri) + }, modifier = Modifier.padding(bottom = MaterialTheme.wireDimensions.dialogTextsSpacing) ) } 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..4412359d971 --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/SupportUrlResolver.kt @@ -0,0 +1,47 @@ +/* + * 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 java.net.URI + +object SupportUrlResolver { + + private const val SUPPORT_PATH = "support" + + @Volatile + private var backendWebsiteUrl: String? = null + + fun setBackendWebsiteUrl(url: String?) { + backendWebsiteUrl = url?.takeIf { it.isNotBlank() } + } + + fun resolveUrl(url: String): String? { + val trimmedUrl = url.trim() + return if (trimmedUrl.isBlank()) { + backendWebsiteUrl?.trimEnd('/')?.let { "$it/$SUPPORT_PATH" } + } else { + trimmedUrl.takeIf { it.isHttpUrl() } + } + } + + private fun String.isHttpUrl(): Boolean = + runCatching { + val uri = URI(this) + uri.scheme in setOf("http", "https") && !uri.host.isNullOrBlank() + }.getOrDefault(false) +} 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/SupportUrlResolverTest.kt b/core/ui-common/src/test/kotlin/com/wire/android/util/SupportUrlResolverTest.kt new file mode 100644 index 00000000000..8e2879869b9 --- /dev/null +++ b/core/ui-common/src/test/kotlin/com/wire/android/util/SupportUrlResolverTest.kt @@ -0,0 +1,70 @@ +/* + * 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.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class SupportUrlResolverTest { + + @Test + fun `given empty url and backend website, when resolving url, then return backend support url`() { + SupportUrlResolver.setBackendWebsiteUrl("https://example.com") + + val result = SupportUrlResolver.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`() { + SupportUrlResolver.setBackendWebsiteUrl("https://example.com/") + + val result = SupportUrlResolver.resolveUrl("") + + assertEquals("https://example.com/support", result) + } + + @Test + fun `given valid url, when resolving url, then keep original url`() { + SupportUrlResolver.setBackendWebsiteUrl("https://example.com") + + val result = SupportUrlResolver.resolveUrl("https://wire.com/help") + + assertEquals("https://wire.com/help", result) + } + + @Test + fun `given invalid url, when resolving url, then return null`() { + SupportUrlResolver.setBackendWebsiteUrl("https://example.com") + + val result = SupportUrlResolver.resolveUrl("not a valid url") + + assertNull(result) + } + + @Test + fun `given empty url and no backend website, when resolving url, then return null`() { + SupportUrlResolver.setBackendWebsiteUrl(null) + + val result = SupportUrlResolver.resolveUrl("") + + assertNull(result) + } +} diff --git a/default.json b/default.json index a84f528fd88..1cd4a94b63f 100644 --- a/default.json +++ b/default.json @@ -118,6 +118,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", From cd0a452a77317addbef328626485a0078d95f42b Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Fri, 3 Jul 2026 09:19:50 +0200 Subject: [PATCH 2/3] 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 203b617728a..8553b1c12dc 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 @@ -152,7 +152,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 ad10eaeafb6..3511922fc4a 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -77,6 +77,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 1cd4a94b63f..adeff08f171 100644 --- a/default.json +++ b/default.json @@ -109,6 +109,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 74291f80ffdea303bb2cc8421add39fa84f2142f Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Fri, 3 Jul 2026 11:00:43 +0200 Subject: [PATCH 3/3] feat: update backend setup login copy --- .../ui/authentication/BackendConfigSetup.kt | 2 +- app/src/main/res/values-de/strings.xml | 10 ++++++++++ app/src/main/res/values/strings.xml | 15 ++++++++------- 3 files changed, 19 insertions(+), 8 deletions(-) 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 index 20e5a288b9c..9564bfb3078 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/BackendConfigSetup.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/BackendConfigSetup.kt @@ -128,7 +128,7 @@ fun MissingBackendConfigContent( ) VerticalSpace.x8() WirePrimaryButton( - text = stringResource(R.string.label_continue), + text = stringResource(R.string.backend_config_setup_button), fillMaxWidth = true, state = if (isConfigInputEmpty) WireButtonState.Disabled else WireButtonState.Default, onClick = { context.openBackendConfig(backendConfigTextState.text.toString()) }, diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 85fec057525..a0b8c8c6a9b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -199,6 +199,7 @@ Ansicht zur Team-Erstellung und Anmeldung schließen Zurück zur Ansicht zur Team-Erstellung und Anmeldung Geben Sie Ihre E-Mail-Adresse oder Ihren Benutzernamen 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 @@ -278,6 +279,13 @@ Sind Sie sicher, dass Sie die App schließen und die laufende Migration abbrechen möchten? 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 + Ihre App ist eingerichtet + Im nächsten Schritt können Sie sich anmelden Passwort vergessen? max@example.com oder max.müller E-MAIL ODER BENUTZERNAME @@ -1102,6 +1110,8 @@ Bei Gruppenunterhaltungen kann der Gruppen-Admin diese Einstellung überschreibe Accounts-URL: Website-URL: Backend-WSURL: + Ein Fehler ist aufgetreten + 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 856b01a87ff..a5917c0eb87 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,7 +252,7 @@ Close new team creation and login view Go back to new team creation and login view enter your email or username - Open camera to scan backend configuration QR 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 @@ -361,12 +361,13 @@ Are you sure you want to close the application and cancel the ongoing migration? Log in - Set up backend configuration - Your app has no backend configured. Ask your administrator for a configuration link or QR code. + Set up your app + To get started, enter the link or scan the QR code provided by your administration. Configuration link - https://example.com/deeplink.json - Configuration successful - You can now log in + Enter link or scan QR code + Set Up + Your app is set up + In the next step, you can log in Forgot password? jane@example.com or jane.doe EMAIL OR USERNAME @@ -1210,7 +1211,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