From f73477def98fd2cdbc7f489659ce0ea190e9ba94 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] Add toggle button for split tunneling Co-Authored-By: Boban Sijuk <49131853+Boki91@users.noreply.github.com> --- .../compose/cell/SplitTunnelingCell.kt | 18 +- .../compose/component/Scaffolding.kt | 45 +++ .../mullvadvpn/compose/component/TopBar.kt | 40 +++ .../compose/screen/SplitTunnelingScreen.kt | 302 +++++++++++------- .../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 +- 10 files changed, 343 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/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index 9a35df1ad3aa..a84c849aceca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -148,6 +148,51 @@ fun ScaffoldWithMediumTopBar( ) } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ScaffoldWithMediumTopBar( + appBarTitle: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + switch: @Composable RowScope.() -> Unit = {}, + lazyListState: LazyListState = rememberLazyListState(), + scrollbarColor: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + content: @Composable (modifier: Modifier, lazyListState: LazyListState) -> Unit +) { + val appBarState = rememberTopAppBarState() + val canScroll = lazyListState.canScrollForward || lazyListState.canScrollBackward + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState, canScroll = { canScroll }) + Scaffold( + modifier = modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MullvadMediumTopBarWithSwitch( + title = appBarTitle, + navigationIcon = navigationIcon, + actions, + switch, + scrollBehavior = scrollBehavior + ) + }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) } + ) + }, + content = { + content( + Modifier.fillMaxSize() + .padding(it) + .drawVerticalScrollbar(state = lazyListState, color = scrollbarColor), + lazyListState + ) + } + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ScaffoldWithMediumTopBar( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt index d7b454111693..516ff861caa2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt @@ -237,6 +237,46 @@ fun MullvadMediumTopBar( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MullvadMediumTopBarWithSwitch( + title: String, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + switch: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + MediumTopAppBar( + title = { + Row( + modifier = Modifier.padding(end = Dimens.mediumPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + if (scrollBehavior?.state?.collapsedFraction == 0f) { + switch() + } + } + }, + navigationIcon = navigationIcon, + scrollBehavior = scrollBehavior, + colors = + TopAppBarDefaults.mediumTopAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary.copy(AlphaTopBar), + ), + actions = actions + ) +} + @Preview @Composable private fun PreviewMullvadTopBarWithLongDeviceName() { 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..162e1fc99ae4 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, + onShowSplitTunneling = viewModel::enableSplitTunneling, 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, + onShowSplitTunneling: (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,195 @@ 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, onShowSplitTunneling) + 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, onShowSplitTunneling: (Boolean) -> Unit) { + item { + HeaderSwitchComposeCell( + title = textResource(id = R.string.enable), + isToggled = enabled, + onCellClicked = onShowSplitTunneling + ) + } +} + +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..588db6e05f6e 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 enableSplitTunneling(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 ->