diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-05-05 23:29:22 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-05-06 12:39:13 +0200 |
| commit | 2ea5bf3bff4a68b04ee805419b952917cd15ce59 (patch) | |
| tree | bea1dd4405b5d4345f23f9bbf9b504d72491378b | |
| parent | c228ff918ac023c1c76bb4cf4f3d595a181cea63 (diff) | |
| download | mullvadvpn-2ea5bf3bff4a68b04ee805419b952917cd15ce59.tar.xz mullvadvpn-2ea5bf3bff4a68b04ee805419b952917cd15ce59.zip | |
Fix go to vpn settings on TV devices
- Do not show the action on devices without vpn settings
- Handle exceptions when starting vpn settings activity
6 files changed, 86 insertions, 11 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 2a3474c755..45e9bdee52 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -231,7 +231,7 @@ fun Connect( createVpnProfile.launch(sideEffect.prepareError.prepareIntent) } - is ConnectViewModel.UiSideEffect.ConnectError.Generic -> + ConnectViewModel.UiSideEffect.ConnectError.Generic -> snackbarHostState.showSnackbarImmediately( message = context.getString(R.string.error_occurred) ) @@ -239,10 +239,31 @@ fun Connect( is ConnectViewModel.UiSideEffect.ConnectError.PermissionDenied -> { launch { snackbarHostState.showSnackbarImmediately( - message = context.getString(R.string.vpn_permission_denied_error), - actionLabel = context.getString(R.string.go_to_vpn_settings), - withDismissAction = true, - onAction = context::openVpnSettings, + message = + context.getString( + if (sideEffect.systemVpnSettingsAvailable) { + R.string.vpn_permission_denied_error + } else { + R.string.vpn_permission_denied_error_no_vpn_settings + } + ), + actionLabel = + if (sideEffect.systemVpnSettingsAvailable) { + context.getString(R.string.go_to_vpn_settings) + } else { + null + }, + withDismissAction = sideEffect.systemVpnSettingsAvailable, + onAction = { + context.openVpnSettings().onLeft { + launch { + snackbarHostState.showSnackbarImmediately( + message = + context.getString(R.string.vpn_settings_not_available) + ) + } + } + }, ) } } 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 776a36ccb7..c44aedd890 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 @@ -217,6 +217,7 @@ val uiModule = module { paymentUseCase = get(), connectionProxy = get(), lastKnownLocationUseCase = get(), + systemVpnSettingsUseCase = get(), resources = get(), isPlayBuild = IS_PLAY_BUILD, isFdroidBuild = IS_FDROID_BUILD, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index 2ccb138a5f..e836acb844 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -35,6 +35,7 @@ import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase +import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase import net.mullvad.mullvadvpn.util.combine import net.mullvad.mullvadvpn.util.isSuccess import net.mullvad.mullvadvpn.util.withPrev @@ -51,6 +52,7 @@ class ConnectViewModel( private val paymentUseCase: PaymentUseCase, private val connectionProxy: ConnectionProxy, lastKnownLocationUseCase: LastKnownLocationUseCase, + private val systemVpnSettingsUseCase: SystemVpnSettingsAvailableUseCase, private val resources: Resources, private val isPlayBuild: Boolean, private val isFdroidBuild: Boolean, @@ -154,7 +156,11 @@ class ConnectViewModel( } else { // Either the user denied the permission or another always-on-vpn is active (if // Android 11+ and run from Android Studio) - _uiSideEffect.send(UiSideEffect.ConnectError.PermissionDenied) + // If we don't have vpn system settings available we assume that there is no other + // always-on-vpn active. + _uiSideEffect.send( + UiSideEffect.ConnectError.PermissionDenied(systemVpnSettingsUseCase()) + ) } } } @@ -220,7 +226,7 @@ class ConnectViewModel( sealed interface ConnectError : UiSideEffect { data object Generic : ConnectError - data object PermissionDenied : ConnectError + data class PermissionDenied(val systemVpnSettingsAvailable: Boolean) : ConnectError } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index ec4e9c0bbb..b298270fad 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt @@ -39,6 +39,7 @@ import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase +import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -90,6 +91,9 @@ class ConnectViewModelTest { // Last known location private val mockLastKnownLocationUseCase: LastKnownLocationUseCase = mockk() + // System VPN Settings + private val mockSystemVpnSettingsUseCase: SystemVpnSettingsAvailableUseCase = mockk() + @BeforeEach fun setup() { every { mockServiceConnectionManager.connectionState } returns serviceConnectionState @@ -125,6 +129,7 @@ class ConnectViewModelTest { selectedLocationTitleUseCase = mockSelectedLocationTitleUseCase, connectionProxy = mockConnectionProxy, lastKnownLocationUseCase = mockLastKnownLocationUseCase, + systemVpnSettingsUseCase = mockSystemVpnSettingsUseCase, resources = mockk(), isPlayBuild = false, isFdroidBuild = false, @@ -334,4 +339,34 @@ class ConnectViewModelTest { assertEquals(lastKnownLocation, result.location) } } + + @Test + fun `given no vpn system setting available should return the correct permission denied`() = + runTest { + // Arrange + val expectedSideEffect = + ConnectViewModel.UiSideEffect.ConnectError.PermissionDenied(false) + every { mockSystemVpnSettingsUseCase.invoke() } returns false + + // Act + viewModel.createVpnProfileResult(hasVpnPermission = false) + + // Assert + viewModel.uiSideEffect.test { assertEquals(expectedSideEffect, awaitItem()) } + } + + @Test + fun `given vpn system setting available should return the correct permission denied`() = + runTest { + // Arrange + val expectedSideEffect = + ConnectViewModel.UiSideEffect.ConnectError.PermissionDenied(true) + every { mockSystemVpnSettingsUseCase.invoke() } returns true + + // Act + viewModel.createVpnProfileResult(hasVpnPermission = false) + + // Assert + viewModel.uiSideEffect.test { assertEquals(expectedSideEffect, awaitItem()) } + } } diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt index bf275c4f53..2d82bc56f4 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt @@ -1,9 +1,12 @@ package net.mullvad.mullvadvpn.lib.common.util +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri import androidx.core.net.toUri +import arrow.core.Either +import co.touchlab.kermit.Logger import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken fun createAccountUri(accountUri: String, websiteAuthToken: WebsiteAuthToken?): Uri { @@ -17,7 +20,14 @@ fun createAccountUri(accountUri: String, websiteAuthToken: WebsiteAuthToken?): U return urlString.toUri() } -fun Context.openVpnSettings() { - val intent = Intent("android.settings.VPN_SETTINGS") - startActivity(intent) -} +// Activity not found can be return if the device does not have system vpn settings available. +// This is the case for Android TV devices. In normal cases, this action should not be available +// for those devices (see SystemVpnSettingsAvailableUseCase). This is an extra safety check. + +fun Context.openVpnSettings(): Either<ActivityNotFoundException, Unit> = + Either.catch { + val intent = Intent("android.settings.VPN_SETTINGS") + startActivity(intent) + } + .onLeft { Logger.e("Failed to open VPN settings", it) } + .mapLeft { it as? ActivityNotFoundException ?: throw it } diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 401e57e566..d9fe661a21 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -416,4 +416,6 @@ <string name="device_ip_info_first_paragraph">This feature allows you to choose whether to use only IPv4, only IPv6, or allow the app to automatically decide the best option when connecting to a server.</string> <string name="device_ip_info_second_paragraph">It can be useful when you are aware of problems caused by a certain IP version.</string> <string name="daita_multihop">%s: Multihop</string> + <string name="vpn_permission_denied_error_no_vpn_settings">VPN permission was denied</string> + <string name="vpn_settings_not_available">VPN Settings not available on device</string> </resources> |
