Skip to content

Commit b8c6835

Browse files
MaryamShaghaghiBoki91
authored andcommitted
Add toggle button for split tunneling
Co-Authored-By: Boban Sijuk <49131853+Boki91@users.noreply.github.com>
1 parent f565a3d commit b8c6835

File tree

8 files changed

+207
-72
lines changed

8 files changed

+207
-72
lines changed

android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt

+45
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,51 @@ fun ScaffoldWithMediumTopBar(
148148
)
149149
}
150150

151+
@Composable
152+
@OptIn(ExperimentalMaterial3Api::class)
153+
fun ScaffoldWithMediumTopBar(
154+
appBarTitle: String,
155+
modifier: Modifier = Modifier,
156+
navigationIcon: @Composable () -> Unit = {},
157+
actions: @Composable RowScope.() -> Unit = {},
158+
switch: @Composable RowScope.() -> Unit = {},
159+
lazyListState: LazyListState = rememberLazyListState(),
160+
scrollbarColor: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar),
161+
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
162+
content: @Composable (modifier: Modifier, lazyListState: LazyListState) -> Unit
163+
) {
164+
val appBarState = rememberTopAppBarState()
165+
val canScroll = lazyListState.canScrollForward || lazyListState.canScrollBackward
166+
val scrollBehavior =
167+
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState, canScroll = { canScroll })
168+
Scaffold(
169+
modifier = modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
170+
topBar = {
171+
MullvadMediumTopBarWithSwitch(
172+
title = appBarTitle,
173+
navigationIcon = navigationIcon,
174+
actions,
175+
switch,
176+
scrollBehavior = scrollBehavior
177+
)
178+
},
179+
snackbarHost = {
180+
SnackbarHost(
181+
snackbarHostState,
182+
snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) }
183+
)
184+
},
185+
content = {
186+
content(
187+
Modifier.fillMaxSize()
188+
.padding(it)
189+
.drawVerticalScrollbar(state = lazyListState, color = scrollbarColor),
190+
lazyListState
191+
)
192+
}
193+
)
194+
}
195+
151196
@OptIn(ExperimentalMaterial3Api::class)
152197
@Composable
153198
fun ScaffoldWithMediumTopBar(

android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt

+39
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,45 @@ fun MullvadMediumTopBar(
237237
)
238238
}
239239

