diff options
| author | Albin <albin@mullvad.net> | 2023-05-23 16:59:41 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2023-05-23 16:59:41 +0200 |
| commit | d5d2bd1d62ec6d52faef631b4c827641fe438728 (patch) | |
| tree | bab47aef017e90de816e1eb057c50251b01ce155 | |
| parent | dfab0c483fbb737a4aa9b59a47d4eba8320c9a4d (diff) | |
| parent | 5cae6612dfaed04079a34e38b47c6bc7a5d6dcdb (diff) | |
| download | mullvadvpn-d5d2bd1d62ec6d52faef631b4c827641fe438728.tar.xz mullvadvpn-d5d2bd1d62ec6d52faef631b4c827641fe438728.zip | |
Merge branch 'migrate-split-tunneling-view-to-compose-droid-62'
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) - } } |
