summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md1
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt211
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragmentTest.kt164
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt92
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt95
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/ContentType.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/SplitTunnelingContentKey.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt217
-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/compose/theme/Theme.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt117
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt140
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionContainer.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt3
-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.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ApplicationListItemView.kt)33
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt208
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt28
-rw-r--r--android/app/src/main/res/layout/app_list_item.xml39
-rw-r--r--android/app/src/main/res/layout/collapsed_title_layout.xml65
-rw-r--r--android/app/src/main/res/values/dimensions.xml1
-rw-r--r--android/app/src/main/res/values/styles.xml15
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/di/UiModuleTest.kt45
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt253
29 files changed, 910 insertions, 934 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa1f943ee3..7439b386cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,6 +46,7 @@ Line wrap the file at 100 chars. Th
- Rename "Advanced settings" to "VPN Settings".
- Move the "Split tunneling" menu item up a level from "VPN settings" to "Settings".
- Remove purchasing information from release builds. This does not affect F-Droid builds.
+- Migrate split tunneling view to compose.
### Fixed
#### Android
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt
new file mode 100644
index 0000000000..dc011abd05
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt
@@ -0,0 +1,211 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import io.mockk.verify
+import net.mullvad.mullvadvpn.applist.AppData
+import net.mullvad.mullvadvpn.applist.ApplicationsIconManager
+import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.koin.core.context.loadKoinModules
+import org.koin.core.context.unloadKoinModules
+import org.koin.dsl.module
+
+class SplitTunnelingScreenTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ private val testModule = module {
+ single {
+ mockk<ApplicationsIconManager>().apply {
+ every { getAppIcon(any()) } returns mockk(relaxed = true)
+ }
+ }
+ }
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ loadKoinModules(testModule)
+ }
+
+ @After
+ fun tearDown() {
+ unloadKoinModules(testModule)
+ unmockkAll()
+ }
+
+ @Test
+ fun testLoadingState() {
+ // Arrange
+ composeTestRule.setContent { SplitTunnelingScreen(uiState = SplitTunnelingUiState.Loading) }
+
+ // Assert
+ composeTestRule.apply {
+ onNodeWithText(TITLE).assertExists()
+ onNodeWithText(DESCRIPTION).assertExists()
+ onNodeWithText(EXCLUDED_APPLICATIONS).assertDoesNotExist()
+ onNodeWithText(SHOW_SYSTEM_APPS).assertDoesNotExist()
+ onNodeWithText(ALL_APPLICATIONS).assertDoesNotExist()
+ }
+ }
+
+ @Test
+ fun testListDisplayed() {
+ // Arrange
+ val excludedApp =
+ AppData(packageName = EXCLUDED_APP_PACKAGE_NAME, iconRes = 0, name = EXCLUDED_APP_NAME)
+ val includedApp =
+ AppData(packageName = INCLUDED_APP_PACKAGE_NAME, iconRes = 0, name = INCLUDED_APP_NAME)
+ composeTestRule.setContent {
+ SplitTunnelingScreen(
+ uiState =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = listOf(excludedApp),
+ includedApps = listOf(includedApp),
+ showSystemApps = false
+ )
+ )
+ }
+
+ // Assert
+ composeTestRule.apply {
+ onNodeWithText(TITLE).assertExists()
+ onNodeWithText(DESCRIPTION).assertExists()
+ onNodeWithText(EXCLUDED_APPLICATIONS).assertExists()
+ onNodeWithText(EXCLUDED_APP_NAME).assertExists()
+ onNodeWithText(SHOW_SYSTEM_APPS).assertExists()
+ onNodeWithText(ALL_APPLICATIONS).assertExists()
+ onNodeWithText(INCLUDED_APP_NAME).assertExists()
+ }
+ }
+
+ @Test
+ fun testNoExcludedApps() {
+ // Arrange
+ val includedApp =
+ AppData(packageName = INCLUDED_APP_PACKAGE_NAME, iconRes = 0, name = INCLUDED_APP_NAME)
+ composeTestRule.setContent {
+ SplitTunnelingScreen(
+ uiState =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = emptyList(),
+ includedApps = listOf(includedApp),
+ showSystemApps = false
+ )
+ )
+ }
+
+ // Assert
+ composeTestRule.apply {
+ onNodeWithText(TITLE).assertExists()
+ onNodeWithText(DESCRIPTION).assertExists()
+ onNodeWithText(EXCLUDED_APPLICATIONS).assertDoesNotExist()
+ onNodeWithText(EXCLUDED_APP_NAME).assertDoesNotExist()
+ onNodeWithText(SHOW_SYSTEM_APPS).assertExists()
+ onNodeWithText(ALL_APPLICATIONS).assertExists()
+ onNodeWithText(INCLUDED_APP_NAME).assertExists()
+ }
+ }
+
+ @Test
+ fun testClickIncludedItem() {
+ // Arrange
+ val excludedApp =
+ AppData(packageName = EXCLUDED_APP_PACKAGE_NAME, iconRes = 0, name = EXCLUDED_APP_NAME)
+ val includedApp =
+ AppData(packageName = INCLUDED_APP_PACKAGE_NAME, iconRes = 0, name = INCLUDED_APP_NAME)
+ val mockedClickHandler: (String) -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ SplitTunnelingScreen(
+ uiState =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = listOf(excludedApp),
+ includedApps = listOf(includedApp),
+ showSystemApps = false
+ ),
+ onExcludeAppClick = mockedClickHandler
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText(INCLUDED_APP_NAME).performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke(INCLUDED_APP_PACKAGE_NAME) }
+ }
+
+ @Test
+ fun testClickExcludedItem() {
+ // Arrange
+ val excludedApp =
+ AppData(packageName = EXCLUDED_APP_PACKAGE_NAME, iconRes = 0, name = EXCLUDED_APP_NAME)
+ val includedApp =
+ AppData(packageName = INCLUDED_APP_PACKAGE_NAME, iconRes = 0, name = INCLUDED_APP_NAME)
+ val mockedClickHandler: (String) -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ SplitTunnelingScreen(
+ uiState =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = listOf(excludedApp),
+ includedApps = listOf(includedApp),
+ showSystemApps = false
+ ),
+ onIncludeAppClick = mockedClickHandler
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText(EXCLUDED_APP_NAME).performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke(EXCLUDED_APP_PACKAGE_NAME) }
+ }
+
+ @Test
+ fun testClickShowSystemApps() {
+ // Arrange
+ val excludedApp =
+ AppData(packageName = EXCLUDED_APP_PACKAGE_NAME, iconRes = 0, name = EXCLUDED_APP_NAME)
+ val includedApp =
+ AppData(packageName = INCLUDED_APP_PACKAGE_NAME, iconRes = 0, name = INCLUDED_APP_NAME)
+ val mockedClickHandler: (Boolean) -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ SplitTunnelingScreen(
+ uiState =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = listOf(excludedApp),
+ includedApps = listOf(includedApp),
+ showSystemApps = false
+ ),
+ onShowSystemAppsClick = mockedClickHandler
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText(SHOW_SYSTEM_APPS).performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke(true) }
+ }
+
+ companion object {
+ private const val EXCLUDED_APP_PACKAGE_NAME = "excluded-pkg"
+ private const val EXCLUDED_APP_NAME = "Excluded Name"
+ private const val INCLUDED_APP_PACKAGE_NAME = "included-pkg"
+ private const val INCLUDED_APP_NAME = "Included Name"
+ private const val TITLE = "Split tunneling"
+ private const val DESCRIPTION =
+ "Split tunneling makes it possible to select which applications should not be routed through the VPN tunnel."
+ private const val EXCLUDED_APPLICATIONS = "Excluded applications"
+ private const val SHOW_SYSTEM_APPS = "Show system apps"
+ private const val ALL_APPLICATIONS = "All applications"
+ }
+}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragmentTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragmentTest.kt
deleted file mode 100644
index f0b6d0a24d..0000000000
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragmentTest.kt
+++ /dev/null
@@ -1,164 +0,0 @@
-package net.mullvad.mullvadvpn.ui.fragment
-
-import androidx.fragment.app.testing.launchFragmentInContainer
-import androidx.lifecycle.Lifecycle
-import androidx.test.espresso.Espresso.onView
-import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.assertion.ViewAssertions.matches
-import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
-import androidx.test.espresso.matcher.ViewMatchers.withId
-import androidx.test.espresso.matcher.ViewMatchers.withText
-import androidx.test.filters.LargeTest
-import androidx.test.runner.AndroidJUnit4
-import io.mockk.Runs
-import io.mockk.coEvery
-import io.mockk.coVerifyAll
-import io.mockk.every
-import io.mockk.just
-import io.mockk.mockk
-import io.mockk.mockkClass
-import io.mockk.unmockkAll
-import io.mockk.verifyAll
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.runBlocking
-import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.RecyclerViewMatcher.Companion.withRecyclerView
-import net.mullvad.mullvadvpn.applist.ApplicationsIconManager
-import net.mullvad.mullvadvpn.applist.ViewIntent
-import net.mullvad.mullvadvpn.di.APPS_SCOPE
-import net.mullvad.mullvadvpn.model.ListItemData
-import net.mullvad.mullvadvpn.model.WidgetState
-import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
-import org.junit.After
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.koin.core.context.loadKoinModules
-import org.koin.core.context.unloadKoinModules
-import org.koin.core.qualifier.named
-import org.koin.core.scope.Scope
-import org.koin.dsl.module
-import org.koin.test.KoinTest
-import org.koin.test.mock.MockProviderRule
-import org.koin.test.mock.declareMock
-
-@RunWith(AndroidJUnit4::class)
-@LargeTest
-class SplitTunnelingFragmentTest : KoinTest {
-
- private val mockedViewModel = mockk<SplitTunnelingViewModel>(relaxUnitFun = true)
- private val sharedFlow = MutableSharedFlow<List<ListItemData>>()
- private lateinit var scope: Scope
-
- private val testModule = module {
- scope(named(APPS_SCOPE)) {
- scoped {
- mockk<ApplicationsIconManager>().apply {
- every { getAppIcon(any()) } returns mockk(relaxed = true)
- }
- }
- }
- }
-
- @get:Rule
- val mockProvider =
- MockProviderRule.create { clazz ->
- when (clazz) {
- SplitTunnelingViewModel::class -> mockedViewModel
- else -> mockkClass(clazz)
- }
- }
-
- @Before
- fun setUp() {
- loadKoinModules(testModule)
- scope = getKoin().getOrCreateScope(APPS_SCOPE, named(APPS_SCOPE))
- scope.declareMock<SplitTunnelingViewModel>()
- every { mockedViewModel.listItems } returns sharedFlow
- coEvery { mockedViewModel.processIntent(ViewIntent.ViewIsReady) } just Runs
- }
-
- @After
- fun tearDown() {
- scope.close()
- unloadKoinModules(testModule)
- unmockkAll()
- }
-
- @Test
- fun test_fragment_title() {
- launchFragmentInContainer<SplitTunnelingFragment>(themeResId = R.style.AppTheme)
-
- onView(withId(R.id.collapsing_toolbar))
- .check(matches(withContentDescription("Split tunneling")))
- }
-
- @Test
- fun test_fragment_loading() {
- val scenario =
- launchFragmentInContainer<SplitTunnelingFragment>(
- themeResId = R.style.AppTheme,
- initialState = Lifecycle.State.CREATED
- )
- scenario.moveToState(Lifecycle.State.RESUMED)
- sharedFlow.tryEmit(emptyList())
-
- verifyAll { mockedViewModel.listItems }
- }
-
- @Test
- fun test_fragment_list_displayed() = runBlocking {
- launchFragmentInContainer<SplitTunnelingFragment>(
- themeResId = R.style.AppTheme,
- initialState = Lifecycle.State.RESUMED
- )
-
- sharedFlow.emit(
- listOf(
- ListItemData.build("testItem") {
- type = ListItemData.PLAIN
- text = "Test Item"
- action = ListItemData.ItemAction(text.toString())
- }
- )
- )
-
- onView(withRecyclerView(R.id.recyclerView).atPositionOnView(0, R.id.plain_text))
- .check(matches(withText("Test Item")))
-
- verifyAll { mockedViewModel.listItems }
- }
-
- @Test
- fun test_fragment_list_click_application_item() = runBlocking {
- val testListItem =
- ListItemData.build("test.package.name") {
- type = ListItemData.APPLICATION
- text = "Test App Name"
- action = ListItemData.ItemAction("test.package.name")
- widget = WidgetState.ImageState(R.drawable.ic_icons_add)
- }
-
- coEvery {
- mockedViewModel.processIntent(ViewIntent.ChangeApplicationGroup(testListItem))
- } just Runs
-
- launchFragmentInContainer<SplitTunnelingFragment>(
- themeResId = R.style.AppTheme,
- initialState = Lifecycle.State.RESUMED
- )
-
- sharedFlow.emit(listOf(testListItem))
-
- onView(withRecyclerView(R.id.recyclerView).atPositionOnView(0, R.id.itemText))
- .check(matches(withText("Test App Name")))
-
- onView(withRecyclerView(R.id.recyclerView).atPositionOnView(0)).perform(click())
-
- coVerifyAll {
- mockedViewModel.listItems
- mockedViewModel.processIntent(ViewIntent.ChangeApplicationGroup(testListItem))
- }
- }
-}
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..06e0197268
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt
@@ -0,0 +1,92 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.viewinterop.AndroidView
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
+import net.mullvad.mullvadvpn.compose.theme.Dimens
+import net.mullvad.mullvadvpn.compose.theme.typeface.listItemText
+import net.mullvad.mullvadvpn.ui.widget.ApplicationImageView
+
+@Preview
+@Composable
+fun PreviewTunnelingCell() {
+ AppTheme {
+ Column(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ SplitTunnelingCell("Mullvad VPN", "", false)
+ SplitTunnelingCell("Mullvad VPN", "", true)
+ }
+ }
+}
+
+@Composable
+fun SplitTunnelingCell(
+ title: String,
+ packageName: String?,
+ isSelected: Boolean,
+ modifier: Modifier = Modifier,
+ onCellClicked: () -> Unit = {}
+) {
+ Row(
+ modifier =
+ modifier
+ .wrapContentHeight()
+ .defaultMinSize(minHeight = Dimens.listItemHeightExtra)
+ .fillMaxWidth()
+ .padding(vertical = Dimens.listItemDivider)
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ .clickable(onClick = onCellClicked)
+ ) {
+ AndroidView(
+ factory = { context -> ApplicationImageView(context) },
+ update = { applicationImageView ->
+ applicationImageView.packageName = packageName ?: ""
+ },
+ modifier =
+ Modifier.padding(start = Dimens.cellLeftPadding)
+ .align(Alignment.CenterVertically)
+ .size(width = Dimens.listIconSize, height = Dimens.listIconSize)
+ )
+ Text(
+ text = title,
+ style = MaterialTheme.typography.listItemText,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier =
+ Modifier.weight(1f)
+ .padding(horizontal = Dimens.mediumPadding, vertical = Dimens.smallPadding)
+ .align(Alignment.CenterVertically)
+ )
+ Image(
+ painter =
+ painterResource(
+ id =
+ if (isSelected) {
+ R.drawable.ic_icons_remove
+ } else {
+ R.drawable.ic_icons_add
+ }
+ ),
+ contentDescription = null,
+ modifier =
+ Modifier.padding(end = Dimens.cellRightPadding)
+ .align(Alignment.CenterVertically)
+ .padding(horizontal = Dimens.loadingSpinnerPadding)
+ )
+ }
+}
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..f31e9052dd 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,11 +19,13 @@ 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
import androidx.constraintlayout.compose.ConstraintLayout
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
import net.mullvad.mullvadvpn.compose.theme.Dimens
import net.mullvad.mullvadvpn.compose.theme.typeface.listItemSubText
import net.mullvad.mullvadvpn.compose.theme.typeface.listItemText
@@ -31,47 +33,49 @@ import net.mullvad.mullvadvpn.compose.theme.typeface.listItemText
@Preview
@Composable
fun PreviewListItem() {
- Column {
- ListItem(text = "No subtext No icon not loading", isLoading = false, onClick = {})
- ListItem(text = "No subtext No icon is loading", isLoading = true, onClick = {})
- ListItem(
- text = "No subtext With icon is loading",
- isLoading = true,
- iconResourceId = R.drawable.icon_close,
- onClick = {}
- )
- ListItem(
- text = "No subtext With icon not loading",
- isLoading = false,
- iconResourceId = R.drawable.icon_close,
- onClick = {}
- )
- ListItem(
- text = "With subtext with icon is loading",
- subText = "Subtext",
- isLoading = true,
- iconResourceId = R.drawable.icon_close,
- onClick = {}
- )
- ListItem(
- text = "With subtext no icon is loading",
- subText = "Subtext",
- isLoading = true,
- onClick = {}
- )
- ListItem(
- text = "With subtext with icon not loading",
- subText = "Subtext",
- isLoading = false,
- iconResourceId = R.drawable.icon_close,
- onClick = {}
- )
- ListItem(
- text = "With subtext no icon not loading",
- subText = "Subtext",
- isLoading = false,
- onClick = {}
- )
+ AppTheme {
+ Column {
+ ListItem(text = "No subtext No icon not loading", isLoading = false, onClick = {})
+ ListItem(text = "No subtext No icon is loading", isLoading = true, onClick = {})
+ ListItem(
+ text = "No subtext With icon is loading",
+ isLoading = true,
+ iconResourceId = R.drawable.icon_close,
+ onClick = {}
+ )
+ ListItem(
+ text = "No subtext With icon not loading",
+ isLoading = false,
+ iconResourceId = R.drawable.icon_close,
+ onClick = {}
+ )
+ ListItem(
+ text = "With subtext with icon is loading",
+ subText = "Subtext",
+ isLoading = true,
+ iconResourceId = R.drawable.icon_close,
+ onClick = {}
+ )
+ ListItem(
+ text = "With subtext no icon is loading",
+ subText = "Subtext",
+ isLoading = true,
+ onClick = {}
+ )
+ ListItem(
+ text = "With subtext with icon not loading",
+ subText = "Subtext",
+ isLoading = false,
+ iconResourceId = R.drawable.icon_close,
+ onClick = {}
+ )
+ ListItem(
+ text = "With subtext no icon not loading",
+ subText = "Subtext",
+ isLoading = false,
+ onClick = {}
+ )
+ }
}
}
@@ -88,7 +92,8 @@ fun ListItem(
height: Dp = Dimens.listItemHeight,
isLoading: Boolean,
@DrawableRes iconResourceId: Int? = null,
- onClick: () -> Unit
+ background: Color = MaterialTheme.colorScheme.primary,
+ onClick: (() -> Unit)?
) {
Box(
modifier =
@@ -96,7 +101,7 @@ fun ListItem(
.padding(vertical = Dimens.listItemDivider)
.wrapContentHeight()
.defaultMinSize(minHeight = height)
- .background(MaterialTheme.colorScheme.primary),
+ .background(background)
) {
Column(
modifier =
@@ -133,7 +138,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/constant/CommonContentKey.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt
new file mode 100644
index 0000000000..41dbcadaa1
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.compose.constant
+
+object CommonContentKey {
+ const val DESCRIPTION = "description"
+ const val SPACER = "spacer"
+ const val PROGRESS = "progress"
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/ContentType.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/ContentType.kt
new file mode 100644
index 0000000000..df55278d07
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/ContentType.kt
@@ -0,0 +1,11 @@
+package net.mullvad.mullvadvpn.compose.constant
+
+// Content types, to improve the ability to reuse views
+object ContentType {
+ const val HEADER = 1
+ const val ITEM = 2
+ const val OTHER_ITEM = 3
+ const val DESCRIPTION = 4
+ const val SPACER = 5
+ const val PROGRESS = 6
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/SplitTunnelingContentKey.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/SplitTunnelingContentKey.kt
new file mode 100644
index 0000000000..28a123410f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/SplitTunnelingContentKey.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.compose.constant
+
+object SplitTunnelingContentKey {
+ const val EXCLUDED_APPLICATIONS = "excluded"
+ const val SHOW_SYSTEM_APPLICATIONS = "show_system"
+ const val INCLUDED_APPLICATIONS = "included"
+}
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..383b745746 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,11 @@ import androidx.compose.material.Divider
import androidx.compose.runtime.Composable
inline fun LazyListScope.itemWithDivider(
+ key: Any? = null,
+ contentType: Any? = null,
crossinline itemContent: @Composable LazyItemScope.() -> Unit
-) = item {
- itemContent()
- Divider()
-}
+) =
+ item(key = key, 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..a46e5dad35
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt
@@ -0,0 +1,217 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+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.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+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.component.drawVerticalScrollbar
+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.SplitTunnelingUiState
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
+import net.mullvad.mullvadvpn.compose.theme.Dimens
+
+@Preview
+@Composable
+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
+ )
+ )
+ }
+}
+
+@Composable
+@OptIn(ExperimentalFoundationApi::class)
+fun SplitTunnelingScreen(
+ uiState: SplitTunnelingUiState = SplitTunnelingUiState.Loading,
+ onShowSystemAppsClick: (show: Boolean) -> Unit = {},
+ onExcludeAppClick: (packageName: String) -> Unit = {},
+ onIncludeAppClick: (packageName: String) -> Unit = {},
+ onBackClick: () -> Unit = {}
+) {
+ val state = rememberCollapsingToolbarScaffoldState()
+ val progress = state.toolbarState.progress
+ val lazyListState = rememberLazyListState()
+
+ CollapsableAwareToolbarScaffold(
+ backgroundColor = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxSize(),
+ state = state,
+ scrollStrategy = ScrollStrategy.ExitUntilCollapsed,
+ isEnabledWhenCollapsable = true,
+ toolbar = {
+ val scaffoldModifier =
+ Modifier.road(
+ whenCollapsed = Alignment.TopCenter,
+ whenExpanded = Alignment.BottomStart
+ )
+ CollapsingTopBar(
+ backgroundColor = MaterialTheme.colorScheme.background,
+ onBackClicked = { onBackClick() },
+ title = stringResource(id = R.string.split_tunneling),
+ progress = progress,
+ modifier = scaffoldModifier,
+ backTitle = stringResource(id = R.string.settings)
+ )
+ },
+ ) {
+ LazyColumn(
+ modifier = Modifier.drawVerticalScrollbar(state = lazyListState),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ state = lazyListState
+ ) {
+ item(key = CommonContentKey.DESCRIPTION, contentType = ContentType.DESCRIPTION) {
+ Text(
+ style = MaterialTheme.typography.labelMedium,
+ text = stringResource(id = R.string.split_tunneling_description),
+ modifier =
+ Modifier.padding(
+ start = Dimens.mediumPadding,
+ end = Dimens.mediumPadding,
+ bottom = Dimens.mediumPadding
+ )
+ )
+ }
+ when (uiState) {
+ SplitTunnelingUiState.Loading -> {
+ item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier =
+ Modifier.size(
+ width = Dimens.progressIndicatorSize,
+ height = Dimens.progressIndicatorSize
+ )
+ )
+ }
+ }
+ is SplitTunnelingUiState.ShowAppList -> {
+ if (uiState.excludedApps.isNotEmpty()) {
+ itemWithDivider(
+ key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS,
+ contentType = ContentType.HEADER
+ ) {
+ BaseCell(
+ title = {
+ Text(
+ text = stringResource(id = R.string.exclude_applications),
+ style = MaterialTheme.typography.titleMedium
+ )
+ },
+ bodyView = {},
+ background = MaterialTheme.colorScheme.primary,
+ )
+ }
+ items(
+ items = uiState.excludedApps,
+ key = { listItem -> listItem.packageName },
+ contentType = { ContentType.ITEM }
+ ) { listItem ->
+ SplitTunnelingCell(
+ title = listItem.name,
+ packageName = listItem.packageName,
+ isSelected = true,
+ modifier = Modifier.animateItemPlacement().fillMaxWidth()
+ ) {
+ onIncludeAppClick(listItem.packageName)
+ }
+ }
+ item(key = CommonContentKey.SPACER, contentType = ContentType.SPACER) {
+ Spacer(modifier = Modifier.height(Dimens.mediumPadding))
+ }
+ }
+
+ itemWithDivider(
+ key = SplitTunnelingContentKey.SHOW_SYSTEM_APPLICATIONS,
+ contentType = ContentType.OTHER_ITEM
+ ) {
+ SwitchComposeCell(
+ title = stringResource(id = R.string.show_system_apps),
+ isToggled = uiState.showSystemApps,
+ onCellClicked = { newValue -> onShowSystemAppsClick(newValue) }
+ )
+ }
+ itemWithDivider(
+ key = SplitTunnelingContentKey.INCLUDED_APPLICATIONS,
+ contentType = ContentType.HEADER
+ ) {
+ BaseCell(
+ title = {
+ Text(
+ text = stringResource(id = R.string.all_applications),
+ style = MaterialTheme.typography.titleMedium
+ )
+ },
+ bodyView = {},
+ background = MaterialTheme.colorScheme.primary,
+ )
+ }
+ items(
+ items = uiState.includedApps,
+ key = { listItem -> listItem.packageName },
+ contentType = { ContentType.ITEM }
+ ) { listItem ->
+ SplitTunnelingCell(
+ title = listItem.name,
+ packageName = listItem.packageName,
+ isSelected = false,
+ modifier = Modifier.animateItemPlacement().fillMaxWidth()
+ ) {
+ onExcludeAppClick(listItem.packageName)
+ }
+ }
+ }
+ }
+ }
+ }
+}
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..d63afac400
--- /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 ShowAppList(
+ 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/compose/theme/Theme.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt
index 815cfbf88b..8749c7cb55 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt
@@ -25,7 +25,19 @@ private val MullvadTypography =
fontSize = TypeScale.TextBig,
fontWeight = FontWeight.Bold
),
- bodySmall = TextStyle(color = MullvadWhite, fontSize = TypeScale.TextSmall)
+ bodySmall = TextStyle(color = MullvadWhite, fontSize = TypeScale.TextSmall),
+ titleMedium =
+ TextStyle(
+ color = MullvadWhite,
+ fontSize = TypeScale.TextMediumPlus,
+ fontWeight = FontWeight.SemiBold
+ ),
+ labelMedium =
+ TextStyle(
+ color = MullvadWhite60,
+ fontSize = TypeScale.TextSmall,
+ fontWeight = FontWeight.SemiBold
+ )
)
private val MullvadColorPalette =
@@ -33,6 +45,9 @@ private val MullvadColorPalette =
primary = MullvadBlue,
secondary = MullvadDarkBlue,
tertiary = MullvadRed,
+ background = MullvadDarkBlue,
+ onBackground = MullvadWhite,
+ primaryContainer = MullvadBlue40,
onSurfaceVariant = MullvadWhite,
onPrimary = MullvadWhite
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
index 04904ac410..57c9f173a9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
@@ -11,7 +11,11 @@ data class Dimensions(
val listItemHeightExtra: Dp = 60.dp,
val loadingSpinnerSize: Dp = 24.dp,
val loadingSpinnerStrokeWidth: Dp = 3.dp,
- val loadingSpinnerPadding: Dp = 12.dp
+ val loadingSpinnerPadding: Dp = 12.dp,
+ val cellLeftPadding: Dp = 22.dp,
+ val cellRightPadding: Dp = 16.dp,
+ val listIconSize: Dp = 24.dp,
+ val progressIndicatorSize: Dp = 60.dp
)
val defaultDimensions = Dimensions()
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
index 26703d98ec..f0fcc4fa65 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
@@ -45,16 +45,12 @@ val uiModule = module {
single<PackageManager> { androidContext().packageManager }
single<String>(named(SELF_PACKAGE_NAME)) { androidContext().packageName }
- scope(named(APPS_SCOPE)) {
- viewModel { SplitTunnelingViewModel(get(), get(), Dispatchers.Default) }
- scoped { ApplicationsIconManager(get()) } onClose { it?.dispose() }
- scoped { ApplicationsProvider(get(), get(named(SELF_PACKAGE_NAME))) }
- }
+ viewModel { SplitTunnelingViewModel(get(), get(), Dispatchers.Default) }
+ single { ApplicationsIconManager(get()) } onClose { it?.dispose() }
+ single { ApplicationsProvider(get(), get(named(SELF_PACKAGE_NAME))) }
- scope(named(SERVICE_CONNECTION_SCOPE)) {
- scoped<SplitTunneling> { (messenger: Messenger, dispatcher: EventDispatcher) ->
- SplitTunneling(messenger, dispatcher)
- }
+ single { (messenger: Messenger, dispatcher: EventDispatcher) ->
+ SplitTunneling(messenger, dispatcher)
}
single { ServiceConnectionManager(androidContext()) }
@@ -97,7 +93,5 @@ val uiModule = module {
}
}
-const val APPS_SCOPE = "APPS_SCOPE"
-const val SERVICE_CONNECTION_SCOPE = "SERVICE_CONNECTION_SCOPE"
const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME"
const val APP_PREFERENCES_NAME = "net.mullvad.mullvadvpn.app_preferences"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt
deleted file mode 100644
index 4676a3532a..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package net.mullvad.mullvadvpn.ui
-
-import android.view.ViewGroup
-import androidx.recyclerview.widget.AsyncDifferConfig
-import androidx.recyclerview.widget.AsyncListDiffer
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListUpdateCallback
-import androidx.recyclerview.widget.RecyclerView
-import java.util.concurrent.atomic.AtomicLong
-import net.mullvad.mullvadvpn.model.ListItemData
-import net.mullvad.mullvadvpn.ui.listitemview.ActionListItemView
-import net.mullvad.mullvadvpn.ui.listitemview.ApplicationListItemView
-import net.mullvad.mullvadvpn.ui.listitemview.DividerGroupListItemView
-import net.mullvad.mullvadvpn.ui.listitemview.ListItemView
-import net.mullvad.mullvadvpn.ui.listitemview.PlainListItemView
-import net.mullvad.mullvadvpn.ui.listitemview.ProgressListItemView
-import net.mullvad.mullvadvpn.ui.listitemview.TwoActionListItemView
-
-class ListItemsAdapter : RecyclerView.Adapter<ListItemsAdapter.ViewHolder>() {
-
- var listItemListener: ListItemListener? = null
-
- protected var diffCallback: DiffCallback = DefaultDiffCallback()
-
- private val listDiffer: AsyncListDiffer<ListItemData> = createDiffer(diffCallback)
-
- fun setItems(items: List<ListItemData?>) = listDiffer.submitList(items)
-
- override fun onCreateViewHolder(
- parent: ViewGroup,
- @ListItemData.ItemType viewType: Int
- ): ListItemsAdapter.ViewHolder {
- return ViewHolder(
- when (viewType) {
- ListItemData.DIVIDER -> DividerGroupListItemView(parent.context)
- ListItemData.PROGRESS -> ProgressListItemView(parent.context)
- ListItemData.PLAIN -> PlainListItemView(parent.context)
- ListItemData.ACTION -> ActionListItemView(parent.context)
- ListItemData.APPLICATION -> ApplicationListItemView(parent.context)
- ListItemData.DOUBLE_ACTION -> TwoActionListItemView(parent.context)
- else -> throw IllegalArgumentException("View type '$viewType' is not supported")
- }
- )
- }
-
- override fun onBindViewHolder(holder: ViewHolder, position: Int) {
- (holder.itemView as ListItemView).update(getItem(position))
- (holder.itemView as ListItemView).listItemListener = listItemListener
- }
-
- override fun onViewRecycled(holder: ViewHolder) {
- super.onViewRecycled(holder)
- (holder.itemView as ListItemView).listItemListener = null
- }
-
- override fun getItemCount(): Int = listDiffer.currentList.size
-
- @ListItemData.ItemType override fun getItemViewType(position: Int): Int = getItem(position).type
-
- override fun getItemId(position: Int): Long = getId(getItem(position).identifier)
-
- private fun getItem(position: Int): ListItemData = listDiffer.currentList[position]
-
- private fun createDiffer(diffCallback: DiffCallback): AsyncListDiffer<ListItemData> {
- return AsyncListDiffer(getListUpdateCallback(), getConfig(diffCallback))
- }
-
- private fun getConfig(diffUtil: DiffCallback): AsyncDifferConfig<ListItemData> {
- return AsyncDifferConfig.Builder(diffUtil).build()
- }
-
- protected fun getListUpdateCallback(): ListUpdateCallback {
- return object : ListUpdateCallback {
- override fun onInserted(position: Int, count: Int) {
- notifyItemRangeInserted(position, count)
- }
-
- override fun onRemoved(position: Int, count: Int) {
- notifyItemRangeRemoved(position, count)
- }
-
- override fun onMoved(fromPosition: Int, toPosition: Int) {
- notifyItemMoved(fromPosition, toPosition)
- }
-
- override fun onChanged(position: Int, count: Int, payload: Any?) {
- notifyItemRangeChanged(position, count, payload)
- }
- }
- }
-
- internal class DefaultDiffCallback : DiffCallback() {
- override fun areItemsTheSame(oldItem: ListItemData, newItem: ListItemData): Boolean {
- return oldItem.type == newItem.type && oldItem.identifier == newItem.identifier
- }
-
- override fun areContentsTheSame(oldItem: ListItemData, newItem: ListItemData): Boolean {
- return oldItem == newItem
- }
-
- override fun getChangePayload(oldItem: ListItemData, newItem: ListItemData): Any {
- return Any()
- }
- }
-
- inner class ViewHolder(view: ListItemView) : RecyclerView.ViewHolder(view)
-
- companion object StableIdProvider {
- private val idCounter = AtomicLong(0)
- private val mapIds = hashMapOf<String, Long>()
-
- internal fun getId(stringId: String): Long =
- mapIds.computeIfAbsent(stringId) { idCounter.decrementAndGet() }
- }
-}
-
-typealias DiffCallback = DiffUtil.ItemCallback<ListItemData>
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..59328dc273 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,128 +1,38 @@
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.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.compose.screen.SplitTunnelingScreen
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
-import org.koin.android.ext.android.getKoin
-import org.koin.androidx.viewmodel.ViewModelOwner
-import org.koin.androidx.viewmodel.scope.viewModel
-import org.koin.core.qualifier.named
-import org.koin.core.scope.Scope
+import org.koin.androidx.viewmodel.ext.android.viewModel
-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 ->
- appsScope.linkTo(serviceConnectionScope)
- }
- }
- 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
+class SplitTunnelingFragment : BaseFragment() {
+ private val viewModel: SplitTunnelingViewModel by viewModel()
- 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,
+ onShowSystemAppsClick = viewModel::onShowSystemAppsClick,
+ onExcludeAppClick = viewModel::onExcludeAppClick,
+ onIncludeAppClick = viewModel::onIncludeAppClick,
+ 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/ServiceConnectionContainer.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionContainer.kt
index a58db46ff7..7f895618fb 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionContainer.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionContainer.kt
@@ -4,15 +4,12 @@ import android.os.Looper
import android.os.Messenger
import android.os.RemoteException
import android.util.Log
-import net.mullvad.mullvadvpn.di.SERVICE_CONNECTION_SCOPE
import net.mullvad.mullvadvpn.ipc.DispatchingHandler
import net.mullvad.mullvadvpn.ipc.Event
import net.mullvad.mullvadvpn.ipc.Request
import org.koin.core.component.KoinApiExtension
-import org.koin.core.parameter.parametersOf
-import org.koin.core.qualifier.named
-import org.koin.core.scope.KoinScopeComponent
-import org.koin.core.scope.get
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
// Container of classes that communicate with the service through an active connection
//
@@ -23,13 +20,10 @@ class ServiceConnectionContainer(
val connection: Messenger,
onServiceReady: (ServiceConnectionContainer) -> Unit,
onVpnPermissionRequest: () -> Unit
-) : KoinScopeComponent {
+) : KoinComponent {
private val dispatcher =
DispatchingHandler(Looper.getMainLooper()) { message -> Event.fromMessage(message) }
- override val scope =
- getKoin().getOrCreateScope(SERVICE_CONNECTION_SCOPE, named(SERVICE_CONNECTION_SCOPE), this)
-
val accountDataSource = ServiceConnectionAccountDataSource(connection, dispatcher)
val authTokenCache = AuthTokenCache(connection, dispatcher)
val connectionProxy = ConnectionProxy(connection, dispatcher)
@@ -37,8 +31,7 @@ class ServiceConnectionContainer(
val locationInfoCache = LocationInfoCache(dispatcher)
val settingsListener = SettingsListener(connection, dispatcher)
- // NOTE: `org.koin.core.scope.get` must be used here rather than `org.koin.core.component.get`.
- val splitTunneling = get<SplitTunneling>(parameters = { parametersOf(connection, dispatcher) })
+ val splitTunneling = SplitTunneling(connection, dispatcher)
val voucherRedeemer = VoucherRedeemer(connection, dispatcher)
val vpnPermission = VpnPermission(connection, dispatcher)
@@ -61,7 +54,6 @@ class ServiceConnectionContainer(
fun onDestroy() {
unregisterListener()
- closeScope()
dispatcher.onDestroy()
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt
index 1de160ffe8..f7be833792 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt
@@ -22,3 +22,6 @@ fun ServiceConnectionManager.relayListListener() =
fun ServiceConnectionManager.settingsListener() =
this.connectionState.value.readyContainer()?.settingsListener
+
+fun ServiceConnectionManager.splitTunneling() =
+ this.connectionState.value.readyContainer()?.splitTunneling
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/listitemview/ApplicationListItemView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt
index 138e9bfb88..519fefb180 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ApplicationListItemView.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt
@@ -1,11 +1,11 @@
-package net.mullvad.mullvadvpn.ui.listitemview
+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 androidx.core.view.isVisible
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -15,43 +15,38 @@ 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.component.KoinApiExtension
-import org.koin.core.scope.KoinScopeComponent
-import org.koin.core.scope.Scope
-import org.koin.core.scope.inject
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
@OptIn(KoinApiExtension::class)
-class ApplicationListItemView
+class ApplicationImageView
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.applicationListItemViewStyle,
- defStyleRes: Int = 0
-) : ActionListItemView(context, attrs, defStyleAttr, defStyleRes), KoinScopeComponent {
+) : AppCompatImageView(context, attrs, defStyleAttr), KoinComponent {
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 {
- itemText.setTextAppearance(R.style.TextAppearance_Mullvad_Title2)
updateImage(ResourcesCompat.getDrawable(resources, R.drawable.ic_icons_missing, null)!!)
}
- override fun updateImage() {
- itemIcon.isVisible = true
+ private fun updateImage() {
updateImageJob?.cancel()
updateImageJob = viewScope.launch { loadImage()?.let { drawable -> updateImage(drawable) } }
}
- override fun updateText() {
- itemData.text?.let { itemText.text = it }
- }
-
override fun onAttachedToWindow() {
super.onAttachedToWindow()
updateImage()
@@ -66,11 +61,11 @@ constructor(
private suspend fun loadImage(): Drawable? =
withContext(Dispatchers.Default) {
try {
- iconManager.getAppIcon(itemData.identifier)
+ iconManager.getAppIcon(packageName)
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
- private fun updateImage(drawable: Drawable) = itemIcon.setImageDrawable(drawable)
+ 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..bb543d85cf 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,116 @@
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.channels.awaitClose
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
+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.ServiceConnectionContainer
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling
+import net.mullvad.mullvadvpn.ui.serviceconnection.splitTunneling
class SplitTunnelingViewModel(
private val appsProvider: ApplicationsProvider,
- private val splitTunneling: SplitTunneling,
- dispatcher: CoroutineDispatcher
+ private val serviceConnectionManager: ServiceConnectionManager,
+ 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 _shared: SharedFlow<ServiceConnectionContainer> =
+ serviceConnectionManager.connectionState
+ .flatMapLatest { state ->
+ if (state is ServiceConnectionState.ConnectedReady) {
+ flowOf(state.container)
+ } else {
+ emptyFlow()
+ }
+ }
+ .shareIn(viewModelScope, SharingStarted.WhileSubscribed())
+
+ private val vmState =
+ _shared
+ .flatMapLatest { serviceConnection ->
+ combine(
+ serviceConnection.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)
+ if (serviceConnectionManager.splitTunneling()?.enabled == false) {
+ serviceConnectionManager.splitTunneling()?.enabled = true
+ }
+ fetchApps()
}
}
- suspend fun processIntent(intent: ViewIntent) = intentFlow.emit(intent)
-
override fun onCleared() {
- splitTunneling.persist()
+ serviceConnectionManager.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 onIncludeAppClick(packageName: String) {
+ viewModelScope.launch(dispatcher) {
+ serviceConnectionManager.splitTunneling()?.includeApp(packageName)
}
}
- private fun removeFromExcluded(packageName: String) {
- excludedApps.remove(packageName)?.let { appInfo ->
- notExcludedApps[packageName] = appInfo
- splitTunneling.includeApp(packageName)
+ fun onExcludeAppClick(packageName: String) {
+ viewModelScope.launch(dispatcher) {
+ serviceConnectionManager.splitTunneling()?.excludeApp(packageName)
}
}
- private fun addToExcluded(packageName: String) {
- notExcludedApps.remove(packageName)?.let { appInfo ->
- excludedApps[packageName] = appInfo
- splitTunneling.excludeApp(packageName)
- }
+ fun onShowSystemAppsClick(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..eb798eb2c5
--- /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.ShowAppList(
+ excludedApps = excluded,
+ includedApps =
+ if (showSystemApps) {
+ included
+ } else {
+ included.filter { appData -> !appData.isSystemApp }
+ },
+ showSystemApps = showSystemApps
+ )
+ }
+ ?: SplitTunnelingUiState.Loading
+ }
+}
diff --git a/android/app/src/main/res/layout/app_list_item.xml b/android/app/src/main/res/layout/app_list_item.xml
deleted file mode 100644
index eebfccf88e..0000000000
--- a/android/app/src/main/res/layout/app_list_item.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingHorizontal="@dimen/side_margin"
- android:background="@drawable/app_list_item_background"
- android:orientation="horizontal"
- android:gravity="center"
- android:clickable="true"
- android:focusable="true">
- <ProgressBar android:id="@+id/loading"
- android:layout_width="@dimen/app_list_item_icon_size"
- android:layout_height="@dimen/app_list_item_icon_size"
- android:layout_gravity="center"
- android:layout_marginRight="4dp"
- android:indeterminate="true"
- android:indeterminateOnly="true"
- android:indeterminateDuration="600"
- android:indeterminateDrawable="@drawable/icon_spinner"
- android:visibility="visible" />
- <ImageView android:id="@+id/icon"
- android:layout_width="@dimen/app_list_item_icon_size"
- android:layout_height="@dimen/app_list_item_icon_size"
- android:layout_gravity="center"
- android:layout_marginRight="4dp"
- android:visibility="gone" />
- <TextView android:id="@+id/name"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_weight="1"
- android:layout_marginHorizontal="8dp"
- android:layout_marginVertical="16dp"
- android:textColor="@color/white"
- android:textSize="@dimen/text_medium"
- android:text="" />
- <net.mullvad.mullvadvpn.ui.widget.CellSwitch android:id="@+id/excluded"
- android:layout_width="@dimen/cell_switch_width"
- android:layout_height="@dimen/cell_switch_height"
- android:layout_weight="0" />
-</LinearLayout>
diff --git a/android/app/src/main/res/layout/collapsed_title_layout.xml b/android/app/src/main/res/layout/collapsed_title_layout.xml
deleted file mode 100644
index 3d755ba65b..0000000000
--- a/android/app/src/main/res/layout/collapsed_title_layout.xml
+++ /dev/null
@@ -1,65 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:id="@+id/main_content"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="@color/darkBlue"
- android:fitsSystemWindows="false">
- <com.google.android.material.appbar.AppBarLayout android:id="@+id/appbar"
- android:layout_width="match_parent"
- android:layout_height="@dimen/expanded_toolbar_height"
- android:background="@color/darkBlue"
- android:fitsSystemWindows="false"
- android:padding="0dp"
- android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
-
- <com.google.android.material.appbar.CollapsingToolbarLayout android:id="@+id/collapsing_toolbar"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:fitsSystemWindows="false"
- app:collapsedTitleGravity="center"
- app:collapsedTitleTextAppearance="@style/TextAppearance.Mullvad.CollapsingToolbar.Collapsed"
- app:contentScrim="@color/darkBlue"
- app:expandedTitleGravity="start|bottom"
- app:expandedTitleMarginBottom="20dp"
- app:expandedTitleMarginStart="22dp"
- app:expandedTitleMarginTop="0dp"
- app:expandedTitleTextAppearance="@style/TextAppearance.Mullvad.CollapsingToolbar.Expanded"
- app:layout_scrollFlags="scroll|exitUntilCollapsed"
- app:title="@string/split_tunneling">
-
- <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar"
- android:layout_width="match_parent"
- android:layout_height="?attr/actionBarSize"
- android:padding="0dp"
- app:layout_collapseMode="pin"
- app:layout_scrollFlags="scroll|enterAlways"
- android:contentInsetLeft="0dp"
- android:contentInsetStart="0dp"
- app:contentInsetLeft="0dp"
- app:contentInsetStart="0dp"
- android:contentInsetRight="0dp"
- android:contentInsetEnd="0dp"
- app:contentInsetRight="0dp"
- app:contentInsetEnd="0dp"
- app:contentInsetStartWithNavigation="0dp"
- app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
- <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- app:layout_collapseMode="pin"
- app:layout_collapseParallaxMultiplier="0"
- app:layout_scrollFlags="enterAlwaysCollapsed|enterAlways"
- app:text="@string/settings_vpn" />
- </com.google.android.material.appbar.CollapsingToolbarLayout>
- </com.google.android.material.appbar.AppBarLayout>
- <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- tools:listitem="@layout/app_list_item"
- tools:itemCount="15"
- app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
- app:layout_behavior="@string/appbar_scrolling_view_behavior" />
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/android/app/src/main/res/values/dimensions.xml b/android/app/src/main/res/values/dimensions.xml
index 76fa24032d..d6bab1dade 100644
--- a/android/app/src/main/res/values/dimensions.xml
+++ b/android/app/src/main/res/values/dimensions.xml
@@ -39,7 +39,6 @@
<dimen name="half_vertical_space">10dp</dimen>
<dimen name="button_separation">18dp</dimen>
<dimen name="screen_vertical_margin">22dp</dimen>
- <dimen name="app_list_item_icon_size">35dp</dimen>
<dimen name="progress_size">60dp</dimen>
<dimen name="icon_size">24dp</dimen>
<dimen name="widget_padding">16dp</dimen>
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index 9a808e9c3b..dab9229592 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -56,10 +56,6 @@
<item name="android:textColor">@color/white</item>
<item name="android:textSize">@dimen/text_medium_plus</item>
</style>
- <style name="TextAppearance.Mullvad.Title2">
- <item name="android:textColor">@color/white</item>
- <item name="android:textSize">@dimen/text_medium</item>
- </style>
<style name="TextAppearance.Mullvad.Small">
<item name="android:textColor">@color/white60</item>
<item name="android:textSize">@dimen/text_small</item>
@@ -90,17 +86,6 @@
<item name="android:clickable">false</item>
<item name="android:focusable">false</item>
</style>
- <style name="TextAppearance.Mullvad.CollapsingToolbar">
- <item name="android:textColor">@color/white</item>
- </style>
- <style name="TextAppearance.Mullvad.CollapsingToolbar.Expanded">
- <item name="android:textSize">30sp</item>
- <item name="android:textStyle">bold</item>
- </style>
- <style name="TextAppearance.Mullvad.CollapsingToolbar.Collapsed">
- <item name="android:textSize">20sp</item>
- <item name="android:textStyle">bold</item>
- </style>
<!-- Switch Style -->
<style name="AppTheme.Switch">
<item name="android:layout_width">@dimen/switch_width</item>
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/di/UiModuleTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/di/UiModuleTest.kt
deleted file mode 100644
index 01b1807d42..0000000000
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/di/UiModuleTest.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package net.mullvad.mullvadvpn.di
-
-import android.os.Messenger
-import io.mockk.mockk
-import io.mockk.unmockkAll
-import kotlin.test.assertEquals
-import net.mullvad.mullvadvpn.ipc.Event
-import net.mullvad.mullvadvpn.ipc.MessageDispatcher
-import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling
-import org.junit.After
-import org.junit.Rule
-import org.junit.Test
-import org.koin.core.parameter.parametersOf
-import org.koin.core.qualifier.named
-import org.koin.core.scope.Scope
-import org.koin.test.KoinTest
-import org.koin.test.KoinTestRule
-
-class UiModuleTest : KoinTest {
-
- @get:Rule val koinTestRule = KoinTestRule.create { modules(uiModule) }
-
- @After
- fun tearDown() {
- unmockkAll()
- }
-
- @Test
- fun test_scope_linking() {
- val appsScope: Scope = getKoin().createScope(APPS_SCOPE, named(APPS_SCOPE))
- val serviceConnectionScope =
- getKoin().createScope(SERVICE_CONNECTION_SCOPE, named(SERVICE_CONNECTION_SCOPE))
-
- appsScope.linkTo(serviceConnectionScope)
-
- val mockedMessenger = mockk<Messenger>()
- val mockedEventMessageHandler = mockk<MessageDispatcher<Event>>(relaxed = true)
- val serviceConnectionSplitTunneling =
- serviceConnectionScope.get<SplitTunneling>(
- parameters = { parametersOf(mockedMessenger, mockedEventMessageHandler) }
- )
-
- assertEquals(appsScope.get<SplitTunneling>(), serviceConnectionSplitTunneling)
- }
-}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt
index 9fa0e9ea88..8077468a6d 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt
@@ -1,26 +1,28 @@
package net.mullvad.mullvadvpn.viewmodel
-import androidx.annotation.StringRes
import androidx.lifecycle.viewModelScope
-import io.mockk.Runs
+import app.cash.turbine.test
import io.mockk.every
+import io.mockk.invoke
import io.mockk.just
import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import io.mockk.verifyAll
import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runBlockingTest
-import net.mullvad.mullvadvpn.R
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
import net.mullvad.mullvadvpn.TestCoroutineRule
import net.mullvad.mullvadvpn.applist.AppData
import net.mullvad.mullvadvpn.applist.ApplicationsProvider
-import net.mullvad.mullvadvpn.applist.ViewIntent
-import net.mullvad.mullvadvpn.assertLists
-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.ServiceConnectionContainer
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling
import org.junit.After
import org.junit.Before
@@ -34,6 +36,8 @@ class SplitTunnelingViewModelTest {
@get:Rule val timeout = Timeout(3000L, TimeUnit.MILLISECONDS)
private val mockedApplicationsProvider = mockk<ApplicationsProvider>()
private val mockedSplitTunneling = mockk<SplitTunneling>()
+ private val mockedServiceConnectionManager = mockk<ServiceConnectionManager>()
+ private val mockedServiceConnectionContainer = mockk<ServiceConnectionContainer>()
private lateinit var testSubject: SplitTunnelingViewModel
@Before
@@ -49,195 +53,158 @@ class SplitTunnelingViewModelTest {
@Test
fun test_has_progress_on_start() =
- runBlockingTest(testCoroutineRule.testDispatcher) {
+ runTest(testCoroutineRule.testDispatcher) {
initTestSubject(emptyList())
- val actualList: List<ListItemData> = testSubject.listItems.first()
+ val actualState: SplitTunnelingUiState = testSubject.uiState.value
- val initialExpectedList =
- listOf(
- createTextItem(R.string.split_tunneling_description),
- createDivider(0),
- createProgressItem()
- )
+ val initialExpectedState = SplitTunnelingUiState.Loading
- assertLists(initialExpectedList, actualList)
+ assertEquals(initialExpectedState, actualState)
verify(exactly = 1) { mockedApplicationsProvider.getAppsList() }
}
@Test
fun test_empty_app_list() =
- runBlockingTest(testCoroutineRule.testDispatcher) {
+ runTest(testCoroutineRule.testDispatcher) {
+ every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers
+ {
+ lambda<(Set<String>) -> Unit>().invoke(emptySet())
+ }
initTestSubject(emptyList())
- testSubject.processIntent(ViewIntent.ViewIsReady)
- val actualList = testSubject.listItems.first()
- val expectedList = listOf(createTextItem(R.string.split_tunneling_description))
- assertLists(expectedList, actualList)
+ val expectedState =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = emptyList(),
+ includedApps = emptyList(),
+ showSystemApps = false
+ )
+ testSubject.uiState.test { assertEquals(expectedState, awaitItem()) }
}
@Test
fun test_apps_list_delivered() =
- runBlockingTest(testCoroutineRule.testDispatcher) {
+ runTest(testCoroutineRule.testDispatcher) {
val appExcluded = AppData("test.excluded", 0, "testName1")
val appNotExcluded = AppData("test.not.excluded", 0, "testName2")
- every { mockedSplitTunneling.isAppExcluded(appExcluded.packageName) } returns true
- every { mockedSplitTunneling.isAppExcluded(appNotExcluded.packageName) } returns false
+ every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers
+ {
+ lambda<(Set<String>) -> Unit>().invoke(setOf(appExcluded.packageName))
+ }
initTestSubject(listOf(appExcluded, appNotExcluded))
- testSubject.processIntent(ViewIntent.ViewIsReady)
- val actualList = testSubject.listItems.first()
- val expectedList =
- listOf(
- createTextItem(R.string.split_tunneling_description),
- createDivider(0),
- createMainItem(R.string.exclude_applications),
- createApplicationItem(appExcluded, true),
- createDivider(1),
- createSwitchItem(R.string.show_system_apps, false),
- createMainItem(R.string.all_applications),
- createApplicationItem(appNotExcluded, false),
+ val expectedState =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = listOf(appExcluded),
+ includedApps = listOf(appNotExcluded),
+ showSystemApps = false
)
- assertLists(expectedList, actualList)
- verifyAll {
- mockedSplitTunneling.enabled
- mockedSplitTunneling.isAppExcluded(appExcluded.packageName)
- mockedSplitTunneling.isAppExcluded(appNotExcluded.packageName)
+ testSubject.uiState.test {
+ val actualState = awaitItem()
+ assertEquals(expectedState, actualState)
+ verifyAll {
+ mockedSplitTunneling.enabled
+ mockedSplitTunneling.excludedAppsChange = any()
+ }
}
}
@Test
- fun test_remove_app_from_excluded() =
- runBlockingTest(testCoroutineRule.testDispatcher) {
+ fun test_include_app() =
+ runTest(testCoroutineRule.testDispatcher) {
+ var excludedAppsCallback = slot<(Set<String>) -> Unit>()
val app = AppData("test", 0, "testName")
- every { mockedSplitTunneling.isAppExcluded(app.packageName) } returns true
- every { mockedSplitTunneling.includeApp(app.packageName) } just Runs
+ every { mockedSplitTunneling.includeApp(app.packageName) } just runs
+ every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers
+ {
+ excludedAppsCallback = lambda()
+ excludedAppsCallback.invoke(setOf(app.packageName))
+ }
initTestSubject(listOf(app))
- testSubject.processIntent(ViewIntent.ViewIsReady)
- val listBeforeAction = testSubject.listItems.first()
- val expectedListBeforeAction =
- listOf(
- createTextItem(R.string.split_tunneling_description),
- createDivider(0),
- createMainItem(R.string.exclude_applications),
- createApplicationItem(app, true),
+ val expectedStateBeforeAction =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = listOf(app),
+ includedApps = emptyList(),
+ showSystemApps = false
)
-
- assertLists(expectedListBeforeAction, listBeforeAction)
-
- val item = listBeforeAction.first { it.identifier == app.packageName }
- testSubject.processIntent(ViewIntent.ChangeApplicationGroup(item))
-
- val itemsAfterAction = testSubject.listItems.first()
- val expectedList =
- listOf(
- createTextItem(R.string.split_tunneling_description),
- createDivider(1),
- createSwitchItem(R.string.show_system_apps, false),
- createMainItem(R.string.all_applications),
- createApplicationItem(app, false),
+ val expectedStateAfterAction =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = emptyList(),
+ includedApps = listOf(app),
+ showSystemApps = false
)
- assertLists(expectedList, itemsAfterAction)
+ testSubject.uiState.test {
+ assertEquals(expectedStateBeforeAction, awaitItem())
+ testSubject.onIncludeAppClick(app.packageName)
+ excludedAppsCallback.invoke(emptySet())
+ assertEquals(expectedStateAfterAction, awaitItem())
- verifyAll {
- mockedSplitTunneling.enabled
- mockedSplitTunneling.isAppExcluded(app.packageName)
- mockedSplitTunneling.includeApp(app.packageName)
+ verifyAll {
+ mockedSplitTunneling.enabled
+ mockedSplitTunneling.excludedAppsChange = any()
+ mockedSplitTunneling.includeApp(app.packageName)
+ }
}
}
@Test
fun test_add_app_to_excluded() =
- runBlockingTest(testCoroutineRule.testDispatcher) {
+ runTest(testCoroutineRule.testDispatcher) {
+ var excludedAppsCallback = slot<(Set<String>) -> Unit>()
val app = AppData("test", 0, "testName")
- every { mockedSplitTunneling.isAppExcluded(app.packageName) } returns false
- every { mockedSplitTunneling.excludeApp(app.packageName) } just Runs
+ every { mockedSplitTunneling.excludeApp(app.packageName) } just runs
+ every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers
+ {
+ excludedAppsCallback = lambda()
+ excludedAppsCallback.invoke(emptySet())
+ }
+
initTestSubject(listOf(app))
- testSubject.processIntent(ViewIntent.ViewIsReady)
- val listBeforeAction = testSubject.listItems.first()
- val expectedListBeforeAction =
- listOf(
- createTextItem(R.string.split_tunneling_description),
- createDivider(1),
- createSwitchItem(R.string.show_system_apps, false),
- createMainItem(R.string.all_applications),
- createApplicationItem(app, false),
+ val expectedStateBeforeAction =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = emptyList(),
+ includedApps = listOf(app),
+ showSystemApps = false
)
- assertLists(expectedListBeforeAction, listBeforeAction)
-
- val item = listBeforeAction.first { it.identifier == app.packageName }
- testSubject.processIntent(ViewIntent.ChangeApplicationGroup(item))
-
- val itemsAfterAction = testSubject.listItems.first()
- val expectedList =
- listOf(
- createTextItem(R.string.split_tunneling_description),
- createDivider(0),
- createMainItem(R.string.exclude_applications),
- createApplicationItem(app, true),
+ val expectedStateAfterAction =
+ SplitTunnelingUiState.ShowAppList(
+ excludedApps = listOf(app),
+ includedApps = emptyList(),
+ showSystemApps = false
)
- assertLists(expectedList, itemsAfterAction)
+ testSubject.uiState.test {
+ assertEquals(expectedStateBeforeAction, awaitItem())
+ testSubject.onExcludeAppClick(app.packageName)
+ excludedAppsCallback.invoke(setOf(app.packageName))
+ assertEquals(expectedStateAfterAction, awaitItem())
- verifyAll {
- mockedSplitTunneling.enabled
- mockedSplitTunneling.isAppExcluded(app.packageName)
- mockedSplitTunneling.excludeApp(app.packageName)
+ verifyAll {
+ mockedSplitTunneling.enabled
+ mockedSplitTunneling.excludedAppsChange = any()
+ mockedSplitTunneling.excludeApp(app.packageName)
+ }
}
}
private fun initTestSubject(appList: List<AppData>) {
every { mockedApplicationsProvider.getAppsList() } returns appList
+ every { mockedServiceConnectionManager.connectionState } returns
+ MutableStateFlow(
+ ServiceConnectionState.ConnectedReady(mockedServiceConnectionContainer)
+ )
+ every { mockedServiceConnectionContainer.splitTunneling } returns mockedSplitTunneling
testSubject =
SplitTunnelingViewModel(
mockedApplicationsProvider,
- mockedSplitTunneling,
+ mockedServiceConnectionManager,
testCoroutineRule.testDispatcher
)
}
-
- 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)
- }
}