summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-05-05 23:29:22 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-05-06 12:39:13 +0200
commit2ea5bf3bff4a68b04ee805419b952917cd15ce59 (patch)
treebea1dd4405b5d4345f23f9bbf9b504d72491378b
parentc228ff918ac023c1c76bb4cf4f3d595a181cea63 (diff)
downloadmullvadvpn-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
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt10
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt35
-rw-r--r--android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt18
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml2
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>