Skip to content

Commit e56928d

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

File tree

8 files changed

+197
-76
lines changed

8 files changed

+197
-76
lines changed

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

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

151+
@Composable
152+
@OptIn(ExperimentalMaterial3Api::class)
153+
fun ScaffoldWithLargeTopBarAndToggleButton(
154+
appBarTitle: String,
155+
modifier: Modifier = Modifier,
156+
navigationIcon: @Composable () -> Unit = {},
157+
actions: @Composable RowScope.() -> Unit = {},
158+
switch: @Composable () -> Unit = {},
159+
lazyListState: LazyListState = rememberLazyListState(),
160+
scrollbarColor: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar),
161+
content: @Composable (modifier: Modifier, lazyListState: LazyListState) -> Unit
162+
) {
163+
val appBarState = rememberTopAppBarState()
164+
val canScroll = lazyListState.canScrollForward || lazyListState.canScrollBackward
165+
val scrollBehavior =
166+
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState, canScroll = { canScroll })
167+
168+
Scaffold(
169+
modifier = modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
170+
topBar = {
171+
MullvadLargeTopBarWithToggleButton(
172+
title = appBarTitle,
173+
navigationIcon = navigationIcon,
174+
switch = switch,
175+
actions = actions,
176+
scrollBehavior = scrollBehavior
177+
)
178+
},
179+
content = {
180+
content(
181+
Modifier.fillMaxSize()
182+
.padding(it)
183+
.drawVerticalScrollbar(state = lazyListState, color = scrollbarColor),
184+
lazyListState
185+
)
186+
}
187+
)
188+
}
189+
151190
@OptIn(ExperimentalMaterial3Api::class)
152191
@Composable
153192
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
@@ -236,6 +237,45 @@ fun MullvadMediumTopBar(
236237
)
237238
}
238239

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

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

