diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-10-15 07:25:33 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-10-15 07:25:33 +0200 |
| commit | 669c91f34e6fbc17c7916ab0fab0a5b17686a570 (patch) | |
| tree | 1248b168e5c33129a7ea1e31febe5ca12df965a6 | |
| parent | 66d1b4e66ae05913e0fa557ac123758a11f7329d (diff) | |
| parent | e06c8ede52f8fdc7ae974038757674a36c527019 (diff) | |
| download | mullvadvpn-669c91f34e6fbc17c7916ab0fab0a5b17686a570.tar.xz mullvadvpn-669c91f34e6fbc17c7916ab0fab0a5b17686a570.zip | |
Merge branch 'add-account-number-prompt-to-report-a-problem-view-droid-2139'
11 files changed, 145 insertions, 24 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" diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 9966130a23..b03c4d7d36 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -461,4 +461,5 @@ <string name="enable_all_methods">Enable all & retry</string> <string name="send_email">Send email</string> <string name="no_email_app_available">No email app available on the device</string> + <string name="include_account_token_checkbox_text">This is a question about account or payments (include account information)</string> </resources> diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index 322fae8a8b..efeecc4761 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -3426,6 +3426,9 @@ msgstr "" msgid "This field is required" msgstr "" +msgid "This is a question about account or payments (include account information)" +msgstr "" + msgid "This is already set as current" msgstr "" diff --git a/mullvad-jni/src/problem_report.rs b/mullvad-jni/src/problem_report.rs index 1851ab8cb7..a2bfe52619 100644 --- a/mullvad-jni/src/problem_report.rs +++ b/mullvad-jni/src/problem_report.rs @@ -43,6 +43,7 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_dataproxy_MullvadProblemRepor _: JObject<'_>, userEmail: JString<'_>, userMessage: JString<'_>, + accountToken: JString<'_>, outputPath: JString<'_>, cacheDirectory: JString<'_>, endpoint: JObject<'_>, @@ -50,6 +51,11 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_dataproxy_MullvadProblemRepor let env = JnixEnv::from(env); let user_email = String::from_java(&env, userEmail); let user_message = String::from_java(&env, userMessage); + let account_token = if accountToken.is_null() { + None + } else { + Some(String::from_java(&env, accountToken)) + }; let output_path_string = String::from_java(&env, outputPath); let output_path = Path::new(&output_path_string); let cache_directory_string = String::from_java(&env, cacheDirectory); @@ -60,6 +66,7 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_dataproxy_MullvadProblemRepor let send_result = mullvad_problem_report::send_problem_report( &user_email, &user_message, + account_token.as_deref(), output_path, cache_directory, api_endpoint, diff --git a/mullvad-problem-report/src/lib.rs b/mullvad-problem-report/src/lib.rs index 396ac4a98b..730b06ba0a 100644 --- a/mullvad-problem-report/src/lib.rs +++ b/mullvad-problem-report/src/lib.rs @@ -266,6 +266,7 @@ fn write_logcat_to_file(log_dir: &Path) -> Result<PathBuf, io::Error> { pub fn send_problem_report( user_email: &str, user_message: &str, + account_token: Option<&str>, report_path: &Path, cache_dir: &Path, endpoint: ApiEndpoint, @@ -287,6 +288,7 @@ pub fn send_problem_report( runtime.block_on(send_problem_report_inner( user_email, user_message, + account_token, &report_content, cache_dir, &endpoint, @@ -296,6 +298,7 @@ pub fn send_problem_report( async fn send_problem_report_inner( user_email: &str, user_message: &str, + account_token: Option<&str>, report_content: &str, cache_dir: &Path, endpoint: &ApiEndpoint, @@ -316,9 +319,16 @@ async fn send_problem_report_inner( api_runtime.mullvad_rest_handle(connection_mode.into_provider()), ); + let message: String = match account_token { + Some(account_token) => { + format!("{user_message}\nAccountToken: {account_token}") + } + None => user_message.to_string(), + }; + for _attempt in 0..MAX_SEND_ATTEMPTS { match api_client - .problem_report(user_email, user_message, report_content, &metadata) + .problem_report(user_email, &message, report_content, &metadata) .await { Ok(()) => { diff --git a/mullvad-problem-report/src/main.rs b/mullvad-problem-report/src/main.rs index ee7d9eeb0c..5d5ccca6f2 100644 --- a/mullvad-problem-report/src/main.rs +++ b/mullvad-problem-report/src/main.rs @@ -76,6 +76,7 @@ fn run() -> Result<(), Error> { send_problem_report( &email.unwrap_or_default(), &message.unwrap_or_default(), + None, &report, )?; } @@ -87,12 +88,14 @@ fn run() -> Result<(), Error> { fn send_problem_report( user_email: &str, user_message: &str, + account_token: Option<&str>, report_path: &Path, ) -> Result<(), Error> { let cache_dir = mullvad_paths::get_cache_dir().map_err(Error::ObtainCacheDirectory)?; mullvad_problem_report::send_problem_report( user_email, user_message, + account_token, report_path, &cache_dir, ApiEndpoint::from_env_vars(), |
