diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-10-14 09:43:44 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-10-14 09:43:44 +0200 |
| commit | fe1844b2c887cace39aeac0f57c41d7e8b2d7d09 (patch) | |
| tree | dcba71b58d03985b25dfbb0a5f575e20d46b9e87 | |
| parent | 72864c0654510a5a9b2fc5493233880b9fba93d7 (diff) | |
| parent | 813bb62f92680a149c9c1482964ec31fae0b47c3 (diff) | |
| download | mullvadvpn-fe1844b2c887cace39aeac0f57c41d7e8b2d7d09.tar.xz mullvadvpn-fe1844b2c887cace39aeac0f57c41d7e8b2d7d09.zip | |
Merge branch 'improve-login-error-messages-droid-2104'
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 & 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}")), } } |
