Skip to content

Commit a5aa6fb

Browse files
Add toggle button for split tunneling
Co-Authored-By: Boban Sijuk <49131853+Boki91@users.noreply.github.com>
1 parent 8e9b221 commit a5aa6fb

File tree

8 files changed

+189
-75
lines changed

8 files changed

+189
-75
lines changed

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

+39
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,45 @@ fun ScaffoldWithMediumTopBar(
158158
)
159159
}
160160

161+
@Composable
162+
@OptIn(ExperimentalMaterial3Api::class)
163+
fun ScaffoldWithLargeTopBarAndToggleButton(
164+
appBarTitle: String,
165+
modifier: Modifier = Modifier,
166+
navigationIcon: @Composable () -> Unit = {},
167+
actions: @Composable RowScope.() -> Unit = {},
168+
switch: @Composable () -> Unit = {},
169+
lazyListState: LazyListState = rememberLazyListState(),
170+
scrollbarColor: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar),
171+
content: @Composable (modifier: Modifier, lazyListState: LazyListState) -> Unit
172+
) {
173+
174+
val appBarState = rememberTopAppBarState()
175+
val canScroll = lazyListState.canScrollForward || lazyListState.canScrollBackward
176+
val scrollBehavior =
177+
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState, canScroll = { canScroll })
178+
Scaffold(
179+
modifier = modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
180+
topBar = {
181+
MullvadLargeTopBarWithToggleButton(
182+
title = appBarTitle,
183+
navigationIcon = navigationIcon,
184+
switch = switch,
185+
actions = actions,
186+
scrollBehavior = scrollBehavior
187+
)
188+
},
189+
content = {
190+
content(
191+
Modifier.fillMaxSize()
192+
.padding(it)
193+
.drawVerticalScrollbar(state = lazyListState, color = scrollbarColor),
194+
lazyListState
195+
)
196+
}
197+
)
198+
}
199+
161200
@OptIn(ExperimentalMaterial3Api::class)
162201
@Composable
163202
fun ScaffoldWithMediumTopBar(

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

+40
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.size
2020
import androidx.compose.material3.ExperimentalMaterial3Api
2121
import androidx.compose.material3.Icon
2222
import androidx.compose.material3.IconButton
23+
import androidx.compose.material3.LargeTopAppBar
2324
import androidx.compose.material3.MaterialTheme
2425
import androidx.compose.material3.MediumTopAppBar
2526
import androidx.compose.material3.Surface
@@ -224,6 +225,45 @@ fun MullvadMediumTopBar(
224225
)
225226
}
226227

