From 6aff63d1b8c0ce415ac1222fe36e19101d0e49ec Mon Sep 17 00:00:00 2001 From: MaryamShaghaghi <122574719+MaryamShaghaghi@users.noreply.github.com> Date: Wed, 6 Dec 2023 10:18:39 +0100 Subject: [PATCH 1/4] Add toggle button for split tunneling Co-Authored-By: Boban Sijuk <49131853+Boki91@users.noreply.github.com> --- .../compose/cell/SplitTunnelingCell.kt | 18 +- .../compose/screen/SplitTunnelingScreen.kt | 308 ++++++++++++------ .../compose/state/SplitTunnelingUiState.kt | 5 +- .../ui/serviceconnection/SplitTunneling.kt | 14 +- .../viewmodel/SplitTunnelingViewModel.kt | 26 +- .../viewmodel/SplitTunnelingViewModelState.kt | 12 +- .../resource/src/main/res/values/strings.xml | 1 + .../service/endpoint/SplitTunneling.kt | 9 +- 8 files changed, 264 insertions(+), 129 deletions(-) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt index 6ad5675a43c9..25b6f714459b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt @@ -41,8 +41,18 @@ private fun PreviewTunnelingCell() { modifier = Modifier.background(color = MaterialTheme.colorScheme.background).padding(20.dp) ) { - SplitTunnelingCell(title = "Mullvad VPN", packageName = "", isSelected = false) - SplitTunnelingCell(title = "Mullvad VPN", packageName = "", isSelected = true) + SplitTunnelingCell( + title = "Mullvad VPN", + packageName = "", + isSelected = false, + enabled = true + ) + SplitTunnelingCell( + title = "Mullvad VPN", + packageName = "", + isSelected = true, + enabled = true + ) } } } @@ -52,6 +62,7 @@ fun SplitTunnelingCell( title: String, packageName: String?, isSelected: Boolean, + enabled: Boolean, modifier: Modifier = Modifier, backgroundColor: Color = MaterialTheme.colorScheme.primary @@ -110,6 +121,7 @@ fun SplitTunnelingCell( }, onCellClicked = onCellClicked, background = backgroundColor, - modifier = modifier + modifier = modifier, + isRowEnabled = enabled ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt index aae3f8274e5c..12d7a62d7894 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt @@ -2,22 +2,22 @@ package net.mullvad.mullvadvpn.compose.screen import android.graphics.Bitmap import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource @@ -29,9 +29,11 @@ import net.mullvad.mullvadvpn.applist.AppData import net.mullvad.mullvadvpn.compose.cell.HeaderCell import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell import net.mullvad.mullvadvpn.compose.cell.SplitTunnelingCell +import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.component.textResource import net.mullvad.mullvadvpn.compose.constant.CommonContentKey import net.mullvad.mullvadvpn.compose.constant.ContentType import net.mullvad.mullvadvpn.compose.constant.SplitTunnelingContentKey @@ -41,6 +43,8 @@ import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.util.getApplicationIconBitmapOrNull import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import org.koin.androidx.compose.koinViewModel @@ -52,17 +56,18 @@ private fun PreviewSplitTunnelingScreen() { SplitTunnelingScreen( uiState = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = listOf( AppData( packageName = "my.package.a", name = "TitleA", - iconRes = R.drawable.icon_alert, + iconRes = R.drawable.icon_alert ), AppData( packageName = "my.package.b", name = "TitleB", - iconRes = R.drawable.icon_chevron, + iconRes = R.drawable.icon_chevron ) ), includedApps = @@ -88,6 +93,7 @@ fun SplitTunneling(navigator: DestinationsNavigator) { val packageManager = remember(context) { context.packageManager } SplitTunnelingScreen( uiState = state, + onEnableSplitTunneling = viewModel::onEnableSplitTunneling, onShowSystemAppsClick = viewModel::onShowSystemAppsClick, onExcludeAppClick = viewModel::onExcludeAppClick, onIncludeAppClick = viewModel::onIncludeAppClick, @@ -99,14 +105,14 @@ fun SplitTunneling(navigator: DestinationsNavigator) { } @Composable -@OptIn(ExperimentalFoundationApi::class) fun SplitTunnelingScreen( - uiState: SplitTunnelingUiState = SplitTunnelingUiState.Loading, + uiState: SplitTunnelingUiState, + onEnableSplitTunneling: (Boolean) -> Unit = {}, onShowSystemAppsClick: (show: Boolean) -> Unit = {}, onExcludeAppClick: (packageName: String) -> Unit = {}, onIncludeAppClick: (packageName: String) -> Unit = {}, onBackClick: () -> Unit = {}, - onResolveIcon: (String) -> Bitmap? = { null }, + onResolveIcon: (String) -> Bitmap? = { null } ) { val focusManager = LocalFocusManager.current @@ -120,115 +126,201 @@ fun SplitTunnelingScreen( horizontalAlignment = Alignment.CenterHorizontally, state = lazyListState ) { - item(key = CommonContentKey.DESCRIPTION, contentType = ContentType.DESCRIPTION) { - Box(modifier = Modifier.fillMaxWidth()) { - Text( - style = MaterialTheme.typography.labelMedium, - text = stringResource(id = R.string.split_tunneling_description), - modifier = - Modifier.padding( - start = Dimens.mediumPadding, - end = Dimens.mediumPadding, - bottom = Dimens.mediumPadding - ) + enabledToggle( + enabled = uiState.enabled, + onEnableSplitTunneling = onEnableSplitTunneling + ) + description(enabled = uiState.enabled) + spacer() + when (uiState) { + is SplitTunnelingUiState.Loading -> { + loading() + } + is SplitTunnelingUiState.ShowAppList -> { + appList( + uiState = uiState, + focusManager = focusManager, + onShowSystemAppsClick = onShowSystemAppsClick, + onExcludeAppClick = onExcludeAppClick, + onIncludeAppClick = onIncludeAppClick, + onResolveIcon = onResolveIcon ) } } - when (uiState) { - SplitTunnelingUiState.Loading -> { - item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) { - MullvadCircularProgressIndicatorLarge() - } + } + } +} + +private fun LazyListScope.enabledToggle( + enabled: Boolean, + onEnableSplitTunneling: (Boolean) -> Unit +) { + item { + HeaderSwitchComposeCell( + title = textResource(id = R.string.enable), + isToggled = enabled, + onCellClicked = onEnableSplitTunneling + ) + } +} + +private fun LazyListScope.description(enabled: Boolean) { + item(key = CommonContentKey.DESCRIPTION, contentType = ContentType.DESCRIPTION) { + SwitchComposeSubtitleCell( + text = + if (enabled) { + stringResource(id = R.string.split_tunneling_description) + } else { + stringResource(id = R.string.split_tunneling_disabled_description) } - is SplitTunnelingUiState.ShowAppList -> { - if (uiState.excludedApps.isNotEmpty()) { - itemWithDivider( - key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS, - contentType = ContentType.HEADER - ) { - HeaderCell( - modifier = Modifier.animateItemPlacement(), - text = stringResource(id = R.string.exclude_applications), - background = MaterialTheme.colorScheme.primary, - ) - } - itemsIndexedWithDivider( - items = uiState.excludedApps, - key = { _, listItem -> listItem.packageName }, - contentType = { _, _ -> ContentType.ITEM } - ) { index, listItem -> - SplitTunnelingCell( - title = listItem.name, - packageName = listItem.packageName, - isSelected = true, - modifier = Modifier.animateItemPlacement().fillMaxWidth(), - onResolveIcon = onResolveIcon - ) { - // Move focus down unless the clicked item was the last in this - // section. - if (index < uiState.excludedApps.size - 1) { - focusManager.moveFocus(FocusDirection.Down) - } else { - focusManager.moveFocus(FocusDirection.Up) - } + ) + } +} - onIncludeAppClick(listItem.packageName) - } - } - item(key = CommonContentKey.SPACER, contentType = ContentType.SPACER) { - Spacer( - modifier = - Modifier.animateItemPlacement().height(Dimens.mediumPadding) - ) - } - } +private fun LazyListScope.loading() { + item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) { + MullvadCircularProgressIndicatorLarge() + } +} - itemWithDivider( - key = SplitTunnelingContentKey.SHOW_SYSTEM_APPLICATIONS, - contentType = ContentType.OTHER_ITEM - ) { - HeaderSwitchComposeCell( - title = stringResource(id = R.string.show_system_apps), - isToggled = uiState.showSystemApps, - onCellClicked = { newValue -> onShowSystemAppsClick(newValue) }, - modifier = Modifier.animateItemPlacement() - ) - } - itemWithDivider( - key = SplitTunnelingContentKey.INCLUDED_APPLICATIONS, - contentType = ContentType.HEADER - ) { - HeaderCell( - modifier = Modifier.animateItemPlacement(), - text = stringResource(id = R.string.all_applications), - background = MaterialTheme.colorScheme.primary, - ) - } - itemsIndexedWithDivider( - items = uiState.includedApps, - key = { _, listItem -> listItem.packageName }, - contentType = { _, _ -> ContentType.ITEM } - ) { index, listItem -> - SplitTunnelingCell( - title = listItem.name, - packageName = listItem.packageName, - isSelected = false, - modifier = Modifier.animateItemPlacement().fillMaxWidth(), - onResolveIcon = onResolveIcon - ) { - // Move focus down unless the clicked item was the last in this - // section. - if (index < uiState.includedApps.size - 1) { - focusManager.moveFocus(FocusDirection.Down) - } else { - focusManager.moveFocus(FocusDirection.Up) - } +private fun LazyListScope.appList( + uiState: SplitTunnelingUiState.ShowAppList, + focusManager: FocusManager, + onShowSystemAppsClick: (show: Boolean) -> Unit, + onExcludeAppClick: (packageName: String) -> Unit, + onIncludeAppClick: (packageName: String) -> Unit, + onResolveIcon: (String) -> Bitmap? +) { + if (uiState.excludedApps.isNotEmpty()) { + headerItem( + key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS, + textId = R.string.exclude_applications, + enabled = uiState.enabled + ) + appItems( + apps = uiState.excludedApps, + focusManager = focusManager, + onAppClick = onIncludeAppClick, + onResolveIcon = onResolveIcon, + enabled = uiState.enabled, + excluded = true + ) + spacer() + } + systemAppsToggle( + showSystemApps = uiState.showSystemApps, + onShowSystemAppsClick = onShowSystemAppsClick, + enabled = uiState.enabled + ) + headerItem( + key = SplitTunnelingContentKey.INCLUDED_APPLICATIONS, + textId = R.string.all_applications, + enabled = uiState.enabled + ) + appItems( + apps = uiState.includedApps, + focusManager = focusManager, + onAppClick = onExcludeAppClick, + onResolveIcon = onResolveIcon, + enabled = uiState.enabled, + excluded = false + ) +} - onExcludeAppClick(listItem.packageName) +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.appItems( + apps: List, + focusManager: FocusManager, + onAppClick: (String) -> Unit, + onResolveIcon: (String) -> Bitmap?, + enabled: Boolean, + excluded: Boolean +) { + itemsIndexedWithDivider( + items = apps, + key = { _, listItem -> listItem.packageName }, + contentType = { _, _ -> ContentType.ITEM } + ) { index, listItem -> + SplitTunnelingCell( + title = listItem.name, + packageName = listItem.packageName, + isSelected = excluded, + enabled = enabled, + modifier = + Modifier.animateItemPlacement() + .fillMaxWidth() + .alpha( + if (enabled) { + AlphaVisible + } else { + AlphaDisabled } - } - } + ), + onResolveIcon = onResolveIcon + ) { + // Move focus down unless the clicked item was the last in this + // section. + if (index < apps.size - 1) { + focusManager.moveFocus(FocusDirection.Down) + } else { + focusManager.moveFocus(FocusDirection.Up) } + + onAppClick(listItem.packageName) } } } + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.headerItem(key: String, textId: Int, enabled: Boolean) { + itemWithDivider(key = key, contentType = ContentType.HEADER) { + HeaderCell( + modifier = + Modifier.animateItemPlacement() + .alpha( + if (enabled) { + AlphaVisible + } else { + AlphaDisabled + } + ), + text = stringResource(id = textId), + background = MaterialTheme.colorScheme.primary + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.systemAppsToggle( + showSystemApps: Boolean, + onShowSystemAppsClick: (show: Boolean) -> Unit, + enabled: Boolean +) { + itemWithDivider( + key = SplitTunnelingContentKey.SHOW_SYSTEM_APPLICATIONS, + contentType = ContentType.OTHER_ITEM + ) { + HeaderSwitchComposeCell( + title = stringResource(id = R.string.show_system_apps), + isToggled = showSystemApps, + onCellClicked = { newValue -> onShowSystemAppsClick(newValue) }, + isEnabled = enabled, + modifier = + Modifier.animateItemPlacement() + .alpha( + if (enabled) { + AlphaVisible + } else { + AlphaDisabled + } + ) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.spacer() { + item(contentType = ContentType.SPACER) { + Spacer(modifier = Modifier.animateItemPlacement().height(Dimens.mediumPadding)) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt index 77522935163d..24552444e951 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt @@ -3,9 +3,12 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.applist.AppData sealed interface SplitTunnelingUiState { - data object Loading : SplitTunnelingUiState + val enabled: Boolean + + data class Loading(override val enabled: Boolean = false) : SplitTunnelingUiState data class ShowAppList( + override val enabled: Boolean = false, val excludedApps: List = emptyList(), val includedApps: List = emptyList(), val showSystemApps: Boolean = false diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt index 823d222443a2..666d77218401 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt @@ -10,11 +10,12 @@ class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDi private var _excludedApps by observable(emptySet()) { _, _, apps -> excludedAppsChange.invoke(apps) } - var enabled by - observable(false) { _, wasEnabled, isEnabled -> - if (wasEnabled != isEnabled) { - connection.send(Request.SetEnableSplitTunneling(isEnabled).message) - } + var enabled by observable(false) { _, _, isEnabled -> enabledChange.invoke(isEnabled) } + + var enabledChange: (enabled: Boolean) -> Unit = {} + set(value) { + field = value + synchronized(this) { value.invoke(enabled) } } var excludedAppsChange: (apps: Set) -> Unit = {} @@ -41,4 +42,7 @@ class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDi connection.send(Request.IncludeApp(appPackageName).message) fun persist() = connection.send(Request.PersistExcludedApps.message) + + fun enableSplitTunneling(isEnabled: Boolean) = + connection.send(Request.SetEnableSplitTunneling(isEnabled).message) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt index bb543d85cfa4..833117c046a6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt @@ -50,11 +50,13 @@ class SplitTunnelingViewModel( .flatMapLatest { serviceConnection -> combine( serviceConnection.splitTunneling.excludedAppsCallbackFlow(), + serviceConnection.splitTunneling.enabledCallbackFlow(), allApps, - showSystemApps - ) { excludedApps, allApps, showSystemApps -> + showSystemApps, + ) { excludedApps, enabled, allApps, showSystemApps -> SplitTunnelingViewModelState( excludedApps = excludedApps, + enabled = enabled, allApps = allApps, showSystemApps = showSystemApps ) @@ -72,16 +74,11 @@ class SplitTunnelingViewModel( .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - SplitTunnelingUiState.Loading + SplitTunnelingUiState.Loading(enabled = false) ) init { - viewModelScope.launch(dispatcher) { - if (serviceConnectionManager.splitTunneling()?.enabled == false) { - serviceConnectionManager.splitTunneling()?.enabled = true - } - fetchApps() - } + viewModelScope.launch(dispatcher) { fetchApps() } } override fun onCleared() { @@ -89,6 +86,12 @@ class SplitTunnelingViewModel( super.onCleared() } + fun onEnableSplitTunneling(isEnabled: Boolean) { + viewModelScope.launch(dispatcher) { + serviceConnectionManager.splitTunneling()?.enableSplitTunneling(isEnabled) + } + } + fun onIncludeAppClick(packageName: String) { viewModelScope.launch(dispatcher) { serviceConnectionManager.splitTunneling()?.includeApp(packageName) @@ -113,4 +116,9 @@ class SplitTunnelingViewModel( excludedAppsChange = { apps -> trySend(apps) } awaitClose { emptySet() } } + + private fun SplitTunneling.enabledCallbackFlow() = callbackFlow { + enabledChange = { isEnabled -> trySend(isEnabled) } + awaitClose() + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt index 05bc6fb0720a..bc16662f0099 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt @@ -4,15 +4,23 @@ import net.mullvad.mullvadvpn.applist.AppData import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState data class SplitTunnelingViewModelState( + val enabled: Boolean = false, val excludedApps: Set = emptySet(), val allApps: List? = null, val showSystemApps: Boolean = false ) { fun toUiState(): SplitTunnelingUiState { return allApps - ?.partition { appData -> excludedApps.contains(appData.packageName) } + ?.partition { appData -> + if (enabled) { + excludedApps.contains(appData.packageName) + } else { + false + } + } ?.let { (excluded, included) -> SplitTunnelingUiState.ShowAppList( + enabled = enabled, excludedApps = excluded.sortedBy { it.name }, includedApps = if (showSystemApps) { @@ -23,6 +31,6 @@ data class SplitTunnelingViewModelState( .sortedBy { it.name }, showSystemApps = showSystemApps ) - } ?: SplitTunnelingUiState.Loading + } ?: SplitTunnelingUiState.Loading(enabled = enabled) } } diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 836f72cadc2f..1c19dab826e0 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -259,4 +259,5 @@ Connecting... Verifying purchase... Copied logs to clipboard + Split tunneling is disabled. diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt index a683b1e4bf43..4fbe89c82b5a 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt @@ -17,7 +17,14 @@ class SplitTunneling(persistence: SplitTunnelingPersistence, endpoint: ServiceEn } } - val onChange = EventNotifier?>(excludedApps.toList()) + val onChange = + EventNotifier( + if (enabled) { + excludedApps.toList() + } else { + null + } + ) init { onChange.subscribe(this) { excludedApps -> From 54db57a5346c5dc4de78d48b606ee3267cb6f06b Mon Sep 17 00:00:00 2001 From: MaryamShaghaghi <122574719+MaryamShaghaghi@users.noreply.github.com> Date: Wed, 6 Dec 2023 14:59:43 +0100 Subject: [PATCH 2/4] Add test for split tunneling view model and update the other tests Co-Authored-By: Boban Sijuk <49131853+Boki91@users.noreply.github.com> --- .../screen/SplitTunnelingScreenTest.kt | 9 +++- .../viewmodel/SplitTunnelingViewModelTest.kt | 51 +++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt index a4f6d1ab4a38..8b3cdeca2e27 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt @@ -34,7 +34,9 @@ class SplitTunnelingScreenTest { fun testLoadingState() = composeExtension.use { // Arrange - setContentWithTheme { SplitTunnelingScreen(uiState = SplitTunnelingUiState.Loading) } + setContentWithTheme { + SplitTunnelingScreen(uiState = SplitTunnelingUiState.Loading(enabled = true)) + } // Assert onNodeWithText(TITLE).assertExists() @@ -64,6 +66,7 @@ class SplitTunnelingScreenTest { SplitTunnelingScreen( uiState = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = listOf(excludedApp), includedApps = listOf(includedApp), showSystemApps = false @@ -95,6 +98,7 @@ class SplitTunnelingScreenTest { SplitTunnelingScreen( uiState = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = emptyList(), includedApps = listOf(includedApp), showSystemApps = false @@ -133,6 +137,7 @@ class SplitTunnelingScreenTest { SplitTunnelingScreen( uiState = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = listOf(excludedApp), includedApps = listOf(includedApp), showSystemApps = false @@ -169,6 +174,7 @@ class SplitTunnelingScreenTest { SplitTunnelingScreen( uiState = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = listOf(excludedApp), includedApps = listOf(includedApp), showSystemApps = false @@ -205,6 +211,7 @@ class SplitTunnelingScreenTest { SplitTunnelingScreen( uiState = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = listOf(excludedApp), includedApps = listOf(includedApp), showSystemApps = false diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt index 7b2b4cacd5fb..c0d349e1dac1 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt @@ -57,7 +57,7 @@ class SplitTunnelingViewModelTest { initTestSubject(emptyList()) val actualState: SplitTunnelingUiState = testSubject.uiState.value - val initialExpectedState = SplitTunnelingUiState.Loading + val initialExpectedState = SplitTunnelingUiState.Loading(enabled = false) assertEquals(initialExpectedState, actualState) @@ -70,9 +70,14 @@ class SplitTunnelingViewModelTest { { lambda<(Set) -> Unit>().invoke(emptySet()) } + every { mockedSplitTunneling.enabledChange = captureLambda() } answers + { + lambda<(Boolean) -> Unit>().invoke(true) + } initTestSubject(emptyList()) val expectedState = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = emptyList(), includedApps = emptyList(), showSystemApps = false @@ -88,11 +93,16 @@ class SplitTunnelingViewModelTest { { lambda<(Set) -> Unit>().invoke(setOf(appExcluded.packageName)) } + every { mockedSplitTunneling.enabledChange = captureLambda() } answers + { + lambda<(Boolean) -> Unit>().invoke(true) + } initTestSubject(listOf(appExcluded, appNotExcluded)) val expectedState = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = listOf(appExcluded), includedApps = listOf(appNotExcluded), showSystemApps = false @@ -102,7 +112,7 @@ class SplitTunnelingViewModelTest { val actualState = awaitItem() assertEquals(expectedState, actualState) verifyAll { - mockedSplitTunneling.enabled + mockedSplitTunneling.enabledChange = any() mockedSplitTunneling.excludedAppsChange = any() } } @@ -118,17 +128,23 @@ class SplitTunnelingViewModelTest { excludedAppsCallback = lambda() excludedAppsCallback.invoke(setOf(app.packageName)) } + every { mockedSplitTunneling.enabledChange = captureLambda() } answers + { + lambda<(Boolean) -> Unit>().invoke(true) + } initTestSubject(listOf(app)) val expectedStateBeforeAction = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = listOf(app), includedApps = emptyList(), showSystemApps = false ) val expectedStateAfterAction = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = emptyList(), includedApps = listOf(app), showSystemApps = false @@ -141,7 +157,7 @@ class SplitTunnelingViewModelTest { assertEquals(expectedStateAfterAction, awaitItem()) verifyAll { - mockedSplitTunneling.enabled + mockedSplitTunneling.enabledChange = any() mockedSplitTunneling.excludedAppsChange = any() mockedSplitTunneling.includeApp(app.packageName) } @@ -158,11 +174,16 @@ class SplitTunnelingViewModelTest { excludedAppsCallback = lambda() excludedAppsCallback.invoke(emptySet()) } + every { mockedSplitTunneling.enabledChange = captureLambda() } answers + { + lambda<(Boolean) -> Unit>().invoke(true) + } initTestSubject(listOf(app)) val expectedStateBeforeAction = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = emptyList(), includedApps = listOf(app), showSystemApps = false @@ -170,6 +191,7 @@ class SplitTunnelingViewModelTest { val expectedStateAfterAction = SplitTunnelingUiState.ShowAppList( + enabled = true, excludedApps = listOf(app), includedApps = emptyList(), showSystemApps = false @@ -182,13 +204,34 @@ class SplitTunnelingViewModelTest { assertEquals(expectedStateAfterAction, awaitItem()) verifyAll { - mockedSplitTunneling.enabled + mockedSplitTunneling.enabledChange = any() mockedSplitTunneling.excludedAppsChange = any() mockedSplitTunneling.excludeApp(app.packageName) } } } + @Test + fun test_disabled_state() = runTest { + every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers + { + lambda<(Set) -> Unit>().invoke(emptySet()) + } + every { mockedSplitTunneling.enabledChange = captureLambda() } answers + { + lambda<(Boolean) -> Unit>().invoke(false) + } + + initTestSubject(emptyList()) + + val expectedState = SplitTunnelingUiState.ShowAppList(enabled = false) + + testSubject.uiState.test { + val actualState = awaitItem() + assertEquals(expectedState, actualState) + } + } + private fun initTestSubject(appList: List) { every { mockedApplicationsProvider.getAppsList() } returns appList every { mockedServiceConnectionManager.connectionState } returns From f4f15cdbd6cdb57215710b2f9c114437b010b499 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Wed, 7 Feb 2024 15:08:42 +0100 Subject: [PATCH 3/4] Update translations --- gui/locales/messages.pot | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 7839a893ee6d..f92ac6dc44c7 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -2148,6 +2148,9 @@ msgstr "" msgid "Shows reminders when the account time is about to expire" msgstr "" +msgid "Split tunneling is disabled." +msgstr "" + msgid "Submit" msgstr "" From 4a9f3b1f059c638dce7c8d1a8e1cf26d8916c3f1 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 15 Feb 2024 13:18:40 +0100 Subject: [PATCH 4/4] Add changelog entry for split tunneling toggle --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d1c96d57ffb..8c713a744101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Line wrap the file at 100 chars. Th #### Android - Add support for all screen orientations. +- Add toggle for enabling or disabling split tunneling. ### Fixed - Fix connectivity issues that would occur when using quantum-resistant tunnels with an incorrectly