diff options
| author | Aleksandr Granin <aleksandr@mullvad.net> | 2021-03-29 16:31:31 +0200 |
|---|---|---|
| committer | Aleksandr Granin <aleksandr@mullvad.net> | 2021-04-01 13:30:37 +0200 |
| commit | 99a40bc34d559cbb0bf936ad510b97095a02d062 (patch) | |
| tree | d40629748ec2fe8e643588eea62f58392623f4f6 /android/src | |
| parent | a487f5c04431013b884e1e5734370dc22e289279 (diff) | |
| download | mullvadvpn-99a40bc34d559cbb0bf936ad510b97095a02d062.tar.xz mullvadvpn-99a40bc34d559cbb0bf936ad510b97095a02d062.zip | |
Create SplitTunneling ViewModel and tests
Diffstat (limited to 'android/src')
11 files changed, 459 insertions, 12 deletions
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProvider.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProvider.kt index a097ffd231..92af83aea3 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProvider.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProvider.kt @@ -3,6 +3,8 @@ package net.mullvad.mullvadvpn.applist import android.Manifest import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred class ApplicationsProvider( private val packageManager: PackageManager, @@ -14,15 +16,15 @@ class ApplicationsProvider( !isSelfApplication(appInfo.packageName) } - fun getAppsList(): List<AppData> { - return packageManager.getInstalledApplications(PackageManager.GET_META_DATA) + fun getAppsListAsync(): Deferred<List<AppData>> = CompletableDeferred( + packageManager.getInstalledApplications(PackageManager.GET_META_DATA) .asSequence() .filter(applicationFilterPredicate) .map { info -> AppData(info.packageName, info.icon, info.loadLabel(packageManager).toString()) } .toList() - } + ) private fun hasInternetPermission(packageName: String): Boolean { return PackageManager.PERMISSION_GRANTED == diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ViewIntent.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ViewIntent.kt new file mode 100644 index 0000000000..f1c15abbb8 --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ViewIntent.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.applist + +import net.mullvad.mullvadvpn.model.ListItemData + +sealed class ViewIntent { + // In future we will have search intent + data class ChangeApplicationGroup(val item: ListItemData) : ViewIntent() + object ViewIsReady : ViewIntent() +} diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt new file mode 100644 index 0000000000..c2b95b9c2a --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt @@ -0,0 +1,155 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.shareIn +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.service.SplitTunneling + +class SplitTunnelingViewModel( + private val appsProvider: ApplicationsProvider, + private val splitTunneling: SplitTunneling +) : ViewModel() { + private val listItemsSink = MutableSharedFlow<List<ListItemData>>(replay = 1) + // read-only public view + val listItems: SharedFlow<List<ListItemData>> = listItemsSink.asSharedFlow() + + 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 defaultListItems: List<ListItemData> = listOf( + createTextItem(R.string.split_tunneling_description) + // We will have search item in future + ) + + init { + viewModelScope.launch(Dispatchers.Default) { + 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(Dispatchers.Default) { + intentFlow.shareIn(viewModelScope, SharingStarted.WhileSubscribed()) + .collect(::handleIntents) + } + } + + suspend fun processIntent(intent: ViewIntent) = intentFlow.emit(intent) + + override fun onCleared() { + splitTunneling.persist() + super.onCleared() + } + + private suspend fun handleIntents(viewIntent: ViewIntent) { + when (viewIntent) { + is ViewIntent.ChangeApplicationGroup -> { + viewIntent.item.action?.let { + if (excludedApps.containsKey(it.identifier)) { + removeFromExcluded(it.identifier) + } else { + addToExcluded(it.identifier) + } + publishList() + } + } + is ViewIntent.ViewIsReady -> isUIReady.complete(Unit) + } + } + + private fun removeFromExcluded(packageName: String) { + excludedApps.remove(packageName)?.let { appInfo -> + notExcludedApps[packageName] = appInfo + splitTunneling.includeApp(packageName) + } + } + + private fun addToExcluded(packageName: String) { + notExcludedApps.remove(packageName)?.let { appInfo -> + excludedApps[packageName] = appInfo + splitTunneling.excludeApp(packageName) + } + } + + private suspend fun fetchData() { + appsProvider.getAppsListAsync().await() + .partition { app -> splitTunneling.excludedAppList.contains(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 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) + } + } + if (notExcludedApps.isNotEmpty()) { + listItems += createDivider(1) + listItems += createMainItem(R.string.all_applications) + listItems += notExcludedApps.values.sortedBy { it.name }.map { info -> + createApplicationItem(info, false) + } + } + listItemsSink.emit(listItems) + } + + 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 + } +} diff --git a/android/src/main/res/drawable/ic_icons_add.xml b/android/src/main/res/drawable/ic_icons_add.xml new file mode 100644 index 0000000000..97f0ca7fc7 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons_add.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path android:pathData="M13.05,5.66v5.29h5.29c0.513,0 0.99,0.398 0.99,0.99v0.12c0,0.578 -0.477,0.99 -0.99,0.99h-5.29v5.29c0,0.522 -0.412,0.989 -0.99,0.99l-0.12,-0.001c-0.59,0 -0.99,-0.467 -0.99,-0.989v-5.29H5.66c-0.534,0 -0.99,-0.427 -0.99,-0.99v-0.12c0,-0.559 0.456,-0.99 0.99,-0.99h5.29V5.66c0,-0.512 0.407,-0.99 0.99,-0.99h0.12c0.584,0 0.99,0.478 0.99,0.99zM12,24C5.373,24 0,18.627 0,12S5.373,0 12,0s12,5.373 12,12 -5.373,12 -12,12z" + android:fillColor="@android:color/white" + android:fillType="evenOdd" /> +</vector> diff --git a/android/src/main/res/drawable/ic_icons_remove.xml b/android/src/main/res/drawable/ic_icons_remove.xml new file mode 100644 index 0000000000..50b84ad42c --- /dev/null +++ b/android/src/main/res/drawable/ic_icons_remove.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path android:pathData="M13.05,10.95h5.29c0.513,0 0.99,0.398 0.99,0.99v0.12c0,0.578 -0.477,0.99 -0.99,0.99H5.66c-0.534,0 -0.99,-0.427 -0.99,-0.99v-0.12c0,-0.559 0.456,-0.99 0.99,-0.99H13.05zM12,24C5.373,24 0,18.627 0,12S5.373,0 12,0s12,5.373 12,12 -5.373,12 -12,12z" + android:fillColor="@android:color/white" + android:fillType="evenOdd" /> +</vector> diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 46c2bdb464..b3a41d876c 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -175,7 +175,8 @@ <string name="confirm_public_dns">The DNS server you are trying to add might not work because it is public. Currently we only support local DNS servers.</string> <string name="add_anyway">Add anyway</string> - <string name="exclude_applications">Exclude applications</string> + <string name="exclude_applications">Excluded applications</string> + <string name="all_applications">All applications</string> <string name="account_url">https://mullvad.net/en/account</string> <string name="wg_key_url">https://mullvad.net/en/account/ports</string> <string name="create_account_url">https://mullvad.net/en/account/create</string> diff --git a/android/src/test/kotlin/net/mullvad/mullvadvpn/TestCoroutineRule.kt b/android/src/test/kotlin/net/mullvad/mullvadvpn/TestCoroutineRule.kt new file mode 100644 index 0000000000..1acdf9e577 --- /dev/null +++ b/android/src/test/kotlin/net/mullvad/mullvadvpn/TestCoroutineRule.kt @@ -0,0 +1,24 @@ +package net.mullvad.mullvadvpn + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class TestCoroutineRule( + val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() +) : TestWatcher() { + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + } +} diff --git a/android/src/test/kotlin/net/mullvad/mullvadvpn/TestUtils.kt b/android/src/test/kotlin/net/mullvad/mullvadvpn/TestUtils.kt new file mode 100644 index 0000000000..4c4f043c06 --- /dev/null +++ b/android/src/test/kotlin/net/mullvad/mullvadvpn/TestUtils.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn + +import kotlin.test.assertTrue + +fun <T> assertLists(expected: List<T>, actual: List<T>, message: String? = null) = assertTrue( + expected.size == actual.size && expected.containsAll(actual) && actual.containsAll(expected), + message ?: """Expected list should have same size and contains same items. + | Expected(${expected.size}): $expected + | Actual(${actual.size}) : $actual + """.trimMargin() +) diff --git a/android/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt b/android/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt index 6f871a28ee..e6d43621a1 100644 --- a/android/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt +++ b/android/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManagerTest.kt @@ -86,7 +86,7 @@ class ApplicationsIconManagerTest { } @Test - fun throw_exception_when_invoke_from_MainThread() { + fun test_throw_exception_when_invoke_from_MainThread() { val testPackageName = "test" every { mockedMainLooper.isCurrentThread } returns true diff --git a/android/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProviderTest.kt b/android/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProviderTest.kt index cb24158422..21ea736ee3 100644 --- a/android/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProviderTest.kt +++ b/android/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProviderTest.kt @@ -7,6 +7,8 @@ import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll import io.mockk.verifyAll +import kotlinx.coroutines.test.runBlockingTest +import net.mullvad.mullvadvpn.assertLists import org.junit.After import org.junit.Test @@ -22,7 +24,7 @@ class ApplicationsProviderTest { } @Test - fun test_get_apps() { + fun test_get_apps() = runBlockingTest { val launchWithInternetPackageName = "launch_with_internet_package_name" val launchWithoutInternetPackageName = "launch_without_internet_package_name" val nonLaunchWithInternetPackageName = "non_launch_with_internet_package_name" @@ -38,15 +40,12 @@ class ApplicationsProviderTest { createApplicationInfo(selfPackageName, internet = true, launch = true) ) - val result = testSubject.getAppsList() + val result = testSubject.getAppsListAsync().await() val expected = listOf( AppData(launchWithInternetPackageName, 0, launchWithInternetPackageName) ) - assert( - expected.size == result.size && - expected.containsAll(result) && - result.containsAll(expected) - ) + + assertLists(expected, result) verifyAll { mockedPackageManager.getInstalledApplications(PackageManager.GET_META_DATA) diff --git a/android/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt b/android/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt new file mode 100644 index 0000000000..c0e447e5a0 --- /dev/null +++ b/android/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt @@ -0,0 +1,228 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import io.mockk.verifyAll +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.mullvad.mullvadvpn.R +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.service.SplitTunneling +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class SplitTunnelingViewModelTest { + @get:Rule + val testCoroutineRule = TestCoroutineRule() + private val mockedApplicationsProvider = mockk<ApplicationsProvider>() + private val mockedSplitTunneling = mockk<SplitTunneling>() + private val appsProviderDeferred = CompletableDeferred<List<AppData>>() + private lateinit var testSubject: SplitTunnelingViewModel + + @Before + fun setup() { + every { mockedSplitTunneling.enabled } returns true + coEvery { mockedApplicationsProvider.getAppsListAsync() } returns appsProviderDeferred + testSubject = SplitTunnelingViewModel( + mockedApplicationsProvider, + mockedSplitTunneling + ) + Thread.sleep(50) + } + + @After + fun tearDown() { + testSubject.viewModelScope.coroutineContext.cancel() + unmockkAll() + } + + @Test + fun test_has_progress_on_start() = runBlocking { + val actualList: List<ListItemData> = testSubject.listItems.first() + val initialExpectedList = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(0), createProgressItem() + ) + + assertLists(initialExpectedList, actualList) + + verify(exactly = 1) { + mockedApplicationsProvider.getAppsListAsync() + } + } + + @Test + fun test_empty_app_list() = runBlocking { + val flow = testSubject.listItems + async { + testSubject.processIntent(ViewIntent.ViewIsReady) + appsProviderDeferred.complete(emptyList()) + } + val actualList = flow.drop(1).first() + val expectedList = listOf(createTextItem(R.string.split_tunneling_description)) + assertLists(expectedList, actualList) + } + + @Test + fun test_apps_list_delivered() = runBlocking { + val appExcluded = AppData("test.excluded", 0, "testName1") + val appNotExcluded = AppData("test.not.excluded", 0, "testName2") + every { mockedSplitTunneling.excludedAppList } returns listOf(appExcluded.packageName) + + testSubject.processIntent(ViewIntent.ViewIsReady) + appsProviderDeferred.complete(listOf(appExcluded, appNotExcluded)) + + val actualList = testSubject.listItems.drop(1).first() + val expectedList = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(0), + createMainItem(R.string.exclude_applications), + createApplicationItem(appExcluded, true), + createDivider(1), + createMainItem(R.string.all_applications), + createApplicationItem(appNotExcluded, false), + ) + + assertLists(expectedList, actualList) + verifyAll { + mockedSplitTunneling.enabled + mockedSplitTunneling.excludedAppList + mockedSplitTunneling.excludedAppList + } + } + + @Test + fun test_remove_app_from_excluded() = runBlocking { + val flow = testSubject.listItems.drop(1) + val app = AppData("test", 0, "testName") + every { mockedSplitTunneling.excludedAppList } returns listOf(app.packageName) + every { mockedSplitTunneling.includeApp(app.packageName) } just Runs + async { + testSubject.processIntent(ViewIntent.ViewIsReady) + appsProviderDeferred.complete(listOf(app)) + } + + val listBeforeAction = flow.first() + val expectedListBeforeAction = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(0), + createMainItem(R.string.exclude_applications), + createApplicationItem(app, true), + ) + + assertLists(expectedListBeforeAction, listBeforeAction) + + val item = listBeforeAction.first { it.identifier == app.packageName } + testSubject.processIntent(ViewIntent.ChangeApplicationGroup(item)) + + val itemsAfterAction = flow.first() + val expectedList = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(1), + createMainItem(R.string.all_applications), + createApplicationItem(app, false), + ) + + assertLists(expectedList, itemsAfterAction) + + verifyAll { + mockedSplitTunneling.enabled + mockedSplitTunneling.excludedAppList + mockedSplitTunneling.includeApp(app.packageName) + } + } + + @Test + fun test_add_app_to_excluded() = runBlocking { + val flow = testSubject.listItems.drop(1) + val app = AppData("test", 0, "testName") + every { mockedSplitTunneling.excludedAppList } returns emptyList() + every { mockedSplitTunneling.excludeApp(app.packageName) } just Runs + async { + testSubject.processIntent(ViewIntent.ViewIsReady) + appsProviderDeferred.complete(listOf(app)) + } + + val listBeforeAction = flow.first() + val expectedListBeforeAction = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(1), + createMainItem(R.string.all_applications), + createApplicationItem(app, false), + ) + + assertLists(expectedListBeforeAction, listBeforeAction) + + val item = listBeforeAction.first { it.identifier == app.packageName } + testSubject.processIntent(ViewIntent.ChangeApplicationGroup(item)) + + val itemsAfterAction = flow.first() + val expectedList = listOf( + createTextItem(R.string.split_tunneling_description), + createDivider(0), + createMainItem(R.string.exclude_applications), + createApplicationItem(app, true), + ) + + assertLists(expectedList, itemsAfterAction) + + verifyAll { + mockedSplitTunneling.enabled + mockedSplitTunneling.excludedAppList + mockedSplitTunneling.excludeApp(app.packageName) + } + } + + 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 + } +} |