228+
@OptIn(ExperimentalMaterial3Api::class)
229+
@Composable
230+
fun MullvadLargeTopBarWithToggleButton(
231+
title: String,
232+
navigationIcon: @Composable () -> Unit = {},
233+
actions: @Composable RowScope.() -> Unit = {},
234+
switch: @Composable () -> Unit = {},
235+
scrollBehavior: TopAppBarScrollBehavior? = null
236+
) {
237+
LargeTopAppBar(
238+
title = {
239+
Row(
240+
modifier = Modifier.padding(end = Dimens.mediumPadding),
241+
horizontalArrangement = Arrangement.SpaceBetween,
242+
verticalAlignment = Alignment.Top
243+
) {
244+
Text(
245+
text = title,
246+
maxLines = 1,
247+
overflow = TextOverflow.Ellipsis,
248+
modifier = Modifier.weight(1f)
249+
)
250+
251+
if (scrollBehavior?.state?.collapsedFraction == 0f) {
252+
switch()
253+
}
254+
}
255+
},
256+
navigationIcon = navigationIcon,
257+
scrollBehavior = scrollBehavior,
258+
colors =
259+
TopAppBarDefaults.mediumTopAppBarColors(
260+
containerColor = MaterialTheme.colorScheme.background,
261+
actionIconContentColor = MaterialTheme.colorScheme.onPrimary.copy(AlphaTopBar)
262+
),
263+
actions = actions
264+
)
265+
}
266+
227267
@Preview
228268
@Composable
229269
private fun PreviewMullvadTopBarWithLongDeviceName() {

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

+51-38
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ import net.mullvad.mullvadvpn.compose.cell.BaseCell
2525
import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell
2626
import net.mullvad.mullvadvpn.compose.cell.SplitTunnelingCell
2727
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
28+
import net.mullvad.mullvadvpn.compose.component.MullvadSwitch
2829
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
29-
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
30+
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithLargeTopBarAndToggleButton
3031
import net.mullvad.mullvadvpn.compose.constant.CommonContentKey
3132
import net.mullvad.mullvadvpn.compose.constant.ContentType
3233
import net.mullvad.mullvadvpn.compose.constant.SplitTunnelingContentKey
3334
import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider
35+
import net.mullvad.mullvadvpn.compose.state.AppListState
3436
import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
3537
import net.mullvad.mullvadvpn.lib.theme.AppTheme
3638
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -41,29 +43,32 @@ private fun PreviewSplitTunnelingScreen() {
4143
AppTheme {
4244
SplitTunnelingScreen(
4345
uiState =
44-
SplitTunnelingUiState.ShowAppList(
45-
excludedApps =
46-
listOf(
47-
AppData(
48-
packageName = "my.package.a",
49-
name = "TitleA",
50-
iconRes = R.drawable.icon_alert,
51-
),
52-
AppData(
53-
packageName = "my.package.b",
54-
name = "TitleB",
55-
iconRes = R.drawable.icon_chevron,
56-
)
57-
),
58-
includedApps =
59-
listOf(
60-
AppData(
61-
packageName = "my.package.c",
62-
name = "TitleC",
63-
iconRes = R.drawable.icon_alert
64-
)
65-
),
66-
showSystemApps = true
46+
SplitTunnelingUiState(
47+
appListState =
48+
AppListState.ShowAppList(
49+
excludedApps =
50+
listOf(
51+
AppData(
52+
packageName = "my.package.a",
53+
name = "TitleA",
54+
iconRes = R.drawable.icon_alert
55+
),
56+
AppData(
57+
packageName = "my.package.b",
58+
name = "TitleB",
59+
iconRes = R.drawable.icon_chevron
60+
)
61+
),
62+
includedApps =
63+
listOf(
64+
AppData(
65+
packageName = "my.package.c",
66+
name = "TitleC",
67+
iconRes = R.drawable.icon_alert
68+
)
69+
),
70+
showSystemApps = true
71+
)
6772
)
6873
)
6974
}
@@ -72,18 +77,25 @@ private fun PreviewSplitTunnelingScreen() {
7277
@Composable
7378
@OptIn(ExperimentalFoundationApi::class)
7479
fun SplitTunnelingScreen(
75-
uiState: SplitTunnelingUiState = SplitTunnelingUiState.Loading,
80+
uiState: SplitTunnelingUiState = SplitTunnelingUiState(),
81+
onShowSplitTunneling: (Boolean) -> Unit = {},
7682
onShowSystemAppsClick: (show: Boolean) -> Unit = {},
7783
onExcludeAppClick: (packageName: String) -> Unit = {},
7884
onIncludeAppClick: (packageName: String) -> Unit = {},
7985
onBackClick: () -> Unit = {},
80-
onResolveIcon: (String) -> Bitmap? = { null },
86+
onResolveIcon: (String) -> Bitmap? = { null }
8187
) {
8288
val focusManager = LocalFocusManager.current
8389

84-
ScaffoldWithMediumTopBar(
90+
ScaffoldWithLargeTopBarAndToggleButton(
8591
modifier = Modifier.fillMaxSize(),
8692
appBarTitle = stringResource(id = R.string.split_tunneling),
93+
switch = {
94+
MullvadSwitch(
95+
checked = uiState.checked,
96+
onCheckedChange = { newValue -> onShowSplitTunneling(newValue) }
97+
)
98+
},
8799
navigationIcon = { NavigateBackIconButton(onBackClick) }
88100
) { modifier, lazyListState ->
89101
LazyColumn(
@@ -105,14 +117,14 @@ fun SplitTunnelingScreen(
105117
)
106118
}
107119
}
108-
when (uiState) {
109-
SplitTunnelingUiState.Loading -> {
120+
when (val appList = uiState.appListState) {
121+
AppListState.Loading -> {
110122
item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) {
111123
MullvadCircularProgressIndicatorLarge()
112124
}
113125
}
114-
is SplitTunnelingUiState.ShowAppList -> {
115-
if (uiState.excludedApps.isNotEmpty()) {
126+
is AppListState.ShowAppList -> {
127+
if (appList.excludedApps.isNotEmpty()) {
116128
itemWithDivider(
117129
key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS,
118130
contentType = ContentType.HEADER
@@ -126,11 +138,11 @@ fun SplitTunnelingScreen(
126138
)
127139
},
128140
bodyView = {},
129-
background = MaterialTheme.colorScheme.primary,
141+
background = MaterialTheme.colorScheme.primary
130142
)
131143
}
132144
itemsIndexed(
133-
items = uiState.excludedApps,
145+
items = appList.excludedApps,
134146
key = { _, listItem -> listItem.packageName },
135147
contentType = { _, _ -> ContentType.ITEM }
136148
) { index, listItem ->
@@ -143,7 +155,7 @@ fun SplitTunnelingScreen(
143155
) {
144156
// Move focus down unless the clicked item was the last in this
145157
// section.
146-
if (index < uiState.excludedApps.size - 1) {
158+
if (index < appList.excludedApps.size - 1) {
147159
focusManager.moveFocus(FocusDirection.Down)
148160
} else {
149161
focusManager.moveFocus(FocusDirection.Up)
@@ -166,7 +178,7 @@ fun SplitTunnelingScreen(
166178
) {
167179
HeaderSwitchComposeCell(
168180
title = stringResource(id = R.string.show_system_apps),
169-
isToggled = uiState.showSystemApps,
181+
isToggled = appList.showSystemApps,
170182
onCellClicked = { newValue -> onShowSystemAppsClick(newValue) },
171183
modifier = Modifier.animateItemPlacement()
172184
)
@@ -185,11 +197,11 @@ fun SplitTunnelingScreen(
185197
)
186198
},
187199
bodyView = {},
188-
background = MaterialTheme.colorScheme.primary,
200+
background = MaterialTheme.colorScheme.primary
189201
)
190202
}
191203
itemsIndexed(
192-
items = uiState.includedApps,
204+
items = appList.includedApps,
193205
key = { _, listItem -> listItem.packageName },
194206
contentType = { _, _ -> ContentType.ITEM }
195207
) { index, listItem ->
@@ -202,7 +214,7 @@ fun SplitTunnelingScreen(
202214
) {
203215
// Move focus down unless the clicked item was the last in this
204216
// section.
205-
if (index < uiState.includedApps.size - 1) {
217+
if (index < appList.includedApps.size - 1) {
206218
focusManager.moveFocus(FocusDirection.Down)
207219
} else {
208220
focusManager.moveFocus(FocusDirection.Up)
@@ -212,6 +224,7 @@ fun SplitTunnelingScreen(
212224
}
213225
}
214226
}
227+
AppListState.Disabled -> {}
215228
}
216229
}
217230
}

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 checked: 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/fragment/SplitTunnelingFragment.kt

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class SplitTunnelingFragment : BaseFragment() {
2929
val state = viewModel.uiState.collectAsState().value
3030
SplitTunnelingScreen(
3131
uiState = state,
32+
onShowSplitTunneling = viewModel::enableSplitTunneling,
3233
onShowSystemAppsClick = viewModel::onShowSystemAppsClick,
3334
onExcludeAppClick = viewModel::onExcludeAppClick,
3435
onIncludeAppClick = viewModel::onIncludeAppClick,

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)