diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-10-06 16:32:14 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-10-15 07:23:21 +0200 |
| commit | e06c8ede52f8fdc7ae974038757674a36c527019 (patch) | |
| tree | 1248b168e5c33129a7ea1e31febe5ca12df965a6 /android/app/src | |
| parent | 66d1b4e66ae05913e0fa557ac123758a11f7329d (diff) | |
| download | mullvadvpn-e06c8ede52f8fdc7ae974038757674a36c527019.tar.xz mullvadvpn-e06c8ede52f8fdc7ae974038757674a36c527019.zip | |
Enable the user to include their account token in problem reports
Diffstat (limited to 'android/app/src')
6 files changed, 120 insertions, 23 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ReportProblemUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ReportProblemUiStatePreviewParameterProvider.kt index 52822a316a..f878ae9d37 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ReportProblemUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ReportProblemUiStatePreviewParameterProvider.kt @@ -10,7 +10,7 @@ class ReportProblemUiStatePreviewParameterProvider : override val values: Sequence<ReportProblemUiState> get() = sequenceOf( - ReportProblemUiState(), + ReportProblemUiState(showIncludeAccountId = true), ReportProblemUiState(sendingState = SendingReportUiState.Sending), ReportProblemUiState(sendingState = SendingReportUiState.Success("email@mail.com")), ReportProblemUiState( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt index 699a71a164..ca06b04e74 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt @@ -1,9 +1,11 @@ package net.mullvad.mullvadvpn.compose.screen +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth @@ -11,13 +13,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -60,9 +63,9 @@ import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView -import net.mullvad.mullvadvpn.compose.util.toDp import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.ui.designsystem.Checkbox import net.mullvad.mullvadvpn.viewmodel.ReportProblemSideEffect import net.mullvad.mullvadvpn.viewmodel.ReportProblemUiState import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel @@ -83,6 +86,7 @@ private fun PreviewReportProblemScreen( onNavigateToViewLogs = {}, onEmailChanged = {}, onDescriptionChanged = {}, + onIncludeAccountIdCheckChange = {}, onBackClick = {}, ) } @@ -121,6 +125,7 @@ fun ReportProblem( }, onEmailChanged = vm::updateEmail, onDescriptionChanged = vm::updateDescription, + onIncludeAccountIdCheckChange = vm::onIncludeAccountIdCheckChange, onBackClick = dropUnlessResumed { navigator.navigateUp() }, ) } @@ -133,6 +138,7 @@ private fun ReportProblemScreen( onNavigateToViewLogs: () -> Unit, onEmailChanged: (String) -> Unit, onDescriptionChanged: (String) -> Unit, + onIncludeAccountIdCheckChange: (Boolean) -> Unit, onBackClick: () -> Unit, ) { @@ -169,10 +175,9 @@ private fun ReportProblemScreen( .height(IntrinsicSize.Max), verticalArrangement = Arrangement.spacedBy(Dimens.mediumPadding), ) { - Text( - text = stringResource(id = R.string.problem_report_description), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelLarge, + Description( + state = state, + onIncludeAccountIdCheckChange = onIncludeAccountIdCheckChange, ) TextField( @@ -215,7 +220,58 @@ private fun ReportProblemScreen( } @Composable -fun ProblemMessageTextField( +private fun Description( + state: ReportProblemUiState, + onIncludeAccountIdCheckChange: (Boolean) -> Unit, +) { + Column { + Text( + text = stringResource(id = R.string.problem_report_description), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, + ) + + if (state.showIncludeAccountId) { + IncludeAccountInformationCheckBox( + includeAccountInformation = state.includeAccountId, + onIncludeAccountInformationCheckChange = onIncludeAccountIdCheckChange, + ) + } + } +} + +@Composable +private fun IncludeAccountInformationCheckBox( + includeAccountInformation: Boolean, + onIncludeAccountInformationCheckChange: (Boolean) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.clickable { + onIncludeAccountInformationCheckChange(!includeAccountInformation) + } + .padding(vertical = Dimens.smallPadding) + .fillMaxWidth(), + ) { + // To align the checkbox with the text + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { + Checkbox( + modifier = Modifier.padding(end = Dimens.smallPadding), + checked = includeAccountInformation, + onCheckedChange = onIncludeAccountInformationCheckChange, + ) + Text( + text = stringResource(R.string.include_account_token_checkbox_text), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +@Composable +private fun ProblemMessageTextField( modifier: Modifier = Modifier, value: String, onDescriptionChanged: (String) -> Unit, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt index 3b4a460fea..d02555db00 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointFromIntentHolder import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointOverride +import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.service.BuildConfig const val PROBLEM_REPORT_LOGS_FILE = "problem_report.txt" @@ -28,6 +29,7 @@ class MullvadProblemReport( context: Context, private val apiEndpointOverride: ApiEndpointOverride?, private val apiEndpointFromIntentHolder: ApiEndpointFromIntentHolder, + private val accountRepository: AccountRepository, val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { @@ -47,7 +49,10 @@ class MullvadProblemReport( collectReport(logDirectory.absolutePath, logsPath.absolutePath) } - suspend fun sendReport(userReport: UserReport): SendProblemReportResult { + suspend fun sendReport( + userReport: UserReport, + includeAccountId: Boolean, + ): SendProblemReportResult { // If report is not collected then, collect it, if it fails then return error if (!logsExists() && !collectLogs()) { return SendProblemReportResult.Error.CollectLog @@ -64,11 +69,17 @@ class MullvadProblemReport( } sendProblemReport( - userReport.email ?: "", - userReport.description, - logsPath.absolutePath, - cacheDirectory.absolutePath, - apiOverride, + userEmail = userReport.email ?: "", + userMessage = userReport.description, + accountId = + if (includeAccountId) { + accountRepository.accountData.value?.id?.value?.toString() + } else { + null + }, + reportPath = logsPath.absolutePath, + cacheDirectory = cacheDirectory.absolutePath, + apiEndpointOverride = apiOverride, ) } @@ -104,6 +115,7 @@ class MullvadProblemReport( private external fun sendProblemReport( userEmail: String, userMessage: String, + accountId: String?, reportPath: String, cacheDirectory: String, apiEndpointOverride: ApiEndpointOverride?, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 6839b76753..7b2ae9986e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -137,6 +137,7 @@ val uiModule = module { context = androidContext(), apiEndpointOverride = getOrNull(), apiEndpointFromIntentHolder = get(), + accountRepository = get(), ) } single { RelayOverridesRepository(get()) } @@ -268,7 +269,7 @@ val uiModule = module { viewModel { VoucherDialogViewModel(get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } - viewModel { ReportProblemViewModel(get(), get()) } + viewModel { ReportProblemViewModel(get(), get(), get()) } viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } viewModel { FilterViewModel(get(), get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt index 0f27ea1516..c07f0d5df1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt @@ -17,12 +17,15 @@ import net.mullvad.mullvadvpn.constant.VIEW_MODEL_STOP_TIMEOUT import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult import net.mullvad.mullvadvpn.dataproxy.UserReport +import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.repository.ProblemReportRepository data class ReportProblemUiState( val sendingState: SendingReportUiState? = null, val email: String = "", val description: String = "", + val showIncludeAccountId: Boolean = false, + val includeAccountId: Boolean = false, ) sealed interface SendingReportUiState { @@ -40,16 +43,25 @@ sealed interface ReportProblemSideEffect { class ReportProblemViewModel( private val mullvadProblemReporter: MullvadProblemReport, private val problemReportRepository: ProblemReportRepository, + accountRepository: AccountRepository, ) : ViewModel() { private val sendingState: MutableStateFlow<SendingReportUiState?> = MutableStateFlow(null) + private val includeAccountIdState: MutableStateFlow<Boolean> = MutableStateFlow(false) val uiState = - combine(sendingState, problemReportRepository.problemReport) { pendingState, userReport -> + combine( + sendingState, + includeAccountIdState, + problemReportRepository.problemReport, + accountRepository.accountData, + ) { sendingState, includeAccountToken, userReport, accountData -> ReportProblemUiState( - sendingState = pendingState, + sendingState = sendingState, email = userReport.email ?: "", description = userReport.description, + showIncludeAccountId = accountData != null, + includeAccountId = includeAccountToken, ) } .stateIn( @@ -72,7 +84,10 @@ class ReportProblemViewModel( // Ensure we show loading for at least MINIMUM_LOADING_TIME_MILLIS val deferredResult = async { - mullvadProblemReporter.sendReport(UserReport(nullableEmail, description)) + mullvadProblemReporter.sendReport( + UserReport(nullableEmail, description), + includeAccountIdState.value, + ) } delay(MINIMUM_LOADING_TIME_MILLIS) @@ -99,6 +114,10 @@ class ReportProblemViewModel( problemReportRepository.setDescription(description) } + fun onIncludeAccountIdCheckChange(checked: Boolean) { + includeAccountIdState.tryEmit(checked) + } + private fun shouldShowConfirmNoEmail(userEmail: String?): Boolean = userEmail.isNullOrEmpty() && uiState.value.sendingState !is SendingReportUiState diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt index fa5ec10b5c..e2fccb48ed 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModelTest.kt @@ -14,6 +14,7 @@ import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult import net.mullvad.mullvadvpn.dataproxy.UserReport import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.repository.ProblemReportRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -27,6 +28,8 @@ class ReportProblemViewModelTest { @MockK(relaxed = true) private lateinit var mockProblemReportRepository: ProblemReportRepository + @MockK(relaxed = true) private lateinit var mockAccountRepository: AccountRepository + private val problemReportFlow = MutableStateFlow(UserReport("", "")) private lateinit var viewModel: ReportProblemViewModel @@ -36,7 +39,13 @@ class ReportProblemViewModelTest { MockKAnnotations.init(this) coEvery { mockMullvadProblemReport.collectLogs() } returns true coEvery { mockProblemReportRepository.problemReport } returns problemReportFlow - viewModel = ReportProblemViewModel(mockMullvadProblemReport, mockProblemReportRepository) + coEvery { mockAccountRepository.accountData } returns MutableStateFlow(null) + viewModel = + ReportProblemViewModel( + mockMullvadProblemReport, + mockProblemReportRepository, + mockAccountRepository, + ) } @AfterEach @@ -48,7 +57,7 @@ class ReportProblemViewModelTest { fun `when sendReport returns CollectLog error then uiState should emit sendingState with CollectLog error`() = runTest { // Arrange - coEvery { mockMullvadProblemReport.sendReport(any()) } returns + coEvery { mockMullvadProblemReport.sendReport(any(), any()) } returns SendProblemReportResult.Error.CollectLog val email = "my@email.com" @@ -68,7 +77,7 @@ class ReportProblemViewModelTest { fun `when sendReport returns SendReport error then uiState should emit sendingState with SendReport error`() = runTest { // Arrange - coEvery { mockMullvadProblemReport.sendReport(any()) } returns + coEvery { mockMullvadProblemReport.sendReport(any(), any()) } returns SendProblemReportResult.Error.SendReport val email = "my@email.com" @@ -88,7 +97,7 @@ class ReportProblemViewModelTest { fun `when sendReport with no email returns Success then uiState should emit sendingState with Success`() = runTest { // Arrange - coEvery { mockMullvadProblemReport.sendReport(any()) } returns + coEvery { mockMullvadProblemReport.sendReport(any(), any()) } returns SendProblemReportResult.Success val email = "" val description = "My description" @@ -121,7 +130,7 @@ class ReportProblemViewModelTest { runTest { // Arrange coEvery { mockMullvadProblemReport.collectLogs() } returns true - coEvery { mockMullvadProblemReport.sendReport(any()) } returns + coEvery { mockMullvadProblemReport.sendReport(any(), any()) } returns SendProblemReportResult.Success val email = "my@email.com" val description = "My description" |
