summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-10-14 09:43:44 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-10-14 09:43:44 +0200
commitfe1844b2c887cace39aeac0f57c41d7e8b2d7d09 (patch)
treedcba71b58d03985b25dfbb0a5f575e20d46b9e87
parent72864c0654510a5a9b2fc5493233880b9fba93d7 (diff)
parent813bb62f92680a149c9c1482964ec31fae0b47c3 (diff)
downloadmullvadvpn-fe1844b2c887cace39aeac0f57c41d7e8b2d7d09.tar.xz
mullvadvpn-fe1844b2c887cace39aeac0f57c41d7e8b2d7d09.zip
Merge branch 'improve-login-error-messages-droid-2104'
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningInfoDialog.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningDialog.kt)0
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/ApiUnreachableInfoDialog.kt191
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt27
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/LoginUiStatePreviewParameterProvider.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt177
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiUnreachableUiState.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/SendEmail.kt52
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/provider/MullvadFileProvider.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepository.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/InternetAvailableUseCase.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SupportEmailUseCase.kt41
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiUnreachableViewModel.kt112
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt49
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt90
-rw-r--r--android/lib/daemon-grpc/build.gradle.kts1
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt21
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateAccountError.kt6
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginAccountError.kt8
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml16
-rw-r--r--android/lib/resource/src/main/res/values/strings_non_translatable.xml3
-rw-r--r--desktop/packages/mullvad-vpn/locales/messages.pot48
-rw-r--r--mullvad-daemon/src/management_interface.rs9
26 files changed, 863 insertions, 92 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningInfoDialog.kt
index a70bb62d09..a70bb62d09 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/Android16UpgradeWarningInfoDialog.kt
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/ApiUnreachableInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/ApiUnreachableInfoDialog.kt
new file mode 100644
index 0000000000..97655d1ab2
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/ApiUnreachableInfoDialog.kt
@@ -0,0 +1,191 @@
+package net.mullvad.mullvadvpn.compose.dialog.info
+
+import android.content.ActivityNotFoundException
+import android.os.Parcelable
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import co.touchlab.kermit.Logger
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.annotation.RootGraph
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import kotlinx.parcelize.Parcelize
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.state.ApiUnreachableUiState
+import net.mullvad.mullvadvpn.compose.textfield.ErrorSupportingText
+import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
+import net.mullvad.mullvadvpn.compose.util.EmailData
+import net.mullvad.mullvadvpn.compose.util.SendEmail
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.provider.createShareLogFile
+import net.mullvad.mullvadvpn.viewmodel.ApiUnreachableSideEffect
+import net.mullvad.mullvadvpn.viewmodel.ApiUnreachableViewModel
+import org.koin.androidx.compose.koinViewModel
+
+@Preview
+@Composable
+private fun PreviewApiUnreachableInfoDialog() {
+ AppTheme {
+ ApiUnreachableInfoDialog(
+ state =
+ ApiUnreachableUiState(
+ showEnableAllAccessMethodsButton = true,
+ noEmailAppAvailable = true,
+ loginAction = LoginAction.LOGIN,
+ ),
+ onEnableAllApiMethods = {},
+ onSendEmail = {},
+ onDismiss = {},
+ )
+ }
+}
+
+@Parcelize
+enum class LoginAction : Parcelable {
+ LOGIN,
+ CREATE_ACCOUNT,
+}
+
+@Parcelize data class ApiUnreachableInfoDialogNavArgs(val action: LoginAction) : Parcelable
+
+sealed interface ApiUnreachableInfoDialogResult : Parcelable {
+ @Parcelize
+ data class Success(val arg: ApiUnreachableInfoDialogNavArgs) : ApiUnreachableInfoDialogResult
+
+ @Parcelize data object Error : ApiUnreachableInfoDialogResult
+}
+
+@Destination<RootGraph>(
+ style = DestinationStyle.Dialog::class,
+ navArgs = ApiUnreachableInfoDialogNavArgs::class,
+)
+@Composable
+fun ApiUnreachableInfo(navigator: ResultBackNavigator<ApiUnreachableInfoDialogResult>) {
+ val viewModel = koinViewModel<ApiUnreachableViewModel>()
+
+ val launcher = rememberLauncherForActivityResult(SendEmail()) {}
+ val context = LocalContext.current
+ CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { sideEffect ->
+ when (sideEffect) {
+ is ApiUnreachableSideEffect.SendEmail -> {
+ val emailData =
+ EmailData(
+ to = listOf(sideEffect.address),
+ subject = sideEffect.subject,
+ attachment = context.createShareLogFile(sideEffect.logs),
+ )
+ try {
+ launcher.launch(emailData)
+ } catch (e: ActivityNotFoundException) {
+ Logger.e("No email app found", e)
+ viewModel.noEmailAppAvailable()
+ }
+ }
+ is ApiUnreachableSideEffect.EnableAllApiAccessMethods ->
+ navigator.navigateBack(result = sideEffect.toResult())
+ }
+ }
+
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+ ApiUnreachableInfoDialog(
+ state = state,
+ onEnableAllApiMethods = viewModel::enableAllApiAccess,
+ onSendEmail = viewModel::sendProblemReportEmail,
+ onDismiss = navigator::navigateBack,
+ )
+}
+
+@Composable
+fun ApiUnreachableInfoDialog(
+ state: ApiUnreachableUiState,
+ onEnableAllApiMethods: () -> Unit,
+ onSendEmail: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ InfoDialog(
+ title = stringResource(id = R.string.unable_to_reach_api_dialog_title),
+ message =
+ buildAnnotatedString {
+ append(
+ stringResource(
+ id = R.string.unable_to_reach_api_dialog_message_first,
+ when (state.loginAction) {
+ LoginAction.LOGIN ->
+ stringResource(
+ id = R.string.unable_to_reach_api_dialog_action_login
+ )
+ LoginAction.CREATE_ACCOUNT ->
+ stringResource(
+ id = R.string.unable_to_reach_api_dialog_action_create
+ )
+ },
+ )
+ )
+ val firstItem =
+ stringResource(id = R.string.unable_to_reach_api_dialog_message_list_first)
+ val secondItem =
+ stringResource(id = R.string.unable_to_reach_api_dialog_message_list_second)
+ val thirdItem =
+ stringResource(id = R.string.unable_to_reach_api_dialog_message_list_third)
+ withBulletList {
+ withBulletListItem { append(firstItem) }
+ withBulletListItem { append(secondItem) }
+ if (state.showEnableAllAccessMethodsButton) {
+ withBulletListItem { append(thirdItem) }
+ }
+ }
+ },
+ additionalInfo = stringResource(id = R.string.unable_to_reach_api_dialog_message_second),
+ showIcon = false,
+ confirmButton = {
+ Column {
+ if (state.showEnableAllAccessMethodsButton) {
+ PrimaryButton(
+ modifier = Modifier.wrapContentHeight().fillMaxWidth(),
+ text = stringResource(R.string.enable_all_methods),
+ onClick = onEnableAllApiMethods,
+ )
+ }
+ PrimaryButton(
+ modifier = Modifier.wrapContentHeight().fillMaxWidth(),
+ text = stringResource(R.string.send_email),
+ onClick = onSendEmail,
+ )
+ if (state.noEmailAppAvailable) {
+ ErrorSupportingText(
+ stringResource(id = R.string.no_email_app_available),
+ modifier = Modifier.padding(bottom = Dimens.smallPadding),
+ )
+ }
+ PrimaryButton(
+ modifier = Modifier.wrapContentHeight().fillMaxWidth(),
+ text = stringResource(R.string.got_it),
+ onClick = onDismiss,
+ )
+ }
+ },
+ onDismiss = onDismiss,
+ )
+}
+
+private fun ApiUnreachableSideEffect.EnableAllApiAccessMethods.toResult() =
+ when (this) {
+ ApiUnreachableSideEffect.EnableAllApiAccessMethods.Error ->
+ ApiUnreachableInfoDialogResult.Error
+ is ApiUnreachableSideEffect.EnableAllApiAccessMethods.Success ->
+ ApiUnreachableInfoDialogResult.Success(navArgs)
+ }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt
index bb5c587002..bbc47b1332 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt
@@ -59,6 +59,33 @@ fun InfoDialog(
},
dismissButton: @Composable (() -> Unit)? = null,
) {
+ InfoDialog(
+ title = title,
+ message = AnnotatedString(message),
+ additionalInfo = additionalInfo,
+ showIcon = showIcon,
+ onDismiss = onDismiss,
+ confirmButton = confirmButton,
+ dismissButton = dismissButton,
+ )
+}
+
+@Composable
+fun InfoDialog(
+ title: String? = null,
+ message: AnnotatedString,
+ additionalInfo: CharSequence? = null,
+ showIcon: Boolean = true,
+ onDismiss: () -> Unit,
+ confirmButton: @Composable () -> Unit = {
+ PrimaryButton(
+ modifier = Modifier.wrapContentHeight().fillMaxWidth(),
+ text = stringResource(R.string.got_it),
+ onClick = onDismiss,
+ )
+ },
+ dismissButton: @Composable (() -> Unit)? = null,
+) {
AlertDialog(
onDismissRequest = { onDismiss() },
icon =
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/LoginUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/LoginUiStatePreviewParameterProvider.kt
index 2e66238c82..a0e8ebbee4 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/LoginUiStatePreviewParameterProvider.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/LoginUiStatePreviewParameterProvider.kt
@@ -1,9 +1,9 @@
package net.mullvad.mullvadvpn.compose.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import net.mullvad.mullvadvpn.compose.state.LoginError
import net.mullvad.mullvadvpn.compose.state.LoginState
import net.mullvad.mullvadvpn.compose.state.LoginUiState
+import net.mullvad.mullvadvpn.compose.state.LoginUiStateError
class LoginUiStatePreviewParameterProvider : PreviewParameterProvider<LoginUiState> {
override val values: Sequence<LoginUiState>
@@ -12,7 +12,9 @@ class LoginUiStatePreviewParameterProvider : PreviewParameterProvider<LoginUiSta
LoginUiState(),
LoginUiState(loginState = LoginState.Loading.LoggingIn),
LoginUiState(loginState = LoginState.Loading.CreatingAccount),
- LoginUiState(loginState = LoginState.Idle(LoginError.InvalidCredentials)),
+ LoginUiState(
+ loginState = LoginState.Idle(LoginUiStateError.LoginError.InvalidCredentials)
+ ),
LoginUiState(loginState = LoginState.Success),
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt
index a026856b29..79921a0662 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt
@@ -33,6 +33,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -47,8 +48,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -57,6 +60,7 @@ import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.NavGraphs
+import com.ramcosta.composedestinations.generated.destinations.ApiUnreachableInfoDestination
import com.ramcosta.composedestinations.generated.destinations.ConnectDestination
import com.ramcosta.composedestinations.generated.destinations.CreateAccountConfirmationDestination
import com.ramcosta.composedestinations.generated.destinations.DeviceListDestination
@@ -65,25 +69,31 @@ import com.ramcosta.composedestinations.generated.destinations.SettingsDestinati
import com.ramcosta.composedestinations.generated.destinations.WelcomeDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.ResultRecipient
+import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.button.VariantButton
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
+import net.mullvad.mullvadvpn.compose.dialog.info.ApiUnreachableInfoDialogNavArgs
+import net.mullvad.mullvadvpn.compose.dialog.info.ApiUnreachableInfoDialogResult
import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed
+import net.mullvad.mullvadvpn.compose.dialog.info.LoginAction
+import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed
import net.mullvad.mullvadvpn.compose.preview.LoginUiStatePreviewParameterProvider
-import net.mullvad.mullvadvpn.compose.state.LoginError
import net.mullvad.mullvadvpn.compose.state.LoginState
import net.mullvad.mullvadvpn.compose.state.LoginState.Idle
import net.mullvad.mullvadvpn.compose.state.LoginState.Loading
import net.mullvad.mullvadvpn.compose.state.LoginState.Success
import net.mullvad.mullvadvpn.compose.state.LoginUiState
+import net.mullvad.mullvadvpn.compose.state.LoginUiStateError
import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors
import net.mullvad.mullvadvpn.compose.transitions.LoginTransition
import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
import net.mullvad.mullvadvpn.compose.util.OnNavResultValue
import net.mullvad.mullvadvpn.compose.util.accountNumberKeyboardType
import net.mullvad.mullvadvpn.compose.util.accountNumberVisualTransformation
+import net.mullvad.mullvadvpn.compose.util.clickableAnnotatedString
import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -107,6 +117,7 @@ private fun PreviewLoginScreen(
onDeleteHistoryClick = {},
onAccountNumberChange = {},
onSettingsClick = {},
+ onShowApiUnreachableDialog = {},
)
}
}
@@ -122,6 +133,8 @@ fun Login(
vm: LoginViewModel = koinViewModel(),
createAccountConfirmationDialogResult:
ResultRecipient<CreateAccountConfirmationDestination, Confirmed>,
+ apiUnreachableInfoDialogResult:
+ ResultRecipient<ApiUnreachableInfoDestination, ApiUnreachableInfoDialogResult>,
) {
val state by vm.uiState.collectAsStateWithLifecycle()
@@ -133,10 +146,29 @@ fun Login(
}
}
- createAccountConfirmationDialogResult.OnNavResultValue { vm.onCreateAccountConfirmed() }
-
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
+ val scope = rememberCoroutineScope()
+
+ createAccountConfirmationDialogResult.OnNavResultValue { vm.onCreateAccountConfirmed() }
+
+ apiUnreachableInfoDialogResult.OnNavResultValue {
+ when (it) {
+ ApiUnreachableInfoDialogResult.Error ->
+ scope.launch {
+ snackbarHostState.showSnackbarImmediately(
+ message = context.getString(R.string.error_occurred)
+ )
+ }
+ is ApiUnreachableInfoDialogResult.Success -> {
+ when (it.arg.action) {
+ LoginAction.LOGIN -> vm.login(state.accountNumberInput)
+ LoginAction.CREATE_ACCOUNT -> vm.onCreateAccountClick()
+ }
+ }
+ }
+ }
+
CollectSideEffectWithLifecycle(vm.uiSideEffect) {
when (it) {
LoginUiSideEffect.NavigateToWelcome ->
@@ -174,6 +206,10 @@ fun Login(
onDeleteHistoryClick = vm::clearAccountHistory,
onAccountNumberChange = vm::onAccountNumberChange,
onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) },
+ onShowApiUnreachableDialog =
+ dropUnlessResumed { error: LoginUiStateError ->
+ navigator.navigate(ApiUnreachableInfoDestination(error.toApiUnreachableNavArg()))
+ },
)
}
@@ -186,6 +222,7 @@ private fun LoginScreen(
onDeleteHistoryClick: () -> Unit,
onAccountNumberChange: (String) -> Unit,
onSettingsClick: () -> Unit,
+ onShowApiUnreachableDialog: (LoginUiStateError) -> Unit,
) {
ScaffoldWithTopBar(
snackbarHostState = snackbarHostState,
@@ -210,7 +247,13 @@ private fun LoginScreen(
Modifier.align(Alignment.CenterHorizontally)
.padding(bottom = Dimens.largePadding),
)
- LoginContent(state, onAccountNumberChange, onLoginClick, onDeleteHistoryClick)
+ LoginContent(
+ state,
+ onAccountNumberChange,
+ onLoginClick,
+ onDeleteHistoryClick,
+ onShowApiUnreachableDialog,
+ )
Spacer(modifier = Modifier.weight(BOTTOM_SPACER_WEIGHT))
CreateAccountPanel(onCreateAccountClick, isEnabled = state.loginState is Idle)
}
@@ -223,6 +266,7 @@ private fun LoginContent(
onAccountNumberChange: (String) -> Unit,
onLoginClick: (String) -> Unit,
onDeleteHistoryClick: () -> Unit,
+ onShowApiUnreachableDialog: (LoginUiStateError) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Dimens.sideMargin)) {
Text(
@@ -235,7 +279,13 @@ private fun LoginContent(
.padding(bottom = Dimens.smallPadding),
)
- LoginInput(state, onLoginClick, onAccountNumberChange, onDeleteHistoryClick)
+ LoginInput(
+ state,
+ onLoginClick,
+ onAccountNumberChange,
+ onDeleteHistoryClick,
+ onShowApiUnreachableDialog,
+ )
Spacer(modifier = Modifier.size(Dimens.largePadding))
VariantButton(
@@ -254,17 +304,12 @@ private fun ColumnScope.LoginInput(
onLoginClick: (String) -> Unit,
onAccountNumberChange: (String) -> Unit,
onDeleteHistoryClick: () -> Unit,
+ onShowApiUnreachableDialog: (LoginUiStateError) -> Unit,
) {
- Text(
- modifier = Modifier.padding(bottom = Dimens.smallPadding),
- text = state.loginState.supportingText() ?: "",
- style = MaterialTheme.typography.labelLarge,
- color =
- if (state.loginState.isError()) {
- MaterialTheme.colorScheme.error
- } else {
- MaterialTheme.colorScheme.onPrimary
- },
+ SupportingText(
+ Modifier.padding(bottom = Dimens.smallPadding),
+ onShowApiUnreachableDialog = onShowApiUnreachableDialog,
+ state = state,
)
TextField(
@@ -332,7 +377,7 @@ private fun LoginIcon(loginState: LoginState, modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier) {
when (loginState) {
is Idle ->
- if (loginState.loginError != null) {
+ if (loginState.loginUiStateError != null) {
Image(
painter = painterResource(id = R.drawable.icon_fail),
contentDescription = stringResource(id = R.string.login_fail_title),
@@ -356,8 +401,10 @@ private fun LoginState.title(): String =
id =
when (this) {
is Idle ->
- when (this.loginError) {
- is LoginError -> R.string.login_fail_title
+ when (this.loginUiStateError) {
+ is LoginUiStateError.LoginError -> R.string.login_fail_title
+ is LoginUiStateError.CreateAccountError ->
+ R.string.create_account_fail_title
null -> R.string.login_title
}
is Loading -> R.string.logging_in_title
@@ -366,26 +413,76 @@ private fun LoginState.title(): String =
)
@Composable
-private fun LoginState.supportingText(): String? {
- val res =
- when (this) {
- is Idle -> {
- when (loginError) {
- LoginError.InvalidCredentials -> R.string.login_fail_description
- LoginError.UnableToCreateAccount -> R.string.failed_to_create_account
- LoginError.NoInternetConnection -> R.string.no_internet_connection
- is LoginError.Unknown -> R.string.error_occurred
- null -> return null
- }
- }
- is Loading.CreatingAccount -> R.string.creating_new_account
- is Loading.LoggingIn -> R.string.logging_in_description
- Success -> R.string.logged_in_description
- }
- return stringResource(id = res)
+private fun SupportingText(
+ modifier: Modifier = Modifier,
+ state: LoginUiState,
+ onShowApiUnreachableDialog: (LoginUiStateError) -> Unit,
+) {
+ Text(
+ modifier = modifier,
+ text = state.loginState.supportingText(onShowApiUnreachableDialog) ?: AnnotatedString(""),
+ style = MaterialTheme.typography.labelLarge,
+ color =
+ if (state.loginState.isError()) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onPrimary
+ },
+ )
}
@Composable
+@Suppress("CyclomaticComplexMethod")
+private fun LoginState.supportingText(
+ onShowApiUnreachableDialog: (LoginUiStateError) -> Unit
+): AnnotatedString? =
+ when (this) {
+ is Idle if
+ loginUiStateError is LoginUiStateError.LoginError.ApiUnreachable ||
+ loginUiStateError is LoginUiStateError.CreateAccountError.ApiUnreachable
+ -> apiUnreachableText(loginUiStateError, onShowApiUnreachableDialog)
+ is Idle -> {
+ when (loginUiStateError) {
+ LoginUiStateError.LoginError.InvalidCredentials -> R.string.login_fail_description
+ is LoginUiStateError.LoginError.InvalidInput -> R.string.login_error_invalid_input
+ LoginUiStateError.LoginError.NoInternetConnection,
+ LoginUiStateError.CreateAccountError.NoInternetConnection ->
+ R.string.no_internet_connection
+ LoginUiStateError.LoginError.ApiUnreachable,
+ LoginUiStateError.CreateAccountError.ApiUnreachable -> R.string.api_unreachable
+ LoginUiStateError.LoginError.TooManyAttempts,
+ LoginUiStateError.CreateAccountError.TooManyAttempts ->
+ R.string.login_error_too_many_attempts
+ is LoginUiStateError.LoginError.Unknown -> R.string.error_occurred
+ LoginUiStateError.CreateAccountError.Unknown -> R.string.failed_to_create_account
+ null -> null
+ }?.toAnnotatedString()
+ }
+ is Loading.CreatingAccount -> R.string.creating_new_account.toAnnotatedString()
+ is Loading.LoggingIn -> R.string.logging_in_description.toAnnotatedString()
+ Success -> R.string.logged_in_description.toAnnotatedString()
+ }
+
+@Composable
+private fun apiUnreachableText(
+ state: LoginUiStateError,
+ onShowApiUnreachableDialog: (LoginUiStateError) -> Unit,
+): AnnotatedString =
+ clickableAnnotatedString(
+ text = stringResource(R.string.login_error_api_unreachable),
+ argument = stringResource(R.string.read_more_here),
+ linkStyle =
+ SpanStyle(
+ color = MaterialTheme.colorScheme.onPrimary,
+ textDecoration = TextDecoration.Underline,
+ ),
+ onClick = { onShowApiUnreachableDialog(state) },
+ )
+
+@Composable
+private fun Int.toAnnotatedString(): AnnotatedString = AnnotatedString(stringResource(this))
+
+@Composable
private fun AccountDropDownItem(
modifier: Modifier = Modifier,
accountNumber: String,
@@ -451,3 +548,13 @@ private fun CreateAccountPanel(onCreateAccountClick: () -> Unit, isEnabled: Bool
)
}
}
+
+private fun LoginUiStateError.toApiUnreachableNavArg(): ApiUnreachableInfoDialogNavArgs =
+ ApiUnreachableInfoDialogNavArgs(
+ action =
+ when (this) {
+ is LoginUiStateError.LoginError.ApiUnreachable -> LoginAction.LOGIN
+ is LoginUiStateError.CreateAccountError.ApiUnreachable -> LoginAction.CREATE_ACCOUNT
+ else -> throw IllegalArgumentException("Not an API unreachable error")
+ }
+ )
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiUnreachableUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiUnreachableUiState.kt
new file mode 100644
index 0000000000..2a4f407934
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ApiUnreachableUiState.kt
@@ -0,0 +1,9 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.compose.dialog.info.LoginAction
+
+data class ApiUnreachableUiState(
+ val showEnableAllAccessMethodsButton: Boolean,
+ val noEmailAppAvailable: Boolean,
+ val loginAction: LoginAction,
+)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt
index 59ee72d6ba..2dbde070f4 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt
@@ -18,9 +18,9 @@ data class LoginUiState(
}
sealed interface LoginState {
- fun isError() = this is Idle && loginError != null
+ fun isError() = this is Idle && loginUiStateError != null
- data class Idle(val loginError: LoginError? = null) : LoginState
+ data class Idle(val loginUiStateError: LoginUiStateError? = null) : LoginState
sealed interface Loading : LoginState {
data object LoggingIn : Loading
@@ -31,12 +31,28 @@ sealed interface LoginState {
data object Success : LoginState
}
-sealed class LoginError {
- data object UnableToCreateAccount : LoginError()
+sealed interface LoginUiStateError {
+ sealed interface CreateAccountError : LoginUiStateError {
+ data object TooManyAttempts : CreateAccountError
- data object InvalidCredentials : LoginError()
+ data object ApiUnreachable : CreateAccountError
- data class Unknown(val reason: String) : LoginError()
+ data object NoInternetConnection : CreateAccountError
- data object NoInternetConnection : LoginError()
+ data object Unknown : CreateAccountError
+ }
+
+ sealed interface LoginError : LoginUiStateError {
+ data object InvalidCredentials : LoginError
+
+ data class InvalidInput(val accountNumber: AccountNumber) : LoginError
+
+ data object TooManyAttempts : LoginError
+
+ data object ApiUnreachable : LoginError
+
+ data class Unknown(val reason: String) : LoginError
+
+ data object NoInternetConnection : LoginError
+ }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt
index ea555784a5..e55f768acc 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/ApiAccessMethodTextField.kt
@@ -52,6 +52,14 @@ fun ApiAccessMethodTextField(
keyboardType = keyboardType,
imeAction = imeAction,
),
- supportingText = errorText?.let { { ErrorSupportingText(errorText) } },
+ supportingText =
+ errorText?.let {
+ {
+ ErrorSupportingText(
+ errorText,
+ modifier = Modifier.padding(top = Dimens.miniPadding),
+ )
+ }
+ },
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt
index b030093cce..e7e75ca3d6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt
@@ -113,8 +113,8 @@ fun CustomTextField(
}
@Composable
-fun ErrorSupportingText(text: String) {
- Row(modifier = Modifier.padding(top = Dimens.miniPadding)) {
+fun ErrorSupportingText(text: String, modifier: Modifier = Modifier) {
+ Row(modifier = modifier) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/SendEmail.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/SendEmail.kt
new file mode 100644
index 0000000000..5d02ad555f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/SendEmail.kt
@@ -0,0 +1,52 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.core.app.ShareCompat
+import androidx.core.net.toUri
+import kotlin.collections.toTypedArray
+
+class SendEmail : ActivityResultContract<EmailData, Unit>() {
+
+ /**
+ * Create email intent with or without attachment Depending on whether an attachment is provided
+ * we either create a generic share intent (with attachment) or a standard email intent (without
+ * attachment)
+ */
+ override fun createIntent(context: Context, input: EmailData): Intent {
+ return ShareCompat.IntentBuilder(context)
+ .setEmailTo(input.to.toTypedArray())
+ .setSubject(input.subject)
+ .setText(input.body)
+ .setType(EMAIL_TYPE)
+ .intent
+ .let {
+ if (input.attachment != null) {
+ it.putExtra(Intent.EXTRA_STREAM, input.attachment)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ } else {
+ it.setAction(Intent.ACTION_SENDTO).setData("mailto:".toUri())
+ }
+ }
+ }
+
+ override fun getSynchronousResult(
+ context: Context,
+ input: EmailData,
+ ): SynchronousResult<Unit>? = null
+
+ override fun parseResult(resultCode: Int, intent: Intent?): Unit = Unit
+
+ companion object {
+ const val EMAIL_TYPE = "text/plain"
+ }
+}
+
+data class EmailData(
+ val to: List<String>,
+ val subject: String? = null,
+ val body: String? = null,
+ val attachment: Uri? = null,
+)
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 a70e6f93db..6839b76753 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
@@ -46,6 +46,7 @@ import net.mullvad.mullvadvpn.usecase.RecentsUseCase
import net.mullvad.mullvadvpn.usecase.SelectHopUseCase
import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase
import net.mullvad.mullvadvpn.usecase.SelectedLocationUseCase
+import net.mullvad.mullvadvpn.usecase.SupportEmailUseCase
import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase
import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
import net.mullvad.mullvadvpn.usecase.customlists.CustomListRelayItemsUseCase
@@ -64,6 +65,7 @@ import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel
import net.mullvad.mullvadvpn.viewmodel.ApiAccessListViewModel
import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsViewModel
+import net.mullvad.mullvadvpn.viewmodel.ApiUnreachableViewModel
import net.mullvad.mullvadvpn.viewmodel.AppInfoViewModel
import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
@@ -190,6 +192,13 @@ val uiModule = module {
wireguardConstraintsRepository = get(),
)
}
+ single {
+ SupportEmailUseCase(
+ context = androidContext(),
+ mullvadProblemReport = get(),
+ buildVersion = get(),
+ )
+ }
single { InAppNotificationController(getAll(), MainScope()) }
@@ -319,6 +328,13 @@ val uiModule = module {
isPlayBuild = IS_PLAY_BUILD,
)
}
+ viewModel {
+ ApiUnreachableViewModel(
+ apiAccessRepository = get(),
+ supportEmailUseCase = get(),
+ savedStateHandle = get(),
+ )
+ }
// This view model must be single so we correctly attach lifecycle and share it with activity
single { MullvadAppViewModel(get(), get()) }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/provider/MullvadFileProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/provider/MullvadFileProvider.kt
index b070f7c646..2cd96347de 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/provider/MullvadFileProvider.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/provider/MullvadFileProvider.kt
@@ -24,11 +24,15 @@ enum class ProviderCacheDirectory(val directoryName: String) {
LOGS("logs")
}
-fun Context.getLogsShareIntent(logContent: String): Intent {
+fun Context.createShareLogFile(logContent: String): Uri {
val fileName = createShareLogFileName()
val cacheFile = createCacheFile(ProviderCacheDirectory.LOGS, fileName)
cacheFile.writeText(logContent)
- val logsUri = MullvadFileProvider.uriForFile(this, cacheFile)
+ return MullvadFileProvider.uriForFile(this, cacheFile)
+}
+
+fun Context.getLogsShareIntent(logContent: String): Intent {
+ val logsUri = this.createShareLogFile(logContent)
val sendIntent: Intent =
Intent().apply {
@@ -40,7 +44,7 @@ fun Context.getLogsShareIntent(logContent: String): Intent {
return Intent.createChooser(sendIntent, null)
}
-fun Context.createCacheFile(directory: ProviderCacheDirectory, fileName: String): File {
+private fun Context.createCacheFile(directory: ProviderCacheDirectory, fileName: String): File {
// Path to log file
val logsPath = File(cacheDir, directory.directoryName)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepository.kt
index cdb3779fed..b8978b4993 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ApiAccessRepository.kt
@@ -1,5 +1,6 @@
package net.mullvad.mullvadvpn.repository
+import arrow.core.Either
import arrow.core.raise.either
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -15,6 +16,7 @@ import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodName
import net.mullvad.mullvadvpn.lib.model.ApiAccessMethodSetting
import net.mullvad.mullvadvpn.lib.model.GetApiAccessMethodError
import net.mullvad.mullvadvpn.lib.model.NewAccessMethodSetting
+import net.mullvad.mullvadvpn.lib.model.UpdateApiAccessMethodError
class ApiAccessRepository(
private val managementService: ManagementService,
@@ -82,4 +84,12 @@ class ApiAccessRepository(
val accessMethod = getApiAccessMethodSettingById(id).bind()
updateApiAccessMethod(accessMethod.copy(enabled = enabled)).bind()
}
+
+ suspend fun enableAllApiAccessMethods(): Either<UpdateApiAccessMethodError, Unit> = either {
+ accessMethods.value?.forEach {
+ if (!it.enabled) {
+ updateApiAccessMethod(it.copy(enabled = true)).bind()
+ }
+ }
+ }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/InternetAvailableUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/InternetAvailableUseCase.kt
index c00aee7e62..5bd2a4db25 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/InternetAvailableUseCase.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/InternetAvailableUseCase.kt
@@ -4,6 +4,12 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
+/**
+ * Checks for internet availability on the device.
+ *
+ * NOTE! This check is unreliable and should not be used to gate network requests, only to check for
+ * issues after a network request has failed.
+ */
class InternetAvailableUseCase(val context: Context) {
operator fun invoke(): Boolean {
val connectivityManager =
@@ -12,7 +18,6 @@ class InternetAvailableUseCase(val context: Context) {
val network = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(network)
- // If we are not able to fetch capabilities we should assume we have connectivity
- return capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: true
+ return capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SupportEmailUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SupportEmailUseCase.kt
new file mode 100644
index 0000000000..1aabb5740e
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SupportEmailUseCase.kt
@@ -0,0 +1,41 @@
+package net.mullvad.mullvadvpn.usecase
+
+import android.content.Context
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport
+import net.mullvad.mullvadvpn.lib.model.BuildVersion
+
+class SupportEmailUseCase(
+ private val context: Context,
+ private val mullvadProblemReport: MullvadProblemReport,
+ private val buildVersion: BuildVersion,
+) {
+ suspend operator fun invoke(message: String? = null): SupportMail {
+ return SupportMail(
+ address = context.getString(R.string.support_email),
+ subject = subject(),
+ message = message,
+ logs = mullvadProblemReport.readLogs(),
+ )
+ }
+
+ // This is an approximation of the subject line that is used when a user contacts support
+ // through the app
+ private fun subject(): String {
+ return context.getString(
+ R.string.support_email_subject,
+ buildVersion.name,
+ android.os.Build.VERSION.RELEASE,
+ android.os.Build.VERSION.SDK_INT,
+ android.os.Build.MANUFACTURER,
+ android.os.Build.MODEL,
+ )
+ }
+}
+
+data class SupportMail(
+ val address: String,
+ val subject: String,
+ val message: String?,
+ val logs: List<String>,
+)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiUnreachableViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiUnreachableViewModel.kt
new file mode 100644
index 0000000000..deedc935e8
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ApiUnreachableViewModel.kt
@@ -0,0 +1,112 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ramcosta.composedestinations.generated.destinations.ApiUnreachableInfoDestination
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.WhileSubscribed
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.dialog.info.ApiUnreachableInfoDialogNavArgs
+import net.mullvad.mullvadvpn.compose.state.ApiUnreachableUiState
+import net.mullvad.mullvadvpn.constant.VIEW_MODEL_STOP_TIMEOUT
+import net.mullvad.mullvadvpn.lib.ui.component.NEWLINE_STRING
+import net.mullvad.mullvadvpn.repository.ApiAccessRepository
+import net.mullvad.mullvadvpn.usecase.SupportEmailUseCase
+
+class ApiUnreachableViewModel(
+ private val apiAccessRepository: ApiAccessRepository,
+ private val supportEmailUseCase: SupportEmailUseCase,
+ savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+ private val navArgs = ApiUnreachableInfoDestination.argsFrom(savedStateHandle)
+
+ private val noEmailAppAvailable = MutableStateFlow(false)
+ private val hasEnabledAllApiAccessMethods = MutableStateFlow(false)
+
+ private val _uiSideEffect = Channel<ApiUnreachableSideEffect>(Channel.BUFFERED)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ val uiState =
+ combine(noEmailAppAvailable, hasEnabledAllApiAccessMethods) {
+ noEmailAppAvailable,
+ hasEnabledAllApiAccessMethods ->
+ ApiUnreachableUiState(
+ showEnableAllAccessMethodsButton = !hasEnabledAllApiAccessMethods,
+ noEmailAppAvailable = noEmailAppAvailable,
+ loginAction = navArgs.action,
+ )
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(stopTimeout = VIEW_MODEL_STOP_TIMEOUT),
+ initialValue =
+ ApiUnreachableUiState(
+ showEnableAllAccessMethodsButton = false,
+ noEmailAppAvailable = false,
+ loginAction = navArgs.action,
+ ),
+ )
+
+ init {
+ viewModelScope.launch {
+ hasEnabledAllApiAccessMethods.emit(
+ apiAccessRepository.accessMethods.filterNotNull().first().all { it.enabled }
+ )
+ }
+ }
+
+ fun enableAllApiAccess() {
+ viewModelScope.launch {
+ apiAccessRepository
+ .enableAllApiAccessMethods()
+ .fold(
+ {
+ _uiSideEffect.send(ApiUnreachableSideEffect.EnableAllApiAccessMethods.Error)
+ },
+ {
+ _uiSideEffect.send(
+ ApiUnreachableSideEffect.EnableAllApiAccessMethods.Success(navArgs)
+ )
+ },
+ )
+ }
+ }
+
+ fun sendProblemReportEmail() {
+ viewModelScope.launch {
+ noEmailAppAvailable.emit(false)
+ val supportEmail = supportEmailUseCase()
+ _uiSideEffect.send(
+ ApiUnreachableSideEffect.SendEmail(
+ address = supportEmail.address,
+ subject = supportEmail.subject,
+ logs = supportEmail.logs.joinToString(NEWLINE_STRING),
+ )
+ )
+ }
+ }
+
+ fun noEmailAppAvailable() {
+ viewModelScope.launch { noEmailAppAvailable.emit(true) }
+ }
+}
+
+sealed interface ApiUnreachableSideEffect {
+ data class SendEmail(val address: String, val subject: String, val logs: String) :
+ ApiUnreachableSideEffect
+
+ sealed interface EnableAllApiAccessMethods : ApiUnreachableSideEffect {
+ data class Success(val navArgs: ApiUnreachableInfoDialogNavArgs) :
+ EnableAllApiAccessMethods
+
+ data object Error : EnableAllApiAccessMethods
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
index f1213ea6f6..e114ccde7c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
@@ -20,21 +20,24 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import net.mullvad.mullvadvpn.compose.state.LoginError
import net.mullvad.mullvadvpn.compose.state.LoginState
import net.mullvad.mullvadvpn.compose.state.LoginState.Idle
import net.mullvad.mullvadvpn.compose.state.LoginState.Loading
import net.mullvad.mullvadvpn.compose.state.LoginState.Success
import net.mullvad.mullvadvpn.compose.state.LoginUiState
+import net.mullvad.mullvadvpn.compose.state.LoginUiStateError
import net.mullvad.mullvadvpn.constant.VIEW_MODEL_STOP_TIMEOUT
import net.mullvad.mullvadvpn.lib.common.util.isBeforeNowInstant
import net.mullvad.mullvadvpn.lib.model.AccountNumber
+import net.mullvad.mullvadvpn.lib.model.CreateAccountError
import net.mullvad.mullvadvpn.lib.model.LoginAccountError
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
import net.mullvad.mullvadvpn.repository.NewDeviceRepository
import net.mullvad.mullvadvpn.usecase.InternetAvailableUseCase
import net.mullvad.mullvadvpn.util.delayAtLeast
import net.mullvad.mullvadvpn.util.getOrDefault
+import net.mullvad.mullvadvpn.viewmodel.LoginUiSideEffect.NavigateToWelcome
+import net.mullvad.mullvadvpn.viewmodel.LoginUiSideEffect.TooManyDevices
private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L
@@ -108,17 +111,13 @@ class LoginViewModel(
accountRepository
.createAccount()
.fold(
- { _loginState.value = Idle(LoginError.UnableToCreateAccount) },
- { _uiSideEffect.send(LoginUiSideEffect.NavigateToWelcome) },
+ { _loginState.value = it.toUiState() },
+ { _uiSideEffect.send(NavigateToWelcome) },
)
}
}
fun login(accountNumber: String) {
- if (!isInternetAvailable()) {
- _loginState.value = Idle(LoginError.NoInternetConnection)
- return
- }
_loginState.value = Loading.LoggingIn
viewModelScope.launch(dispatcher) {
val uiState =
@@ -172,17 +171,43 @@ class LoginViewModel(
private suspend fun LoginAccountError.toUiState(): LoginState =
when (this) {
- LoginAccountError.InvalidAccount -> Idle(LoginError.InvalidCredentials)
+ LoginAccountError.InvalidAccount ->
+ Idle(LoginUiStateError.LoginError.InvalidCredentials)
is LoginAccountError.MaxDevicesReached ->
- Idle().also { _uiSideEffect.send(LoginUiSideEffect.TooManyDevices(accountNumber)) }
- LoginAccountError.RpcError ->
- Idle(LoginError.Unknown(this.toString())).also { Logger.w("RPC Error") }
+ Idle().also { _uiSideEffect.send(TooManyDevices(accountNumber)) }
+ is LoginAccountError.InvalidInput ->
+ Idle(LoginUiStateError.LoginError.InvalidInput(accountNumber))
+ LoginAccountError.Timeout,
+ LoginAccountError.ApiUnreachable ->
+ if (isInternetAvailable()) {
+ Idle(LoginUiStateError.LoginError.ApiUnreachable)
+ } else {
+ Idle(LoginUiStateError.LoginError.NoInternetConnection)
+ }
+ LoginAccountError.TooManyAttempts -> Idle(LoginUiStateError.LoginError.TooManyAttempts)
is LoginAccountError.Unknown ->
- Idle(LoginError.Unknown(this.toString())).also {
+ Idle(LoginUiStateError.LoginError.Unknown(this.toString())).also {
Logger.w("Login failed with error: $this", error)
}
}
+ private fun CreateAccountError.toUiState(): LoginState =
+ when (this) {
+ CreateAccountError.ApiUnreachable,
+ CreateAccountError.TimeOut ->
+ if (isInternetAvailable()) {
+ Idle(LoginUiStateError.CreateAccountError.ApiUnreachable)
+ } else {
+ Idle(LoginUiStateError.CreateAccountError.NoInternetConnection)
+ }
+ CreateAccountError.TooManyAttempts ->
+ Idle(LoginUiStateError.CreateAccountError.TooManyAttempts)
+ is CreateAccountError.Unknown ->
+ Idle(LoginUiStateError.CreateAccountError.Unknown).also {
+ Logger.w("Create account failed with error: $this", error)
+ }
+ }
+
private fun isInternetAvailable(): Boolean {
return internetAvailableUseCase()
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
index 2c246d8eff..81e0afefbe 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
@@ -17,15 +17,16 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
-import net.mullvad.mullvadvpn.compose.state.LoginError
import net.mullvad.mullvadvpn.compose.state.LoginState.Idle
import net.mullvad.mullvadvpn.compose.state.LoginState.Loading
import net.mullvad.mullvadvpn.compose.state.LoginState.Success
import net.mullvad.mullvadvpn.compose.state.LoginUiState
+import net.mullvad.mullvadvpn.compose.state.LoginUiStateError
import net.mullvad.mullvadvpn.data.mock
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.AccountData
import net.mullvad.mullvadvpn.lib.model.AccountNumber
+import net.mullvad.mullvadvpn.lib.model.CreateAccountError
import net.mullvad.mullvadvpn.lib.model.LoginAccountError
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
import net.mullvad.mullvadvpn.usecase.InternetAvailableUseCase
@@ -67,16 +68,21 @@ class LoginViewModelTest {
// Arrange
every { connectivityUseCase() } returns false
val uiStates = loginViewModel.uiState.testIn(backgroundScope)
+ coEvery { mockedAccountRepository.login(any()) } returns
+ LoginAccountError.ApiUnreachable.left()
// Act
loginViewModel.login("")
// Discard default item
- uiStates.awaitItem()
+ uiStates.skipDefaultItem()
+
+ // Logging in state
+ assertEquals(Loading.LoggingIn, uiStates.awaitItem().loginState)
// Assert
assertEquals(
- Idle(loginError = LoginError.NoInternetConnection),
+ Idle(loginUiStateError = LoginUiStateError.LoginError.NoInternetConnection),
uiStates.awaitItem().loginState,
)
}
@@ -153,7 +159,10 @@ class LoginViewModelTest {
skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_NUMBER.value)
assertEquals(Loading.LoggingIn, awaitItem().loginState)
- assertEquals(Idle(loginError = LoginError.InvalidCredentials), awaitItem().loginState)
+ assertEquals(
+ Idle(loginUiStateError = LoginUiStateError.LoginError.InvalidCredentials),
+ awaitItem().loginState,
+ )
}
}
@@ -180,24 +189,6 @@ class LoginViewModelTest {
}
@Test
- fun `given RpcError when logging in then show unknown error with message`() = runTest {
- loginViewModel.uiState.test {
- // Arrange
- coEvery { mockedAccountRepository.login(any()) } returns
- LoginAccountError.RpcError.left()
-
- // Act, Assert
- skipDefaultItem()
- loginViewModel.login(DUMMY_ACCOUNT_NUMBER.value)
- assertEquals(Loading.LoggingIn, awaitItem().loginState)
- assertEquals(
- Idle(LoginError.Unknown(EXPECTED_RPC_ERROR_MESSAGE)),
- awaitItem().loginState,
- )
- }
- }
-
- @Test
fun `given unknown error when logging in then show unknown error with message`() = runTest {
loginViewModel.uiState.test {
// Arrange
@@ -210,7 +201,7 @@ class LoginViewModelTest {
assertEquals(Loading.LoggingIn, awaitItem().loginState)
val loginState = awaitItem().loginState
assertIs<Idle>(loginState)
- assertIs<LoginError.Unknown>(loginState.loginError)
+ assertIs<LoginUiStateError.LoginError.Unknown>(loginState.loginUiStateError)
}
}
@@ -239,12 +230,63 @@ class LoginViewModelTest {
coVerify { mockedAccountRepository.clearAccountHistory() }
}
+ @Test
+ fun `given InvalidInput when logging in then show invalid input error`() = runTest {
+ loginViewModel.uiState.test {
+ // Arrange
+ coEvery { mockedAccountRepository.login(any()) } returns
+ LoginAccountError.InvalidInput(DUMMY_ACCOUNT_NUMBER).left()
+
+ // Act, Assert
+ skipDefaultItem()
+ loginViewModel.login(DUMMY_ACCOUNT_NUMBER.value)
+ assertEquals(Loading.LoggingIn, awaitItem().loginState)
+ assertEquals(
+ Idle(LoginUiStateError.LoginError.InvalidInput(DUMMY_ACCOUNT_NUMBER)),
+ awaitItem().loginState,
+ )
+ }
+ }
+
+ @Test
+ fun `given TooManyAttempts when logging in then show too many attempts error`() = runTest {
+ loginViewModel.uiState.test {
+ // Arrange
+ coEvery { mockedAccountRepository.login(any()) } returns
+ LoginAccountError.TooManyAttempts.left()
+
+ // Act, Assert
+ skipDefaultItem()
+ loginViewModel.login(DUMMY_ACCOUNT_NUMBER.value)
+ assertEquals(Loading.LoggingIn, awaitItem().loginState)
+ assertEquals(Idle(LoginUiStateError.LoginError.TooManyAttempts), awaitItem().loginState)
+ }
+ }
+
+ @Test
+ fun `given TooManyAttempts when creating an account in then show too many attempts error`() =
+ runTest {
+ loginViewModel.uiState.test {
+ // Arrange
+ coEvery { mockedAccountRepository.createAccount() } returns
+ CreateAccountError.TooManyAttempts.left()
+
+ // Act, Assert
+ skipDefaultItem()
+ loginViewModel.onCreateAccountClick()
+ assertEquals(Loading.CreatingAccount, awaitItem().loginState)
+ assertEquals(
+ Idle(LoginUiStateError.CreateAccountError.TooManyAttempts),
+ awaitItem().loginState,
+ )
+ }
+ }
+
private suspend fun <T> ReceiveTurbine<T>.skipDefaultItem() where T : Any? {
awaitItem()
}
companion object {
private val DUMMY_ACCOUNT_NUMBER = AccountNumber("DUMMY")
- private const val EXPECTED_RPC_ERROR_MESSAGE = "RpcError"
}
}
diff --git a/android/lib/daemon-grpc/build.gradle.kts b/android/lib/daemon-grpc/build.gradle.kts
index cadd126830..3dde9bf8a8 100644
--- a/android/lib/daemon-grpc/build.gradle.kts
+++ b/android/lib/daemon-grpc/build.gradle.kts
@@ -25,6 +25,7 @@ android {
compilerOptions {
jvmTarget = JvmTarget.fromTarget(libs.versions.jvm.target.get())
allWarningsAsErrors = true
+ freeCompilerArgs = listOf("-XXLanguage:+WhenGuards")
}
}
diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt
index 0a9266eb50..62d78bc4e7 100644
--- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt
+++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt
@@ -346,9 +346,13 @@ class ManagementService(
.mapLeftStatus {
when (it.status.code) {
Status.Code.UNAUTHENTICATED -> LoginAccountError.InvalidAccount
+ Status.Code.RESOURCE_EXHAUSTED if it.status.isTooManyRequests() ->
+ LoginAccountError.TooManyAttempts
Status.Code.RESOURCE_EXHAUSTED ->
LoginAccountError.MaxDevicesReached(accountNumber)
- Status.Code.UNAVAILABLE -> LoginAccountError.RpcError
+ Status.Code.DEADLINE_EXCEEDED -> LoginAccountError.Timeout
+ Status.Code.INVALID_ARGUMENT -> LoginAccountError.InvalidInput(accountNumber)
+ Status.Code.UNAVAILABLE -> LoginAccountError.ApiUnreachable
else -> {
Logger.e("Unknown login account error")
LoginAccountError.Unknown(it)
@@ -403,7 +407,16 @@ class ManagementService(
AccountNumber(accountNumberStringValue.value)
}
.onLeft { Logger.e("Create account error") }
- .mapLeft(CreateAccountError::Unknown)
+ .mapLeftStatus {
+ when (it.status.code) {
+ Status.Code.RESOURCE_EXHAUSTED -> CreateAccountError.TooManyAttempts
+ Status.Code.UNAVAILABLE -> CreateAccountError.ApiUnreachable
+ Status.Code.DEADLINE_EXCEEDED -> CreateAccountError.TimeOut
+ else -> {
+ CreateAccountError.Unknown(it)
+ }
+ }
+ }
suspend fun updateDnsContentBlockers(
update: (DefaultDnsOptions) -> DefaultDnsOptions
@@ -900,8 +913,12 @@ class ManagementService(
}
}
+ private fun Status.isTooManyRequests() = description == TOO_MANY_REQUESTS
+
companion object {
const val ENABLE_TRACE_LOGGING = false
+
+ const val TOO_MANY_REQUESTS = "429 Too Many Requests"
}
}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateAccountError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateAccountError.kt
index eeeaf11fca..f32e2abf22 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateAccountError.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateAccountError.kt
@@ -1,5 +1,11 @@
package net.mullvad.mullvadvpn.lib.model
sealed class CreateAccountError {
+ data object TooManyAttempts : CreateAccountError()
+
+ data object ApiUnreachable : CreateAccountError()
+
+ data object TimeOut : CreateAccountError()
+
data class Unknown(val error: Throwable) : CreateAccountError()
}
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginAccountError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginAccountError.kt
index 99c36bc9d2..462a6f3cc6 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginAccountError.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginAccountError.kt
@@ -9,7 +9,13 @@ sealed class LoginAccountError : Parcelable {
data class MaxDevicesReached(val accountNumber: AccountNumber) : LoginAccountError()
- data object RpcError : LoginAccountError()
+ data class InvalidInput(val accountNumber: AccountNumber) : LoginAccountError()
+
+ data object TooManyAttempts : LoginAccountError()
+
+ data object Timeout : LoginAccountError()
+
+ data object ApiUnreachable : LoginAccountError()
data class Unknown(val error: Throwable) : LoginAccountError()
}
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index bc07a73fd7..9966130a23 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -445,4 +445,20 @@
<string name="android_16_upgrade_warning_dialog_first_message">After updating a VPN app on Android 16, devices might end up in a state where VPN apps are no longer able to reach the internet.</string>
<string name="android_16_upgrade_warning_dialog_second_message">Please restart your device and try connecting again. If this does not work, please write an email to %s in Swedish or English.</string>
<string name="click_here">click here</string>
+ <string name="login_error_api_unreachable">Unable to reach API, %s</string>
+ <string name="login_error_too_many_attempts">Too many attempts, please try again later</string>
+ <string name="login_error_invalid_input">Account number is too long, please enter a valid number</string>
+ <string name="create_account_fail_title">Creating account failed</string>
+ <string name="read_more_here">read more here</string>
+ <string name="unable_to_reach_api_dialog_title">Unable to reach API</string>
+ <string name="unable_to_reach_api_dialog_message_first">The app was unable to %s due to not being able to reach the Mullvad API. This might be due to censorship or network issues. Please try to:</string>
+ <string name="unable_to_reach_api_dialog_action_login">login</string>
+ <string name="unable_to_reach_api_dialog_action_create">create an account</string>
+ <string name="unable_to_reach_api_dialog_message_list_first">Restart your device</string>
+ <string name="unable_to_reach_api_dialog_message_list_second">Connect to a different network</string>
+ <string name="unable_to_reach_api_dialog_message_list_third">Enable all access methods in the app</string>
+ <string name="unable_to_reach_api_dialog_message_second">If these steps don’t work, please send an email to support using the button below. It will automatically attach the logs which have been anonymized.</string>
+ <string name="enable_all_methods">Enable all &amp; retry</string>
+ <string name="send_email">Send email</string>
+ <string name="no_email_app_available">No email app available on the device</string>
</resources>
diff --git a/android/lib/resource/src/main/res/values/strings_non_translatable.xml b/android/lib/resource/src/main/res/values/strings_non_translatable.xml
index 0c87a19c25..529c8c625c 100644
--- a/android/lib/resource/src/main/res/values/strings_non_translatable.xml
+++ b/android/lib/resource/src/main/res/values/strings_non_translatable.xml
@@ -18,5 +18,6 @@
</string>
<string name="daita" translatable="false">DAITA</string>
<string name="daita_full" translatable="false">Defence against AI-guided Traffic Analysis</string>
- <string name="support_email">support@mullvadvpn.net</string>
+ <string name="support_email" translatable="false">support@mullvadvpn.net</string>
+ <string name="support_email_subject" translatable="false">Mullvad VPN App %s Android %s (%d) - %s %s</string>
</resources>
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot
index f9c7989f91..322fae8a8b 100644
--- a/desktop/packages/mullvad-vpn/locales/messages.pot
+++ b/desktop/packages/mullvad-vpn/locales/messages.pot
@@ -2934,6 +2934,9 @@ msgstr ""
msgid "Account credit expires soon"
msgstr ""
+msgid "Account number is too long, please enter a valid number"
+msgstr ""
+
msgid "Account time reminders"
msgstr ""
@@ -3030,6 +3033,9 @@ msgstr ""
msgid "Connect on device start-up"
msgstr ""
+msgid "Connect to a different network"
+msgstr ""
+
msgid "Connecting..."
msgstr ""
@@ -3051,6 +3057,9 @@ msgstr ""
msgid "Create new list"
msgstr ""
+msgid "Creating account failed"
+msgstr ""
+
msgid "Critical error (your attention is required)"
msgstr ""
@@ -3117,6 +3126,12 @@ msgstr ""
msgid "Enable %1$s"
msgstr ""
+msgid "Enable all & retry"
+msgstr ""
+
+msgid "Enable all access methods in the app"
+msgstr ""
+
msgid "Enable method"
msgstr ""
@@ -3180,6 +3195,9 @@ msgstr ""
msgid "If the split tunneling feature is used, then the app queries your system for a list of all installed applications. This list is only retrieved in the split tunneling view. The list of installed applications is never sent from the device."
msgstr ""
+msgid "If these steps don’t work, please send an email to support using the button below. It will automatically attach the logs which have been anonymized."
+msgstr ""
+
msgid "Import new overrides by"
msgstr ""
@@ -3243,6 +3261,9 @@ msgstr ""
msgid "No custom lists available"
msgstr ""
+msgid "No email app available on the device"
+msgstr ""
+
msgid "No internet connection"
msgstr ""
@@ -3330,9 +3351,15 @@ msgstr ""
msgid "Reset to default"
msgstr ""
+msgid "Restart your device"
+msgstr ""
+
msgid "Search"
msgstr ""
+msgid "Send email"
+msgstr ""
+
msgid "Set %s obfuscation to \"Automatic\" or \"Off\" below to activate this setting."
msgstr ""
@@ -3381,6 +3408,9 @@ msgstr ""
msgid "The app is blocking internet, please disconnect first"
msgstr ""
+msgid "The app was unable to %s due to not being able to reach the Mullvad API. This might be due to censorship or network issues. Please try to:"
+msgstr ""
+
msgid "The local DNS server will not work unless you enable \"Local Network Sharing\" under VPN settings."
msgstr ""
@@ -3414,12 +3444,21 @@ msgstr ""
msgid "Toggle VPN"
msgstr ""
+msgid "Too many attempts, please try again later"
+msgstr ""
+
msgid "Unable to apply firewall rules. Please troubleshoot or send a problem report."
msgstr ""
msgid "Unable to parse patch"
msgstr ""
+msgid "Unable to reach API"
+msgstr ""
+
+msgid "Unable to reach API, %s"
+msgstr ""
+
msgid "Unable to start tunnel connection. Please disable Always-on VPN before using Mullvad VPN."
msgstr ""
@@ -3513,12 +3552,21 @@ msgstr ""
msgid "click here"
msgstr ""
+msgid "create an account"
+msgstr ""
+
msgid "here"
msgstr ""
msgid "less than one day"
msgstr ""
+msgid "login"
+msgstr ""
+
+msgid "read more here"
+msgstr ""
+
msgid "%d day"
msgid_plural "%d days"
msgstr[0] ""
diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs
index a584b95f38..4ed05df442 100644
--- a/mullvad-daemon/src/management_interface.rs
+++ b/mullvad-daemon/src/management_interface.rs
@@ -1489,8 +1489,17 @@ fn map_rest_error(error: &RestError) -> Status {
{
Status::new(Code::Unauthenticated, message)
}
+ RestError::ApiError(status, message) if *status == StatusCode::BAD_REQUEST => {
+ Status::new(Code::InvalidArgument, message)
+ }
+ // FIXME: do not use Code for this
+ RestError::ApiError(status, _) if *status == StatusCode::TOO_MANY_REQUESTS => Status::new(
+ Code::ResourceExhausted,
+ StatusCode::TOO_MANY_REQUESTS.to_string(),
+ ),
RestError::TimeoutError => Status::deadline_exceeded("API request timed out"),
RestError::HyperError(_) => Status::unavailable("Cannot reach the API"),
+ RestError::LegacyHyperError(_) => Status::unavailable("Cannot reach the API"),
error => Status::unknown(format!("REST error: {error}")),
}
}