240+
@Composable
241+
fun MullvadMediumTopBarWithSwitch(
242+
title: String,
243+
navigationIcon: @Composable () -> Unit = {},
244+
actions: @Composable RowScope.() -> Unit = {},
245+
switch: @Composable RowScope.() -> Unit = {},
246+
scrollBehavior: TopAppBarScrollBehavior? = null
247+
) {
248+
MediumTopAppBar(
249+
title = {
250+
Row(
251+
modifier = Modifier.padding(end = Dimens.mediumPadding),
252+
horizontalArrangement = Arrangement.SpaceBetween,
253+
verticalAlignment = Alignment.Top
254+
) {
255+
Text(
256+
text = title,
257+
maxLines = 1,
258+
overflow = TextOverflow.Ellipsis,
259+
modifier = Modifier.weight(1f)
260+
)
261+
262+
if (scrollBehavior?.state?.collapsedFraction == 0f) {
263+
switch()
264+
}
265+
}
266+
},
267+
navigationIcon = navigationIcon,
268+
scrollBehavior = scrollBehavior,
269+
colors =
270+
TopAppBarDefaults.mediumTopAppBarColors(
271+
containerColor = MaterialTheme.colorScheme.background,
272+
scrolledContainerColor = MaterialTheme.colorScheme.background,
273+
actionIconContentColor = MaterialTheme.colorScheme.onPrimary.copy(AlphaTopBar),
274+
),
275+
actions = actions
276+
)
277+
}
278+
240279
@Preview
241280
@Composable
242281
private fun PreviewMullvadTopBarWithLongDeviceName() {

android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt

+54-35
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
99
import androidx.compose.foundation.layout.height
1010
import androidx.compose.foundation.layout.padding
1111
import androidx.compose.foundation.lazy.LazyColumn
12+
import androidx.compose.material3.ExperimentalMaterial3Api
13+
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
1214
import androidx.compose.material3.MaterialTheme
1315
import androidx.compose.material3.Text
1416
import androidx.compose.runtime.Composable
17+
import androidx.compose.runtime.CompositionLocalProvider
1518
import androidx.compose.runtime.collectAsState
1619
import androidx.compose.runtime.getValue
1720
import androidx.compose.runtime.remember
@@ -30,13 +33,15 @@ import net.mullvad.mullvadvpn.compose.cell.HeaderCell
3033
import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell
3134
import net.mullvad.mullvadvpn.compose.cell.SplitTunnelingCell
3235
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
36+
import net.mullvad.mullvadvpn.compose.component.MullvadSwitch
3337
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
3438
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
3539
import net.mullvad.mullvadvpn.compose.constant.CommonContentKey
3640
import net.mullvad.mullvadvpn.compose.constant.ContentType
3741
import net.mullvad.mullvadvpn.compose.constant.SplitTunnelingContentKey
3842
import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider
3943
import net.mullvad.mullvadvpn.compose.extensions.itemsIndexedWithDivider
44+
import net.mullvad.mullvadvpn.compose.state.AppListState
4045
import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
4146
import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
4247
import net.mullvad.mullvadvpn.lib.theme.AppTheme
@@ -51,29 +56,32 @@ private fun PreviewSplitTunnelingScreen() {
5156
AppTheme {
5257
SplitTunnelingScreen(
5358
uiState =
54-
SplitTunnelingUiState.ShowAppList(
55-
excludedApps =
56-
listOf(
57-
AppData(
58-
packageName = "my.package.a",
59-
name = "TitleA",
60-
iconRes = R.drawable.icon_alert,
61-
),
62-
AppData(
63-
packageName = "my.package.b",
64-
name = "TitleB",
65-
iconRes = R.drawable.icon_chevron,
66-
)
67-
),
68-
includedApps =
69-
listOf(
70-
AppData(
71-
packageName = "my.package.c",
72-
name = "TitleC",
73-
iconRes = R.drawable.icon_alert
74-
)
75-
),
76-
showSystemApps = true
59+
SplitTunnelingUiState(
60+
appListState =
61+
AppListState.ShowAppList(
62+
excludedApps =
63+
listOf(
64+
AppData(
65+
packageName = "my.package.a",
66+
name = "TitleA",
67+
iconRes = R.drawable.icon_alert
68+
),
69+
AppData(
70+
packageName = "my.package.b",
71+
name = "TitleB",
72+
iconRes = R.drawable.icon_chevron
73+
)
74+
),
75+
includedApps =
76+
listOf(
77+
AppData(
78+
packageName = "my.package.c",
79+
name = "TitleC",
80+
iconRes = R.drawable.icon_alert
81+
)
82+
),
83+
showSystemApps = true
84+
)
7785
)
7886
)
7987
}
@@ -88,6 +96,7 @@ fun SplitTunneling(navigator: DestinationsNavigator) {
8896
val packageManager = remember(context) { context.packageManager }
8997
SplitTunnelingScreen(
9098
uiState = state,
99+
onShowSplitTunneling = viewModel::enableSplitTunneling,
91100
onShowSystemAppsClick = viewModel::onShowSystemAppsClick,
92101
onExcludeAppClick = viewModel::onExcludeAppClick,
93102
onIncludeAppClick = viewModel::onIncludeAppClick,
@@ -99,20 +108,29 @@ fun SplitTunneling(navigator: DestinationsNavigator) {
99108
}
100109

101110
@Composable
102-
@OptIn(ExperimentalFoundationApi::class)
111+
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
103112
fun SplitTunnelingScreen(
104-
uiState: SplitTunnelingUiState = SplitTunnelingUiState.Loading,
113+
uiState: SplitTunnelingUiState = SplitTunnelingUiState(),
114+
onShowSplitTunneling: (Boolean) -> Unit = {},
105115
onShowSystemAppsClick: (show: Boolean) -> Unit = {},
106116
onExcludeAppClick: (packageName: String) -> Unit = {},
107117
onIncludeAppClick: (packageName: String) -> Unit = {},
108118
onBackClick: () -> Unit = {},
109-
onResolveIcon: (String) -> Bitmap? = { null },
119+
onResolveIcon: (String) -> Bitmap? = { null }
110120
) {
111121
val focusManager = LocalFocusManager.current
112122

113123
ScaffoldWithMediumTopBar(
114124
modifier = Modifier.fillMaxSize(),
115125
appBarTitle = stringResource(id = R.string.split_tunneling),
126+
switch = {
127+
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
128+
MullvadSwitch(
129+
checked = uiState.enabled,
130+
onCheckedChange = { newValue -> onShowSplitTunneling(newValue) }
131+
)
132+
}
133+
},
116134
navigationIcon = { NavigateBackIconButton(onBackClick) }
117135
) { modifier, lazyListState ->
118136
LazyColumn(
@@ -134,14 +152,14 @@ fun SplitTunnelingScreen(
134152
)
135153
}
136154
}
137-
when (uiState) {
138-
SplitTunnelingUiState.Loading -> {
155+
when (val appList = uiState.appListState) {
156+
AppListState.Loading -> {
139157
item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) {
140158
MullvadCircularProgressIndicatorLarge()
141159
}
142160
}
143-
is SplitTunnelingUiState.ShowAppList -> {
144-
if (uiState.excludedApps.isNotEmpty()) {
161+
is AppListState.ShowAppList -> {
162+
if (appList.excludedApps.isNotEmpty()) {
145163
itemWithDivider(
146164
key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS,
147165
contentType = ContentType.HEADER
@@ -153,7 +171,7 @@ fun SplitTunnelingScreen(
153171
)
154172
}
155173
itemsIndexedWithDivider(
156-
items = uiState.excludedApps,
174+
items = appList.excludedApps,
157175
key = { _, listItem -> listItem.packageName },
158176
contentType = { _, _ -> ContentType.ITEM }
159177
) { index, listItem ->
@@ -166,7 +184,7 @@ fun SplitTunnelingScreen(
166184
) {
167185
// Move focus down unless the clicked item was the last in this
168186
// section.
169-
if (index < uiState.excludedApps.size - 1) {
187+
if (index < appList.excludedApps.size - 1) {
170188
focusManager.moveFocus(FocusDirection.Down)
171189
} else {
172190
focusManager.moveFocus(FocusDirection.Up)
@@ -189,7 +207,7 @@ fun SplitTunnelingScreen(
189207
) {
190208
HeaderSwitchComposeCell(
191209
title = stringResource(id = R.string.show_system_apps),
192-
isToggled = uiState.showSystemApps,
210+
isToggled = appList.showSystemApps,
193211
onCellClicked = { newValue -> onShowSystemAppsClick(newValue) },
194212
modifier = Modifier.animateItemPlacement()
195213
)
@@ -205,7 +223,7 @@ fun SplitTunnelingScreen(
205223
)
206224
}
207225
itemsIndexedWithDivider(
208-
items = uiState.includedApps,
226+
items = appList.includedApps,
209227
key = { _, listItem -> listItem.packageName },
210228
contentType = { _, _ -> ContentType.ITEM }
211229
) { index, listItem ->
@@ -218,7 +236,7 @@ fun SplitTunnelingScreen(
218236
) {
219237
// Move focus down unless the clicked item was the last in this
220238
// section.
221-
if (index < uiState.includedApps.size - 1) {
239+
if (index < appList.includedApps.size - 1) {
222240
focusManager.moveFocus(FocusDirection.Down)
223241
} else {
224242
focusManager.moveFocus(FocusDirection.Up)
@@ -228,6 +246,7 @@ fun SplitTunnelingScreen(
228246
}
229247
}
230248
}
249+
AppListState.Disabled -> {}
231250
}
232251
}
233252
}

android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt

+10-3
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@ package net.mullvad.mullvadvpn.compose.state
22

33
import net.mullvad.mullvadvpn.applist.AppData
44

5-
sealed interface SplitTunnelingUiState {
6-
data object Loading : SplitTunnelingUiState
5+
data class SplitTunnelingUiState(
6+
val enabled: Boolean = false,
7+
val appListState: AppListState = AppListState.Disabled
8+
)
9+
10+
sealed interface AppListState {
11+
data object Disabled : AppListState
12+
13+
data object Loading : AppListState
714

815
data class ShowAppList(
916
val excludedApps: List<AppData> = emptyList(),
1017
val includedApps: List<AppData> = emptyList(),
1118
val showSystemApps: Boolean = false
12-
) : SplitTunnelingUiState
19+
) : AppListState
1320
}

android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt

+9-5
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDi
1010
private var _excludedApps by
1111
observable(emptySet<String>()) { _, _, apps -> excludedAppsChange.invoke(apps) }
1212

13-
var enabled by
14-
observable(false) { _, wasEnabled, isEnabled ->
15-
if (wasEnabled != isEnabled) {
16-
connection.send(Request.SetEnableSplitTunneling(isEnabled).message)
17-
}
13+
var enabled by observable(false) { _, _, isEnabled -> enabledChange.invoke(isEnabled) }
14+
15+
var enabledChange: (enabled: Boolean) -> Unit = {}
16+
set(value) {
17+
field = value
18+
synchronized(this) { value.invoke(enabled) }
1819
}
1920

2021
var excludedAppsChange: (apps: Set<String>) -> Unit = {}
@@ -41,4 +42,7 @@ class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDi
4142
connection.send(Request.IncludeApp(appPackageName).message)
4243

4344
fun persist() = connection.send(Request.PersistExcludedApps.message)
45+
46+
fun enableSplitTunneling(isEnabled: Boolean) =
47+
connection.send(Request.SetEnableSplitTunneling(isEnabled).message)
4448
}

0 commit comments

Comments
 (0)