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 ce8507db6403..b7fed25a50b7 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 @@ -158,6 +158,45 @@ fun ScaffoldWithMediumTopBar( ) } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ScaffoldWithLargeTopBarAndToggleButton( + appBarTitle: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + switch: @Composable () -> Unit = {}, + lazyListState: LazyListState = rememberLazyListState(), + scrollbarColor: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar), + 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 = { + MullvadLargeTopBarWithToggleButton( + title = appBarTitle, + navigationIcon = navigationIcon, + switch = switch, + actions = actions, + scrollBehavior = scrollBehavior + ) + }, + 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 babd89271c33..5caefe3abcb4 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 @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Surface @@ -224,6 +225,45 @@ fun MullvadMediumTopBar( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MullvadLargeTopBarWithToggleButton( + title: String, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + switch: @Composable () -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + LargeTopAppBar( + 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, + 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 a1f9bd8a97fd..7685f4e6e749 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 @@ -25,12 +25,14 @@ import net.mullvad.mullvadvpn.compose.cell.BaseCell import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell import net.mullvad.mullvadvpn.compose.cell.SplitTunnelingCell import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge +import net.mullvad.mullvadvpn.compose.component.MullvadSwitch import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton -import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithLargeTopBarAndToggleButton import net.mullvad.mullvadvpn.compose.constant.CommonContentKey import net.mullvad.mullvadvpn.compose.constant.ContentType import net.mullvad.mullvadvpn.compose.constant.SplitTunnelingContentKey import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider +import net.mullvad.mullvadvpn.compose.state.AppListState import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -41,29 +43,32 @@ private fun PreviewSplitTunnelingScreen() { AppTheme { SplitTunnelingScreen( uiState = - SplitTunnelingUiState.ShowAppList( - excludedApps = - listOf( - AppData( - packageName = "my.package.a", - name = "TitleA", - iconRes = R.drawable.icon_alert, - ), - AppData( - packageName = "my.package.b", - name = "TitleB", - iconRes = R.drawable.icon_chevron, - ) - ), - includedApps = - listOf( - AppData( - packageName = "my.package.c", - name = "TitleC", - iconRes = R.drawable.icon_alert - ) - ), - showSystemApps = true + SplitTunnelingUiState( + appListState = + AppListState.ShowAppList( + excludedApps = + listOf( + AppData( + packageName = "my.package.a", + name = "TitleA", + iconRes = R.drawable.icon_alert + ), + AppData( + packageName = "my.package.b", + name = "TitleB", + iconRes = R.drawable.icon_chevron + ) + ), + includedApps = + listOf( + AppData( + packageName = "my.package.c", + name = "TitleC", + iconRes = R.drawable.icon_alert + ) + ), + showSystemApps = true + ) ) ) } @@ -72,18 +77,25 @@ private fun PreviewSplitTunnelingScreen() { @Composable @OptIn(ExperimentalFoundationApi::class) fun SplitTunnelingScreen( - uiState: SplitTunnelingUiState = SplitTunnelingUiState.Loading, + uiState: SplitTunnelingUiState = 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 - ScaffoldWithMediumTopBar( + ScaffoldWithLargeTopBarAndToggleButton( modifier = Modifier.fillMaxSize(), appBarTitle = stringResource(id = R.string.split_tunneling), + switch = { + MullvadSwitch( + checked = uiState.checked, + onCheckedChange = { newValue -> onShowSplitTunneling(newValue) } + ) + }, navigationIcon = { NavigateBackIconButton(onBackClick) } ) { modifier, lazyListState -> LazyColumn( @@ -105,14 +117,14 @@ fun SplitTunnelingScreen( ) } } - when (uiState) { - SplitTunnelingUiState.Loading -> { + when (val appList = uiState.appListState) { + AppListState.Loading -> { item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) { MullvadCircularProgressIndicatorLarge() } } - is SplitTunnelingUiState.ShowAppList -> { - if (uiState.excludedApps.isNotEmpty()) { + is AppListState.ShowAppList -> { + if (appList.excludedApps.isNotEmpty()) { itemWithDivider( key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS, contentType = ContentType.HEADER @@ -126,11 +138,11 @@ fun SplitTunnelingScreen( ) }, bodyView = {}, - background = MaterialTheme.colorScheme.primary, + background = MaterialTheme.colorScheme.primary ) } itemsIndexed( - items = uiState.excludedApps, + items = appList.excludedApps, key = { _, listItem -> listItem.packageName }, contentType = { _, _ -> ContentType.ITEM } ) { index, listItem -> @@ -143,7 +155,7 @@ fun SplitTunnelingScreen( ) { // Move focus down unless the clicked item was the last in this // section. - if (index < uiState.excludedApps.size - 1) { + if (index < appList.excludedApps.size - 1) { focusManager.moveFocus(FocusDirection.Down) } else { focusManager.moveFocus(FocusDirection.Up) @@ -166,7 +178,7 @@ fun SplitTunnelingScreen( ) { HeaderSwitchComposeCell( title = stringResource(id = R.string.show_system_apps), - isToggled = uiState.showSystemApps, + isToggled = appList.showSystemApps, onCellClicked = { newValue -> onShowSystemAppsClick(newValue) }, modifier = Modifier.animateItemPlacement() ) @@ -185,11 +197,11 @@ fun SplitTunnelingScreen( ) }, bodyView = {}, - background = MaterialTheme.colorScheme.primary, + background = MaterialTheme.colorScheme.primary ) } itemsIndexed( - items = uiState.includedApps, + items = appList.includedApps, key = { _, listItem -> listItem.packageName }, contentType = { _, _ -> ContentType.ITEM } ) { index, listItem -> @@ -202,7 +214,7 @@ fun SplitTunnelingScreen( ) { // Move focus down unless the clicked item was the last in this // section. - if (index < uiState.includedApps.size - 1) { + if (index < appList.includedApps.size - 1) { focusManager.moveFocus(FocusDirection.Down) } else { focusManager.moveFocus(FocusDirection.Up) @@ -212,6 +224,7 @@ fun SplitTunnelingScreen( } } } + AppListState.Disabled -> {} } } } 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..5e3c06f31418 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 @@ -2,12 +2,19 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.applist.AppData -sealed interface SplitTunnelingUiState { - data object Loading : SplitTunnelingUiState +data class SplitTunnelingUiState( + val checked: Boolean = false, + val appListState: AppListState = AppListState.Disabled +) + +sealed interface AppListState { + data object Disabled : AppListState + + data object Loading : AppListState data class ShowAppList( val excludedApps: List = emptyList(), val includedApps: List = emptyList(), val showSystemApps: Boolean = false - ) : SplitTunnelingUiState + ) : AppListState } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt index 7004303ae8c1..67c4639277ca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt @@ -29,6 +29,7 @@ class SplitTunnelingFragment : BaseFragment() { val state = viewModel.uiState.collectAsState().value SplitTunnelingScreen( uiState = state, + onShowSplitTunneling = viewModel::onShowAppList, onShowSystemAppsClick = viewModel::onShowSystemAppsClick, onExcludeAppClick = viewModel::onExcludeAppClick, onIncludeAppClick = viewModel::onIncludeAppClick, 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..37f724b7efee 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, + checked = enabled, allApps = allApps, showSystemApps = showSystemApps ) @@ -69,19 +71,10 @@ class SplitTunnelingViewModel( val uiState = vmState .map(SplitTunnelingViewModelState::toUiState) - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(), - SplitTunnelingUiState.Loading - ) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), SplitTunnelingUiState()) init { - viewModelScope.launch(dispatcher) { - if (serviceConnectionManager.splitTunneling()?.enabled == false) { - serviceConnectionManager.splitTunneling()?.enabled = true - } - fetchApps() - } + viewModelScope.launch(dispatcher) { fetchApps() } } override fun onCleared() { @@ -89,6 +82,12 @@ class SplitTunnelingViewModel( super.onCleared() } + fun onShowAppList(show: Boolean) { + viewModelScope.launch(dispatcher) { + serviceConnectionManager.splitTunneling()?.enableSplitTunneling(show) + } + } + fun onIncludeAppClick(packageName: String) { viewModelScope.launch(dispatcher) { serviceConnectionManager.splitTunneling()?.includeApp(packageName) @@ -113,4 +112,9 @@ class SplitTunnelingViewModel( excludedAppsChange = { apps -> trySend(apps) } awaitClose { emptySet() } } + + private fun SplitTunneling.enabledCallbackFlow() = callbackFlow { + enabledChange = { isEnable -> trySend(isEnable) } + awaitClose { false } + } } 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 7e258869fabe..cd5a35ae9e19 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 @@ -1,29 +1,35 @@ package net.mullvad.mullvadvpn.viewmodel import net.mullvad.mullvadvpn.applist.AppData +import net.mullvad.mullvadvpn.compose.state.AppListState import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState data class SplitTunnelingViewModelState( + val checked: 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) } - ?.let { (excluded, included) -> - SplitTunnelingUiState.ShowAppList( - excludedApps = excluded.sortedBy { it.name }, - includedApps = - if (showSystemApps) { - included - } else { - included.filter { appData -> !appData.isSystemApp } - } - .sortedBy { it.name }, - showSystemApps = showSystemApps - ) - } - ?: SplitTunnelingUiState.Loading + return if (checked) { + allApps + ?.partition { appData -> excludedApps.contains(appData.packageName) } + ?.let { (excluded, included) -> + SplitTunnelingUiState( + checked = true, + appListState = + AppListState.ShowAppList( + excludedApps = excluded.sortedBy { it.name }, + includedApps = + if (showSystemApps) included + else included.filter { !it.isSystemApp }.sortedBy { it.name }, + showSystemApps = showSystemApps + ) + ) + } + ?: SplitTunnelingUiState(checked = true, appListState = AppListState.Loading) + } else { + SplitTunnelingUiState(checked = false, appListState = AppListState.Disabled) + } } }