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 2a1f0d48609..684852b1ab4 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt @@ -99,18 +99,10 @@ 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(CommonR.string.url_support).takeIf { it.isNotBlank() }?.let { - return Intent(Intent.ACTION_VIEW, Uri.parse(it)) - } - } + suspend fun resolveSupportEmail(context: Context): String? = + BackendSupportConfig.resolveEmail(context, context.getString(R.string.send_bug_report_email)) + fun intent(context: Context, supportEmail: String?): Intent { val dir = LogFileWriter.logsDirectory(context) val logsUris = context.getUrisOfFilesInDirectory(dir) val intent = context.multipleFileSharingIntent(logsUris) @@ -129,6 +121,11 @@ object ReportBugDestination : IntentDirection { return Intent.createChooser(intent, context.getString(R.string.send_feedback_choose_email)) } + override fun intent(context: Context): Intent = intent( + context = context, + supportEmail = runBlocking { resolveSupportEmail(context) } + ) + override val route: String get() = "wire-intent:report-bug" } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/MissingSupportEmailDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/MissingSupportEmailDialog.kt new file mode 100644 index 00000000000..4366b3b91f2 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/MissingSupportEmailDialog.kt @@ -0,0 +1,69 @@ +/* + * 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.home.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.VisibilityState +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.visbility.VisibilityState +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +internal fun MissingSupportEmailDialog( + dialogState: VisibilityState, + onConfirm: () -> Unit +) { + VisibilityState(dialogState) { state -> + WireDialog( + title = stringResource(R.string.report_bug_screen_title), + text = stringResource(R.string.report_bug_missing_support_email_dialog_message), + buttonsHorizontalAlignment = true, + onDismiss = dialogState::dismiss, + dismissButtonProperties = WireDialogButtonProperties( + onClick = dialogState::dismiss, + text = stringResource(R.string.label_cancel), + type = WireDialogButtonType.Secondary + ), + optionButton1Properties = WireDialogButtonProperties( + onClick = remember(state) { + { + dialogState.dismiss() + onConfirm() + } + }, + text = stringResource(R.string.label_ok), + type = WireDialogButtonType.Primary + ) + ) + } +} + +@PreviewMultipleThemes +@Composable +private fun PreviewMissingSupportEmailDialog() { + WireTheme { + MissingSupportEmailDialog(VisibilityState(isVisible = true)) {} + } +} 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 8553b1c12dc..aed97fa5fe4 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 @@ -24,9 +24,11 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.wire.android.BuildConfig import com.wire.android.R @@ -36,6 +38,7 @@ import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.HomeNavGraph import com.wire.android.navigation.NavigationCommand +import com.wire.android.navigation.ReportBugDestination import com.wire.android.navigation.WireDestination import com.wire.android.navigation.handleNavigation import com.wire.android.ui.common.visbility.rememberVisibilityState @@ -54,6 +57,7 @@ fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel() ) { val turnAppLockOffDialogState = rememberVisibilityState() + val missingSupportEmailDialogState = rememberVisibilityState() val onAppLockSwitchClicked: (Boolean) -> Unit = remember { { isChecked -> if (isChecked) homeStateHolder.navigator.navigate(NavigationCommand(SetLockCodeScreenDestination, BackStackMode.NONE)) @@ -62,20 +66,39 @@ fun SettingsScreen( } val context = LocalContext.current + val staticSupportEmail = stringResource(R.string.send_bug_report_email) + LaunchedEffect(viewModel, context) { + viewModel.reportBugClickAction.collect { action -> + when (action) { + ReportBugClickAction.ConfirmSharingWithoutRecipient -> missingSupportEmailDialogState.show(Unit) + is ReportBugClickAction.Share -> context.startActivity( + ReportBugDestination.intent(context, action.supportEmail) + ) + } + } + } SettingsScreenContent( lazyListState = homeStateHolder.lazyListStateFor(HomeDestination.Settings), settingsState = viewModel.state, - onItemClicked = remember { - { - it.direction.handleNavigation( - context = context, - handleOtherDirection = { homeStateHolder.navigator.navigate(NavigationCommand(it)) } - ) + onItemClicked = remember(context, homeStateHolder.navigator, staticSupportEmail, viewModel) { + { item -> + if (item == SettingsItem.ReportBug) { + viewModel.onReportBugClicked(staticSupportEmail) + } else { + item.direction.handleNavigation( + context = context, + handleOtherDirection = { homeStateHolder.navigator.navigate(NavigationCommand(it)) } + ) + } } }, onAppLockSwitchChanged = onAppLockSwitchClicked ) TurnAppLockOffDialog(dialogState = turnAppLockOffDialogState, turnOff = viewModel::disableAppLock) + MissingSupportEmailDialog( + dialogState = missingSupportEmailDialogState, + onConfirm = { context.startActivity(ReportBugDestination.intent(context, supportEmail = null)) } + ) } @Composable diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModel.kt index 81375aad0ad..7ae9519fee6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModel.kt @@ -24,11 +24,14 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.datastore.GlobalDataStore +import com.wire.android.util.BackendSupportConfig import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppLockEditableUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.shareIn @@ -45,6 +48,9 @@ class SettingsViewModel @Inject constructor( var state by mutableStateOf(SettingsState()) private set + private val _reportBugClickAction = MutableSharedFlow() + internal val reportBugClickAction = _reportBugClickAction.asSharedFlow() + init { viewModelScope.launch { combine( @@ -69,6 +75,17 @@ class SettingsViewModel @Inject constructor( } } + fun onReportBugClicked(staticSupportEmail: String) { + viewModelScope.launch { + val supportEmail = BackendSupportConfig.resolveEmail(globalDataStore, staticSupportEmail) + _reportBugClickAction.emit( + supportEmail?.takeIf(String::isNotBlank) + ?.let(ReportBugClickAction::Share) + ?: ReportBugClickAction.ConfirmSharingWithoutRecipient + ) + } + } + private suspend fun fetchSelfUser() { viewModelScope.launch { val self = @@ -80,3 +97,8 @@ class SettingsViewModel @Inject constructor( } } } + +internal sealed interface ReportBugClickAction { + data class Share(val supportEmail: String) : ReportBugClickAction + data object ConfirmSharingWithoutRecipient : ReportBugClickAction +} diff --git a/app/src/main/kotlin/com/wire/android/util/BackendSupportConfig.kt b/app/src/main/kotlin/com/wire/android/util/BackendSupportConfig.kt index c4ef113437f..2fbf4cb187a 100644 --- a/app/src/main/kotlin/com/wire/android/util/BackendSupportConfig.kt +++ b/app/src/main/kotlin/com/wire/android/util/BackendSupportConfig.kt @@ -44,11 +44,15 @@ object BackendSupportConfig { } suspend fun resolveEmail(context: Context, staticSupportEmail: String): String? { + return resolveEmail(GlobalDataStore(context.applicationContext), staticSupportEmail) + } + + suspend fun resolveEmail(globalDataStore: GlobalDataStore, staticSupportEmail: String): String? { val staticEmail = staticSupportEmail.trim() return when { staticEmail.isNotBlank() -> staticEmail currentBackendApiUrl != null -> currentBackendApiUrl?.let { - GlobalDataStore(context.applicationContext).getBackendSupportEmail(it) + globalDataStore.getBackendSupportEmail(it) } else -> null } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a0b8c8c6a9b..34ec5de4b5c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1177,6 +1177,7 @@ Bei Gruppenunterhaltungen kann der Gruppen-Admin diese Einstellung überschreibe \"E-Mail-Anbieter auswählen:\" Fehlerbericht + Keine Support-E-Mail-Adresse ist konfiguriert. Tippen Sie auf „OK“, um den Bericht manuell ohne Empfänger zu teilen. Datei speichern Datei teilen Inkompatibles Backup diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae775cc2eca..1ff0b5af518 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1266,6 +1266,7 @@ In group conversations, the group admin can overwrite this setting. \"Select email provider:\" Bug Report wire-newandroid@wearezeta.zendesk.com + No support email address is configured. Tap OK to share the report manually without a recipient. Save File Share File diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsViewModelTest.kt new file mode 100644 index 00000000000..6062b52707c --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsViewModelTest.kt @@ -0,0 +1,99 @@ +/* + * 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.home.settings + +import app.cash.turbine.test +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.TestDispatcherProvider +import com.wire.android.datastore.GlobalDataStore +import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppLockEditableUseCase +import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class SettingsViewModelTest { + + @Test + fun givenSupportEmail_whenReportBugClicked_thenShareWithRecipient() = runTest { + val (_, viewModel) = Arrangement().arrange() + + viewModel.reportBugClickAction.test { + viewModel.onReportBugClicked("support@example.com") + + assertEquals(ReportBugClickAction.Share("support@example.com"), awaitItem()) + } + } + + @Test + fun givenMissingSupportEmail_whenReportBugClicked_thenConfirmSharingWithoutRecipient() = runTest { + val (_, viewModel) = Arrangement().arrange() + + viewModel.reportBugClickAction.test { + viewModel.onReportBugClicked("") + + assertEquals(ReportBugClickAction.ConfirmSharingWithoutRecipient, awaitItem()) + } + } + + @Test + fun givenBlankSupportEmail_whenReportBugClicked_thenConfirmSharingWithoutRecipient() = runTest { + val (_, viewModel) = Arrangement().arrange() + + viewModel.reportBugClickAction.test { + viewModel.onReportBugClicked(" ") + + assertEquals(ReportBugClickAction.ConfirmSharingWithoutRecipient, awaitItem()) + } + } + + private class Arrangement { + @MockK + lateinit var globalDataStore: GlobalDataStore + + @MockK + lateinit var observeIsAppLockEditable: ObserveIsAppLockEditableUseCase + + @MockK + lateinit var getSelf: ObserveSelfUserUseCase + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { globalDataStore.isAppLockPasscodeSetFlow() } returns flowOf(false) + coEvery { observeIsAppLockEditable() } returns flowOf(false) + coEvery { getSelf() } returns emptyFlow() + coEvery { globalDataStore.getBackendSupportEmail(any()) } returns null + } + + fun arrange() = this to SettingsViewModel( + globalDataStore = globalDataStore, + observeIsAppLockEditable = observeIsAppLockEditable, + getSelf = getSelf, + dispatchers = TestDispatcherProvider() + ) + } +}