summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-05-08 15:29:00 +0200
committerAlbin <albin@mullvad.net>2023-05-23 16:27:32 +0200
commitaf8ebd0abb958c026d6e6e672ed651289b59b8b9 (patch)
treeec6e6475b36f58dd02a9a12d258d80ebfe5e9d44 /android
parentdfab0c483fbb737a4aa9b59a47d4eba8320c9a4d (diff)
downloadmullvadvpn-af8ebd0abb958c026d6e6e672ed651289b59b8b9.tar.xz
mullvadvpn-af8ebd0abb958c026d6e6e672ed651289b59b8b9.zip
Migrate split tunneling fragment to compose
- Create SplitTunnelingScreen - Improve SplitTunnelingViewModel - Improve List compose component
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt74
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt202
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt116
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt73
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt180
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt28
11 files changed, 480 insertions, 240 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
new file mode 100644
index 0000000000..13e31a9337
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt
@@ -0,0 +1,74 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.ListItem
+import net.mullvad.mullvadvpn.compose.theme.MullvadBlue40
+import net.mullvad.mullvadvpn.compose.theme.MullvadWhite40
+import net.mullvad.mullvadvpn.ui.widget.ApplicationImageView
+
+@Preview
+@Composable
+fun PreviewTunnelingCell() {
+ Column(modifier = Modifier.background(color = MullvadWhite40)) {
+ SplitTunnelingCell("Mullvad VPN", "", false) {}
+ SplitTunnelingCell("Mullvad VPN", "", true) {}
+ }
+}
+
+@Composable
+fun SplitTunnelingCell(
+ title: String,
+ packageName: String?,
+ isSelected: Boolean,
+ onCellClicked: () -> Unit
+) {
+ val startPadding = dimensionResource(id = R.dimen.cell_left_padding)
+ val endPadding = dimensionResource(id = R.dimen.cell_right_padding)
+ val iconSize = dimensionResource(id = R.dimen.icon_size)
+ Box(
+ modifier =
+ Modifier.background(MullvadBlue40)
+ .padding(top = 1.dp, bottom = 1.dp, start = startPadding, end = endPadding)
+ .clickable { onCellClicked() }
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ packageName?.let {
+ AndroidView(
+ factory = { context -> ApplicationImageView(context) },
+ update = { applicationImageView ->
+ applicationImageView.packageName = packageName
+ },
+ modifier = Modifier.size(width = iconSize, height = iconSize)
+ )
+ }
+ ListItem(
+ text = title,
+ isLoading = false,
+ iconResourceId =
+ if (isSelected) {
+ R.drawable.ic_icons_remove
+ } else {
+ R.drawable.ic_icons_add
+ },
+ background = MullvadBlue40,
+ onClick = null
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt
index 23fba8981f..88fb4ccee8 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt
@@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -88,15 +89,17 @@ fun ListItem(
height: Dp = Dimens.listItemHeight,
isLoading: Boolean,
@DrawableRes iconResourceId: Int? = null,
- onClick: () -> Unit
+ background: Color? = null,
+ onClick: (() -> Unit)?
) {
+ val itemColor = background ?: MaterialTheme.colorScheme.primary
Box(
modifier =
Modifier.fillMaxWidth()
.padding(vertical = Dimens.listItemDivider)
.wrapContentHeight()
.defaultMinSize(minHeight = height)
- .background(MaterialTheme.colorScheme.primary),
+ .background(itemColor),
) {
Column(
modifier =
@@ -133,7 +136,9 @@ fun ListItem(
Image(
painter = painterResource(id = iconResourceId),
contentDescription = "Remove",
- modifier = Modifier.align(Alignment.CenterEnd).clickable { onClick() }
+ modifier =
+ onClick?.let { Modifier.align(Alignment.CenterEnd).clickable { onClick() } }
+ ?: Modifier.align(Alignment.CenterEnd)
)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt
index 5fe6a6d509..453ab50cdf 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt
@@ -6,8 +6,10 @@ import androidx.compose.material.Divider
import androidx.compose.runtime.Composable
inline fun LazyListScope.itemWithDivider(
+ contentType: Any? = null,
crossinline itemContent: @Composable LazyItemScope.() -> Unit
-) = item {
- itemContent()
- Divider()
-}
+) =
+ item(contentType = contentType) {
+ itemContent()
+ Divider()
+ }
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
new file mode 100644
index 0000000000..22ecea3c71
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
@@ -0,0 +1,202 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.sp
+import me.onebone.toolbar.ScrollStrategy
+import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.applist.AppData
+import net.mullvad.mullvadvpn.compose.cell.BaseCell
+import net.mullvad.mullvadvpn.compose.cell.SplitTunnelingCell
+import net.mullvad.mullvadvpn.compose.cell.SwitchComposeCell
+import net.mullvad.mullvadvpn.compose.component.CollapsableAwareToolbarScaffold
+import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar
+import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider
+import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
+import net.mullvad.mullvadvpn.compose.theme.MullvadBlue
+import net.mullvad.mullvadvpn.compose.theme.MullvadDarkBlue
+import net.mullvad.mullvadvpn.compose.theme.MullvadWhite
+import net.mullvad.mullvadvpn.compose.theme.MullvadWhite60
+
+@Preview
+@Composable
+fun PreviewSplitTunnelingScreen() {
+ SplitTunnelingScreen(
+ uiState =
+ SplitTunnelingUiState.Data(
+ excludedApps =
+ listOf(
+ AppData(
+ packageName = "Package C",
+ name = "TitleA",
+ iconRes = R.drawable.icon_alert
+ ),
+ AppData(
+ packageName = "Package G",
+ name = "TitleB",
+ iconRes = R.drawable.icon_chevron,
+ )
+ ),
+ includedApps =
+ listOf(
+ AppData(
+ packageName = "Package I",
+ name = "TitleC",
+ iconRes = R.drawable.icon_alert
+ )
+ ),
+ showSystemApps = true
+ )
+ )
+}
+
+@Composable
+fun SplitTunnelingScreen(
+ uiState: SplitTunnelingUiState = SplitTunnelingUiState.Loading,
+ onShowSystemAppsClicked: (show: Boolean) -> Unit = {},
+ addToExcluded: (packageName: String) -> Unit = {},
+ removeFromExcluded: (packageName: String) -> Unit = {},
+ onBackClick: () -> Unit = {},
+) {
+ val state = rememberCollapsingToolbarScaffoldState()
+ val progress = state.toolbarState.progress
+ val mediumPadding = dimensionResource(id = R.dimen.medium_padding)
+ val progressSize = dimensionResource(id = R.dimen.progress_size)
+
+ CollapsableAwareToolbarScaffold(
+ backgroundColor = MullvadDarkBlue,
+ modifier = Modifier.fillMaxSize(),
+ state = state,
+ scrollStrategy = ScrollStrategy.ExitUntilCollapsed,
+ isEnabledWhenCollapsable = true,
+ toolbar = {
+ val scaffoldModifier =
+ Modifier.road(
+ whenCollapsed = Alignment.TopCenter,
+ whenExpanded = Alignment.BottomStart
+ )
+ CollapsingTopBar(
+ backgroundColor = MullvadDarkBlue,
+ onBackClicked = { onBackClick() },
+ title = stringResource(id = R.string.split_tunneling),
+ progress = progress,
+ modifier = scaffoldModifier,
+ backTitle = stringResource(id = R.string.settings_advanced)
+ )
+ },
+ ) {
+ LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) {
+ item(contentType = TYPE_DESCRIPTION) {
+ Text(
+ color = MullvadWhite60,
+ text = stringResource(id = R.string.split_tunneling_description),
+ fontSize = 13.sp,
+ modifier =
+ Modifier.padding(
+ start = mediumPadding,
+ end = mediumPadding,
+ bottom = mediumPadding
+ )
+ )
+ }
+ when (uiState) {
+ SplitTunnelingUiState.Loading -> {
+ item {
+ CircularProgressIndicator(
+ color = MullvadWhite,
+ modifier = Modifier.size(width = progressSize, height = progressSize)
+ )
+ }
+ }
+ is SplitTunnelingUiState.Data -> {
+ if (uiState.excludedApps.isNotEmpty()) {
+ itemWithDivider(contentType = TYPE_TITLE) {
+ BaseCell(
+ title = {
+ Text(
+ text = stringResource(id = R.string.exclude_applications),
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ },
+ bodyView = {},
+ background = MullvadBlue,
+ )
+ }
+ items(
+ items = uiState.excludedApps,
+ key = { listItem -> listItem.packageName },
+ contentType = { TYPE_APPLICATION }
+ ) { listItem ->
+ SplitTunnelingCell(
+ title = listItem.name,
+ packageName = listItem.packageName,
+ isSelected = true
+ ) {
+ removeFromExcluded(listItem.packageName)
+ }
+ }
+ item { Spacer(modifier = Modifier.height(mediumPadding)) }
+ }
+
+ itemWithDivider(contentType = TYPE_SWITCH_CELL) {
+ SwitchComposeCell(
+ title = stringResource(id = R.string.show_system_apps),
+ isToggled = uiState.showSystemApps,
+ onCellClicked = { newValue -> onShowSystemAppsClicked(newValue) }
+ )
+ }
+ itemWithDivider(contentType = TYPE_TITLE) {
+ BaseCell(
+ title = {
+ Text(
+ text = stringResource(id = R.string.all_applications),
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ },
+ bodyView = {},
+ background = MullvadBlue,
+ )
+ }
+ items(
+ items = uiState.includedApps,
+ key = { listItem -> listItem.packageName },
+ contentType = { TYPE_APPLICATION }
+ ) { listItem ->
+ SplitTunnelingCell(
+ title = listItem.name,
+ packageName = listItem.packageName,
+ isSelected = false
+ ) {
+ addToExcluded(listItem.packageName)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+const val TYPE_DESCRIPTION = 1
+const val TYPE_TITLE = 2
+const val TYPE_SWITCH_CELL = 3
+const val TYPE_APPLICATION = 4
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
new file mode 100644
index 0000000000..903bb7afdd
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt
@@ -0,0 +1,12 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.applist.AppData
+
+sealed interface SplitTunnelingUiState {
+ object Loading : SplitTunnelingUiState
+ data class Data(
+ val excludedApps: List<AppData> = emptyList(),
+ val includedApps: List<AppData> = emptyList(),
+ val showSystemApps: Boolean = false
+ ) : SplitTunnelingUiState
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt
index 8ed9346c2b..77e82280bb 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt
@@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color
val MullvadBeige = Color(0xFFFFCD86)
val MullvadBlue = Color(0xFF294D73)
val MullvadBlue60 = Color(0x99294D73)
+val MullvadBlue40 = Color(0x66294D73)
val MullvadBlue20 = Color(0x33294D73)
val MullvadBrown = Color(0xFFD2943B)
val MullvadDarkBlue = Color(0xFF192E45)
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 27db561ee9..cc7eea32f3 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
@@ -1,33 +1,16 @@
package net.mullvad.mullvadvpn.ui.fragment
-import android.os.Build
import android.os.Bundle
-import android.view.KeyCharacterMap
-import android.view.KeyEvent
+import android.view.LayoutInflater
import android.view.View
-import android.view.ViewConfiguration
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.appbar.CollapsingToolbarLayout
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.consumeAsFlow
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.flow.onEach
+import android.view.ViewGroup
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.ComposeView
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.applist.ViewIntent
+import net.mullvad.mullvadvpn.compose.screen.SplitTunnelingScreen
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
import net.mullvad.mullvadvpn.di.APPS_SCOPE
import net.mullvad.mullvadvpn.di.SERVICE_CONNECTION_SCOPE
-import net.mullvad.mullvadvpn.model.ListItemData
-import net.mullvad.mullvadvpn.model.WidgetState.ImageState
-import net.mullvad.mullvadvpn.model.WidgetState.SwitchState
-import net.mullvad.mullvadvpn.ui.ListItemDividerDecoration
-import net.mullvad.mullvadvpn.ui.ListItemListener
-import net.mullvad.mullvadvpn.ui.ListItemsAdapter
-import net.mullvad.mullvadvpn.util.setMargins
import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
import org.koin.android.ext.android.getKoin
import org.koin.androidx.viewmodel.ViewModelOwner
@@ -36,7 +19,6 @@ import org.koin.core.qualifier.named
import org.koin.core.scope.Scope
class SplitTunnelingFragment : BaseFragment(R.layout.collapsed_title_layout) {
- private val listItemsAdapter = ListItemsAdapter()
private val scope: Scope =
getKoin().getOrCreateScope(APPS_SCOPE, named(APPS_SCOPE)).also { appsScope ->
getKoin().getScopeOrNull(SERVICE_CONNECTION_SCOPE)?.let { serviceConnectionScope ->
@@ -45,84 +27,30 @@ class SplitTunnelingFragment : BaseFragment(R.layout.collapsed_title_layout) {
}
private val viewModel by
scope.viewModel<SplitTunnelingViewModel>(owner = { ViewModelOwner.from(this, this) })
- private val toggleSystemAppsVisibility = Channel<Boolean>(Channel.CONFLATED)
- private val toggleExcludeChannel = Channel<ListItemData>(Channel.BUFFERED)
- private val listItemListener =
- object : ListItemListener {
- override fun onItemAction(item: ListItemData) {
- when (item.widget) {
- is ImageState -> toggleExcludeChannel.trySend(item)
- is SwitchState -> toggleSystemAppsVisibility.trySend(!item.widget.isChecked)
- else -> {
- /* NOOP */
- }
- }
- }
- }
- private var recyclerView: RecyclerView? = null
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- view.findViewById<CollapsingToolbarLayout>(R.id.collapsing_toolbar).apply {
- title = resources.getString(R.string.split_tunneling)
- }
- listItemsAdapter.listItemListener = listItemListener
- listItemsAdapter.setHasStableIds(true)
- recyclerView =
- view.findViewById<RecyclerView>(R.id.recyclerView).apply {
- adapter = listItemsAdapter
- addItemDecoration(
- ListItemDividerDecoration(
- topOffset = resources.getDimensionPixelSize(R.dimen.list_item_divider)
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_compose, container, false).apply {
+ findViewById<ComposeView>(R.id.compose_view).setContent {
+ AppTheme {
+ val state = viewModel.uiState.collectAsState().value
+ SplitTunnelingScreen(
+ uiState = state,
+ onShowSystemAppsClicked = viewModel::setShowSystemApps,
+ addToExcluded = viewModel::addToExcluded,
+ removeFromExcluded = viewModel::removeFromExcluded,
+ onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }
)
- )
- tweakMargin(this)
+ }
}
- view.findViewById<View>(R.id.back).setOnClickListener { requireActivity().onBackPressed() }
-
- lifecycleScope.launchWhenStarted {
- viewModel.listItems.onEach { listItemsAdapter.setItems(it) }.catch {}.collect()
- }
- lifecycleScope.launchWhenResumed {
- // pass view intent to view model
- intents().onEach { viewModel.processIntent(it) }.collect()
}
}
override fun onDestroy() {
- listItemsAdapter.listItemListener = null
- recyclerView?.adapter = null
scope.close()
super.onDestroy()
}
-
- private fun intents(): Flow<ViewIntent> =
- merge(
- transitionFinishedFlow.map { ViewIntent.ViewIsReady },
- toggleExcludeChannel.consumeAsFlow().map { ViewIntent.ChangeApplicationGroup(it) },
- toggleSystemAppsVisibility.consumeAsFlow().map { ViewIntent.ShowSystemApps(it) }
- )
-
- private fun tweakMargin(view: View) {
- if (!hasNavigationBar()) {
- view.setMargins(b = 0)
- }
- }
-
- private fun hasNavigationBar(): Boolean {
- // Emulator
- if (Build.FINGERPRINT.contains("generic")) {
- return true
- }
-
- val hasMenuKey = ViewConfiguration.get(requireContext()).hasPermanentMenuKey()
- val hasBackKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK)
- val hasNoCapacitiveKeys = !hasMenuKey && !hasBackKey
-
- val id = resources.getIdentifier("config_showNavigationBar", "bool", "android")
- val hasOnScreenNavBar = id > 0 && resources.getBoolean(id)
-
- return hasOnScreenNavBar || hasNoCapacitiveKeys
- }
}
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 877d847abc..de36ab9689 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
@@ -7,7 +7,8 @@ import net.mullvad.mullvadvpn.ipc.EventDispatcher
import net.mullvad.mullvadvpn.ipc.Request
class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDispatcher) {
- private var excludedApps: Set<String> = emptySet()
+ private var _excludedApps by
+ observable(emptySet<String>()) { _, _, apps -> excludedAppsChange.invoke(apps) }
var enabled by
observable(false) { _, wasEnabled, isEnabled ->
@@ -16,19 +17,23 @@ class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDi
}
}
+ var excludedAppsChange: (apps: Set<String>) -> Unit = {}
+ set(value) {
+ field = value
+ synchronized(this) { value.invoke(_excludedApps) }
+ }
+
init {
eventDispatcher.registerHandler(Event.SplitTunnelingUpdate::class) { event ->
if (event.excludedApps != null) {
enabled = true
- excludedApps = event.excludedApps.toSet()
+ _excludedApps = event.excludedApps.toSet()
} else {
enabled = false
}
}
}
- fun isAppExcluded(appPackageName: String): Boolean = excludedApps.contains(appPackageName)
-
fun excludeApp(appPackageName: String) =
connection.send(Request.ExcludeApp(appPackageName).message)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt
new file mode 100644
index 0000000000..53970b7eef
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt
@@ -0,0 +1,73 @@
+package net.mullvad.mullvadvpn.ui.widget
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.res.ResourcesCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.applist.ApplicationsIconManager
+import net.mullvad.mullvadvpn.di.APPS_SCOPE
+import org.koin.core.scope.KoinScopeComponent
+import org.koin.core.scope.Scope
+import org.koin.core.scope.inject
+
+class ApplicationImageView
+@JvmOverloads
+constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = R.attr.applicationListItemViewStyle,
+) : AppCompatImageView(context, attrs, defStyleAttr), KoinScopeComponent {
+ private val viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private val iconManager: ApplicationsIconManager by inject()
+
+ private var updateImageJob: Job? = null
+
+ override val scope: Scope = getKoin().getScope(APPS_SCOPE)
+
+ var packageName: String = ""
+ set(value) {
+ field = value
+ updateImage()
+ }
+
+ init {
+ updateImage(ResourcesCompat.getDrawable(resources, R.drawable.ic_icons_missing, null)!!)
+ }
+
+ private fun updateImage() {
+ updateImageJob?.cancel()
+ updateImageJob = viewScope.launch { loadImage()?.let { drawable -> updateImage(drawable) } }
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ updateImage()
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ updateImageJob?.cancel()
+ viewScope.coroutineContext.cancelChildren()
+ }
+
+ private suspend fun loadImage(): Drawable? =
+ withContext(Dispatchers.Default) {
+ try {
+ iconManager.getAppIcon(packageName)
+ } catch (e: PackageManager.NameNotFoundException) {
+ null
+ }
+ }
+
+ private fun updateImage(drawable: Drawable) = setImageDrawable(drawable)
+}
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 d2f43a77f6..e2309dbabb 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
@@ -1,176 +1,86 @@
package net.mullvad.mullvadvpn.viewmodel
-import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
-import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.applist.AppData
import net.mullvad.mullvadvpn.applist.ApplicationsProvider
-import net.mullvad.mullvadvpn.applist.ViewIntent
-import net.mullvad.mullvadvpn.model.ListItemData
-import net.mullvad.mullvadvpn.model.WidgetState
+import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling
class SplitTunnelingViewModel(
private val appsProvider: ApplicationsProvider,
private val splitTunneling: SplitTunneling,
- dispatcher: CoroutineDispatcher
+ private val dispatcher: CoroutineDispatcher
) : ViewModel() {
- private val listItemsSink = MutableSharedFlow<List<ListItemData>>(replay = 1)
- // read-only public view
- val listItems: SharedFlow<List<ListItemData>> = listItemsSink.asSharedFlow()
+ private val allApps = MutableStateFlow<List<AppData>?>(null)
+ private val showSystemApps = MutableStateFlow(false)
- private val intentFlow = MutableSharedFlow<ViewIntent>()
- private val isUIReady = CompletableDeferred<Unit>()
- private val excludedApps: MutableMap<String, AppData> = mutableMapOf()
- private val notExcludedApps: MutableMap<String, AppData> = mutableMapOf()
+ private val vmState =
+ combine(splitTunneling.excludedAppsCallbackFlow(), allApps, showSystemApps) {
+ excludedApps,
+ allApps,
+ showSystemApps ->
+ SplitTunnelingViewModelState(
+ excludedApps = excludedApps,
+ allApps = allApps,
+ showSystemApps = showSystemApps
+ )
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ SplitTunnelingViewModelState()
+ )
- private val defaultListItems: List<ListItemData> =
- listOf(
- createTextItem(R.string.split_tunneling_description)
- // We will have search item in future
+ val uiState =
+ vmState
+ .map(SplitTunnelingViewModelState::toUiState)
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ SplitTunnelingUiState.Loading
)
- private var isSystemAppsVisible = false
init {
viewModelScope.launch(dispatcher) {
- listItemsSink.emit(defaultListItems + createDivider(0) + createProgressItem())
- // this will be removed after changes on native to ignore enable parameter
if (!splitTunneling.enabled) splitTunneling.enabled = true
- fetchData()
- }
- viewModelScope.launch(dispatcher) {
- intentFlow
- .shareIn(viewModelScope, SharingStarted.WhileSubscribed())
- .collect(::handleIntents)
+ fetchApps()
}
}
- suspend fun processIntent(intent: ViewIntent) = intentFlow.emit(intent)
-
override fun onCleared() {
splitTunneling.persist()
super.onCleared()
}
- private suspend fun handleIntents(viewIntent: ViewIntent) {
- when (viewIntent) {
- is ViewIntent.ChangeApplicationGroup -> {
- viewIntent.item.action?.let {
- if (excludedApps.containsKey(it.identifier)) {
- removeFromExcluded(it.identifier)
- } else {
- addToExcluded(it.identifier)
- }
- publishList()
- }
- }
- is ViewIntent.ViewIsReady -> isUIReady.complete(Unit)
- is ViewIntent.ShowSystemApps -> {
- isSystemAppsVisible = viewIntent.show
- publishList()
- }
- }
+ fun removeFromExcluded(packageName: String) {
+ viewModelScope.launch(dispatcher) { splitTunneling.includeApp(packageName) }
}
- private fun removeFromExcluded(packageName: String) {
- excludedApps.remove(packageName)?.let { appInfo ->
- notExcludedApps[packageName] = appInfo
- splitTunneling.includeApp(packageName)
- }
+ fun addToExcluded(packageName: String) {
+ viewModelScope.launch(dispatcher) { splitTunneling.excludeApp(packageName) }
}
- private fun addToExcluded(packageName: String) {
- notExcludedApps.remove(packageName)?.let { appInfo ->
- excludedApps[packageName] = appInfo
- splitTunneling.excludeApp(packageName)
- }
+ fun setShowSystemApps(show: Boolean) {
+ viewModelScope.launch(dispatcher) { showSystemApps.emit(show) }
}
- private suspend fun fetchData() {
- appsProvider
- .getAppsList()
- .partition { app -> splitTunneling.isAppExcluded(app.packageName) }
- .let { (excludedAppsList, notExcludedAppsList) ->
- // TODO: remove potential package names from splitTunneling list
- // if they already uninstalled or filtered; but not in ViewModel
- excludedAppsList.map { it.packageName to it }.toMap(excludedApps)
- notExcludedAppsList.map { it.packageName to it }.toMap(notExcludedApps)
- }
- isUIReady.await()
- publishList()
+ private suspend fun fetchApps() {
+ appsProvider.getAppsList().let { appsList -> allApps.emit(appsList) }
}
- private suspend fun publishList() {
- val listItems = ArrayList(defaultListItems)
- if (excludedApps.isNotEmpty()) {
- listItems += createDivider(0)
- listItems += createMainItem(R.string.exclude_applications)
- listItems +=
- excludedApps.values
- .sortedBy { it.name }
- .map { info -> createApplicationItem(info, true) }
- }
- val shownNotExcludedApps =
- notExcludedApps.filter { app -> !app.value.isSystemApp || isSystemAppsVisible }
- if (shownNotExcludedApps.isNotEmpty()) {
- listItems += createDivider(1)
- listItems += createSwitchItem(R.string.show_system_apps, isSystemAppsVisible)
- listItems += createMainItem(R.string.all_applications)
- listItems +=
- shownNotExcludedApps.values
- .sortedBy { it.name }
- .map { info -> createApplicationItem(info, false) }
- }
- listItemsSink.emit(listItems)
+ private fun SplitTunneling.excludedAppsCallbackFlow() = callbackFlow {
+ excludedAppsChange = { apps -> trySend(apps) }
+ awaitClose { emptySet<String>() }
}
-
- private fun createApplicationItem(appData: AppData, checked: Boolean): ListItemData =
- ListItemData.build(appData.packageName) {
- type = ListItemData.APPLICATION
- text = appData.name
- iconRes = appData.iconRes
- action = ListItemData.ItemAction(appData.packageName)
- widget =
- WidgetState.ImageState(
- if (checked) R.drawable.ic_icons_remove else R.drawable.ic_icons_add
- )
- }
-
- private fun createDivider(id: Int): ListItemData =
- ListItemData.build("space_$id") { type = ListItemData.DIVIDER }
-
- private fun createMainItem(@StringRes text: Int): ListItemData =
- ListItemData.build("header_$text") {
- type = ListItemData.ACTION
- textRes = text
- }
-
- private fun createTextItem(@StringRes text: Int): ListItemData =
- ListItemData.build("text_$text") {
- type = ListItemData.PLAIN
- textRes = text
- action = ListItemData.ItemAction(text.toString())
- }
-
- private fun createProgressItem(): ListItemData =
- ListItemData.build(identifier = "progress") { type = ListItemData.PROGRESS }
-
- private fun createSwitchItem(@StringRes text: Int, checked: Boolean): ListItemData =
- ListItemData.build(identifier = "switch_$text") {
- type = ListItemData.ACTION
- textRes = text
- action = ListItemData.ItemAction(text.toString())
- widget = WidgetState.SwitchState(checked)
- }
}
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
new file mode 100644
index 0000000000..8ea7106247
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt
@@ -0,0 +1,28 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import net.mullvad.mullvadvpn.applist.AppData
+import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
+
+data class SplitTunnelingViewModelState(
+ val excludedApps: Set<String> = emptySet(),
+ val allApps: List<AppData>? = null,
+ val showSystemApps: Boolean = false
+) {
+ fun toUiState(): SplitTunnelingUiState {
+ return allApps
+ ?.partition { appData -> excludedApps.contains(appData.packageName) }
+ ?.let { (excluded, included) ->
+ SplitTunnelingUiState.Data(
+ excludedApps = excluded,
+ includedApps =
+ if (showSystemApps) {
+ included
+ } else {
+ included.filter { appData -> !appData.isSystemApp }
+ },
+ showSystemApps = showSystemApps
+ )
+ }
+ ?: SplitTunnelingUiState.Loading
+ }
+}