+52-38
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,14 @@ import net.mullvad.mullvadvpn.compose.cell.BaseCell
3232
import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell
3333
import net.mullvad.mullvadvpn.compose.cell.SplitTunnelingCell
3434
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
35+
import net.mullvad.mullvadvpn.compose.component.MullvadSwitch
3536
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
36-
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
37+
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithLargeTopBarAndToggleButton
3738
import net.mullvad.mullvadvpn.compose.constant.CommonContentKey
3839
import net.mullvad.mullvadvpn.compose.constant.ContentType
3940
import net.mullvad.mullvadvpn.compose.constant.SplitTunnelingContentKey
4041
import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider
42+
import net.mullvad.mullvadvpn.compose.state.AppListState
4143
import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
4244
import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
4345
import net.mullvad.mullvadvpn.lib.theme.AppTheme
@@ -51,29 +53,32 @@ private fun PreviewSplitTunnelingScreen() {
5153
AppTheme {
5254
SplitTunnelingScreen(
5355
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
56+
SplitTunnelingUiState(
57+
appListState =
58+
AppListState.ShowAppList(
59+
excludedApps =
60+
listOf(
61+
AppData(
62+
packageName = "my.package.a",
63+
name = "TitleA",
64+
iconRes = R.drawable.icon_alert
65+
),
66+
AppData(
67+
packageName = "my.package.b",
68+
name = "TitleB",
69+
iconRes = R.drawable.icon_chevron
70+
)
71+
),
72+
includedApps =
73+
listOf(
74+
AppData(
75+
packageName = "my.package.c",
76+
name = "TitleC",
77+
iconRes = R.drawable.icon_alert
78+
)
79+
),
80+
showSystemApps = true
81+
)
7782
)
7883
)
7984
}
@@ -88,6 +93,7 @@ fun SplitTunneling(navigator: DestinationsNavigator) {
8893
val packageManager = remember(context) { context.packageManager }
8994
SplitTunnelingScreen(
9095
uiState = state,
96+
onShowSplitTunneling = viewModel::enableSplitTunneling,
9197
onShowSystemAppsClick = viewModel::onShowSystemAppsClick,
9298
onExcludeAppClick = viewModel::onExcludeAppClick,
9399
onIncludeAppClick = viewModel::onIncludeAppClick,
@@ -101,18 +107,25 @@ fun SplitTunneling(navigator: DestinationsNavigator) {
101107
@Composable
102108
@OptIn(ExperimentalFoundationApi::class)
103109
fun SplitTunnelingScreen(
104-
uiState: SplitTunnelingUiState = SplitTunnelingUiState.Loading,
110+
uiState: SplitTunnelingUiState = SplitTunnelingUiState(),
111+
onShowSplitTunneling: (Boolean) -> Unit = {},
105112
onShowSystemAppsClick: (show: Boolean) -> Unit = {},
106113
onExcludeAppClick: (packageName: String) -> Unit = {},
107114
onIncludeAppClick: (packageName: String) -> Unit = {},
108115
onBackClick: () -> Unit = {},
109-
onResolveIcon: (String) -> Bitmap? = { null },
116+
onResolveIcon: (String) -> Bitmap? = { null }
110117
) {
111118
val focusManager = LocalFocusManager.current
112119

113-
ScaffoldWithMediumTopBar(
120+
ScaffoldWithLargeTopBarAndToggleButton(
114121
modifier = Modifier.fillMaxSize(),
115122
appBarTitle = stringResource(id = R.string.split_tunneling),
123+
switch = {
124+
MullvadSwitch(
125+
checked = uiState.enabled,
126+
onCheckedChange = { newValue -> onShowSplitTunneling(newValue) }
127+
)
128+
},
116129
navigationIcon = { NavigateBackIconButton(onBackClick) }
117130
) { modifier, lazyListState ->
118131
LazyColumn(
@@ -134,14 +147,14 @@ fun SplitTunnelingScreen(
134147
)
135148
}
136149
}
137-
when (uiState) {
138-
SplitTunnelingUiState.Loading -> {
150+
when (val appList = uiState.appListState) {
151+
AppListState.Loading -> {
139152
item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) {
140153
MullvadCircularProgressIndicatorLarge()
141154
}
142155
}
143-
is SplitTunnelingUiState.ShowAppList -> {
144-
if (uiState.excludedApps.isNotEmpty()) {
156+
is AppListState.ShowAppList -> {
157+
if (appList.excludedApps.isNotEmpty()) {
145158
itemWithDivider(
146159
key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS,
147160
contentType = ContentType.HEADER
@@ -155,11 +168,11 @@ fun SplitTunnelingScreen(
155168
)
156169
},
157170
bodyView = {},
158-
background = MaterialTheme.colorScheme.primary,
171+
background = MaterialTheme.colorScheme.primary
159172
)
160173
}
161174
itemsIndexed(
162-
items = uiState.excludedApps,
175+
items = appList.excludedApps,
163176
key = { _, listItem -> listItem.packageName },
164177
contentType = { _, _ -> ContentType.ITEM }
165178
) { index, listItem ->
@@ -172,7 +185,7 @@ fun SplitTunnelingScreen(
172185
) {
173186
// Move focus down unless the clicked item was the last in this
174187
// section.
175-
if (index < uiState.excludedApps.size - 1) {
188+
if (index < appList.excludedApps.size - 1) {
176189
focusManager.moveFocus(FocusDirection.Down)
177190
} else {
178191
focusManager.moveFocus(FocusDirection.Up)
@@ -195,7 +208,7 @@ fun SplitTunnelingScreen(
195208
) {
196209
HeaderSwitchComposeCell(
197210
title = stringResource(id = R.string.show_system_apps),
198-
isToggled = uiState.showSystemApps,
211+
isToggled = appList.showSystemApps,
199212
onCellClicked = { newValue -> onShowSystemAppsClick(newValue) },
200213
modifier = Modifier.animateItemPlacement()
201214
)
@@ -214,11 +227,11 @@ fun SplitTunnelingScreen(
214227
)
215228
},
216229
bodyView = {},
217-
background = MaterialTheme.colorScheme.primary,
230+
background = MaterialTheme.colorScheme.primary
218231
)
219232
}
220233
itemsIndexed(
221-
items = uiState.includedApps,
234+
items = appList.includedApps,
222235
key = { _, listItem -> listItem.packageName },
223236
contentType = { _, _ -> ContentType.ITEM }
224237
) { index, listItem ->
@@ -231,7 +244,7 @@ fun SplitTunnelingScreen(
231244
) {
232245
// Move focus down unless the clicked item was the last in this
233246
// section.
234-
if (index < uiState.includedApps.size - 1) {
247+
if (index < appList.includedApps.size - 1) {
235248
focusManager.moveFocus(FocusDirection.Down)
236249
} else {
237250
focusManager.moveFocus(FocusDirection.Up)
@@ -241,6 +254,7 @@ fun SplitTunnelingScreen(
241254
}
242255
}
243256
}
257+
AppListState.Disabled -> {}
244258
}
245259
}
246260
}

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)