summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAleksandr Granin <aleksandr@mullvad.net>2021-04-06 14:43:30 +0200
committerAleksandr Granin <aleksandr@mullvad.net>2021-04-19 14:36:22 +0200
commitb3b5da44ec8bf1f2a39cb33ad31d89dd571ac731 (patch)
treef47b084bd46361fb26b95fd66091c393ed8151f7
parent585303d6cb04657d7466a4ba5e1bc6c6eb0227d0 (diff)
downloadmullvadvpn-b3b5da44ec8bf1f2a39cb33ad31d89dd571ac731.tar.xz
mullvadvpn-b3b5da44ec8bf1f2a39cb33ad31d89dd571ac731.zip
Refactor SplitTunneling Fragment, add tests.
-rw-r--r--android/.idea/.gitignore1
-rw-r--r--android/build.gradle25
-rw-r--r--android/src/androidTest/kotlin/net/mullvad/mullvadvpn/RecyclerViewMatcher.kt52
-rw-r--r--android/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt145
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppInfo.kt8
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppListAdapter.kt94
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppListItemHolder.kt87
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt1
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt1
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt2
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SplitTunnelingFragment.kt182
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragment.kt127
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ApplicationListItemView.kt79
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/util/ViewKtx.kt15
-rw-r--r--android/src/main/res/layout/collapsed_title_layout.xml65
-rw-r--r--android/src/main/res/layout/list_item_widget_edit_text.xml21
-rw-r--r--android/src/main/res/values/dimensions.xml1
-rw-r--r--android/src/main/res/values/styles.xml2
18 files changed, 514 insertions, 394 deletions
diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore
index 15bf03b842..286031edc8 100644
--- a/android/.idea/.gitignore
+++ b/android/.idea/.gitignore
@@ -8,6 +8,7 @@ shelf
assetWizardSettings.xml
modules/android.iml
modules.xml
+kotlinc.xml
# Gradle:
gradle.xml
diff --git a/android/build.gradle b/android/build.gradle
index ddecf3cc69..446f188af2 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -24,6 +24,7 @@ android {
targetSdkVersion 30
versionCode 21010001
versionName "2021.1-beta1"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
if (keystorePropertiesFile.exists()) {
@@ -71,6 +72,12 @@ android {
srcDirs += 'src/test/kotlin/'
}
}
+
+ androidTest {
+ java {
+ srcDirs += 'src/androidTest/kotlin/'
+ }
+ }
}
compileOptions {
@@ -124,10 +131,12 @@ repositories {
dependencies {
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
- implementation "androidx.fragment:fragment-ktx:1.3.2"
+ implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
+ implementation "androidx.core:core-ktx:1.3.2"
+ implementation "androidx.fragment:fragment-ktx:$fragmentVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
- implementation "androidx.recyclerview:recyclerview:1.1.0"
+ implementation "androidx.recyclerview:recyclerview:1.2.0"
implementation "com.google.android.material:material:1.3.0"
implementation "commons-validator:commons-validator:1.7"
implementation "joda-time:joda-time:2.10.2"
@@ -145,10 +154,22 @@ dependencies {
testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlinVersion"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.3"
testImplementation "org.koin:koin-test:$koinVersion"
+
+ /* UI test dependencies */
+ debugImplementation "androidx.fragment:fragment-testing:$fragmentVersion"
+ androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
+ androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"
+ androidTestImplementation "androidx.test.ext:junit:1.1.2"
+ androidTestImplementation "io.mockk:mockk-android:$mockkVersion"
+ androidTestImplementation "org.koin:koin-test:$koinVersion"
+ // debugImplementation because LeakCanary should only run in debug builds.
+ // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.6'
}
buildscript {
ext {
+ espressoVersion = "3.3.0"
+ fragmentVersion = "1.3.2"
koinVersion = '2.2.2'
kotlinVersion = '1.4.31'
mockkVersion = '1.10.6'
diff --git a/android/src/androidTest/kotlin/net/mullvad/mullvadvpn/RecyclerViewMatcher.kt b/android/src/androidTest/kotlin/net/mullvad/mullvadvpn/RecyclerViewMatcher.kt
new file mode 100644
index 0000000000..2e87df9720
--- /dev/null
+++ b/android/src/androidTest/kotlin/net/mullvad/mullvadvpn/RecyclerViewMatcher.kt
@@ -0,0 +1,52 @@
+package net.mullvad.mullvadvpn
+
+import android.content.res.Resources
+import android.content.res.Resources.NotFoundException
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import org.hamcrest.Description
+import org.hamcrest.Matcher
+import org.hamcrest.TypeSafeMatcher
+
+class RecyclerViewMatcher(private val recyclerViewId: Int) {
+ fun atPosition(position: Int): Matcher<View> {
+ return atPositionOnView(position)
+ }
+
+ fun atPositionOnView(position: Int, targetViewId: Int? = null): Matcher<View> =
+ object : TypeSafeMatcher<View>() {
+ var resources: Resources? = null
+ var childView: View? = null
+
+ override fun describeTo(description: Description) {
+ val idDescription = resources?.let {
+ try {
+ it.getResourceName(recyclerViewId)
+ } catch (var4: NotFoundException) {
+ "$recyclerViewId (resource name not found)"
+ }
+ } ?: recyclerViewId.toString()
+ description.appendText("with id: $idDescription")
+ }
+
+ override fun matchesSafely(view: View): Boolean {
+ resources = view.resources
+ val recyclerView =
+ view.rootView.findViewById<View>(recyclerViewId) as RecyclerView?
+ if (recyclerView == null || recyclerView.id != recyclerViewId) {
+ return false
+ }
+ childView = recyclerView.findViewHolderForAdapterPosition(position)?.itemView
+ val targetView = targetViewId?.let { id ->
+ childView?.findViewById<View>(id)
+ } ?: childView
+ return view == targetView
+ }
+ }
+
+ companion object {
+ fun withRecyclerView(recyclerViewId: Int): RecyclerViewMatcher {
+ return RecyclerViewMatcher(recyclerViewId)
+ }
+ }
+}
diff --git a/android/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt b/android/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt
new file mode 100644
index 0000000000..8bd3cc70b8
--- /dev/null
+++ b/android/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt
@@ -0,0 +1,145 @@
+package net.mullvad.mullvadvpn.ui.fragments
+
+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.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+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.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.qualifier.named
+import org.koin.core.scope.Scope
+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 val scope: Scope = getKoin().getOrCreateScope(APPS_SCOPE, named(APPS_SCOPE))
+
+ @get:Rule
+ val mockProvider = MockProviderRule.create { clazz ->
+ when (clazz) {
+ SplitTunnelingViewModel::class -> mockedViewModel
+ else -> mockkClass(clazz)
+ }
+ }
+
+ @Before
+ fun setUp() {
+ scope.declareMock<SplitTunnelingViewModel>()
+ every { mockedViewModel.listItems } returns sharedFlow
+ coEvery { mockedViewModel.processIntent(ViewIntent.ViewIsReady) } just Runs
+ }
+
+ @After
+ fun tearDown() {
+ scope.close()
+ 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).atPosition(0)).perform(click())
+
+ coVerifyAll {
+ mockedViewModel.listItems
+ mockedViewModel.processIntent(ViewIntent.ChangeApplicationGroup(testListItem))
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppInfo.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppInfo.kt
deleted file mode 100644
index f7b7986993..0000000000
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppInfo.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package net.mullvad.mullvadvpn.applist
-
-import android.content.pm.ApplicationInfo
-import android.graphics.drawable.Drawable
-
-data class AppInfo(val info: ApplicationInfo, val label: String) {
- var icon: Drawable? = null
-}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppListAdapter.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppListAdapter.kt
deleted file mode 100644
index adbafc3dbd..0000000000
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppListAdapter.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-package net.mullvad.mullvadvpn.applist
-
-import android.Manifest
-import android.content.Context
-import android.content.pm.ApplicationInfo
-import android.content.pm.PackageManager
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView.Adapter
-import kotlin.properties.Delegates.observable
-import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling
-import net.mullvad.mullvadvpn.util.JobTracker
-
-class AppListAdapter(
- context: Context,
- private val splitTunneling: SplitTunneling
-) : Adapter<AppListItemHolder>() {
- private val appList = ArrayList<AppInfo>()
- private val jobTracker = JobTracker()
- private val packageManager = context.packageManager
- private val thisPackageName = context.packageName
-
- private val applicationFilterPredicate: (ApplicationInfo) -> Boolean = { appInfo ->
- hasInternetPermission(appInfo.packageName) && !isSelfApplication(appInfo.packageName) &&
- isLaunchable(appInfo.packageName)
- }
-
- var onListReady: (suspend () -> Unit)? = null
-
- var isListReady = false
- private set
-
- var enabled by observable(false) { _, oldValue, newValue ->
- if (oldValue != newValue) {
- if (newValue == true) {
- notifyItemRangeInserted(0, appList.size)
- } else {
- notifyItemRangeRemoved(0, appList.size)
- }
- }
- }
-
- init {
- jobTracker.newBackgroundJob("populateAppList") {
- populateAppList()
- }
- }
-
- override fun getItemCount() = if (enabled) { appList.size } else { 0 }
-
- override fun onCreateViewHolder(parentView: ViewGroup, type: Int): AppListItemHolder {
- val inflater = LayoutInflater.from(parentView.context)
- val view = inflater.inflate(R.layout.app_list_item, parentView, false)
-
- return AppListItemHolder(splitTunneling, packageManager, jobTracker, view)
- }
-
- override fun onBindViewHolder(holder: AppListItemHolder, position: Int) {
- holder.appInfo = appList.get(position)
- }
-
- private fun populateAppList() {
- val applications = packageManager
- .getInstalledApplications(0)
- .filter(applicationFilterPredicate)
- .map { info -> AppInfo(info, packageManager.getApplicationLabel(info).toString()) }
-
- appList.apply {
- clear()
- addAll(applications)
- sortBy { info -> info.label }
- }
-
- jobTracker.newUiJob("notifyAppListChanges") {
- isListReady = true
- onListReady?.invoke()
- notifyItemRangeInserted(0, applications.size)
- }
- }
-
- private fun hasInternetPermission(packageName: String): Boolean {
- return PackageManager.PERMISSION_GRANTED ==
- packageManager.checkPermission(Manifest.permission.INTERNET, packageName)
- }
-
- private fun isSelfApplication(packageName: String): Boolean {
- return packageName == thisPackageName
- }
-
- private fun isLaunchable(packageName: String): Boolean {
- return packageManager.getLaunchIntentForPackage(packageName) != null
- }
-}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppListItemHolder.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppListItemHolder.kt
deleted file mode 100644
index 6f52604018..0000000000
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppListItemHolder.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package net.mullvad.mullvadvpn.applist
-
-import android.content.pm.PackageManager
-import android.graphics.drawable.Drawable
-import android.view.View
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView.ViewHolder
-import kotlin.properties.Delegates.observable
-import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling
-import net.mullvad.mullvadvpn.ui.widget.CellSwitch
-import net.mullvad.mullvadvpn.util.JobTracker
-
-class AppListItemHolder(
- private val splitTunneling: SplitTunneling,
- private val packageManager: PackageManager,
- private val jobTracker: JobTracker,
- view: View
-) : ViewHolder(view) {
- private val loading: View = view.findViewById(R.id.loading)
- private val icon: ImageView = view.findViewById(R.id.icon)
- private val name: TextView = view.findViewById(R.id.name)
- private val excluded: CellSwitch = view.findViewById(R.id.excluded)
-
- var appInfo by observable<AppInfo?>(null) { _, _, info ->
- if (info != null) {
- val iconImage = info.icon
-
- name.text = info.label
-
- if (iconImage != null) {
- showIcon(iconImage)
- } else {
- hideIcon()
- loadIcon(info)
- }
-
- if (splitTunneling.isAppExcluded(info.info.packageName)) {
- excluded.forcefullySetState(CellSwitch.State.ON)
- } else {
- excluded.forcefullySetState(CellSwitch.State.OFF)
- }
- } else {
- name.text = ""
- hideIcon()
- }
- }
-
- init {
- view.setOnClickListener {
- excluded.toggle()
- }
-
- excluded.listener = { state ->
- appInfo?.info?.packageName?.let { app ->
- when (state) {
- CellSwitch.State.ON -> splitTunneling.excludeApp(app)
- CellSwitch.State.OFF -> splitTunneling.includeApp(app)
- }
- }
- }
- }
-
- private fun hideIcon() {
- icon.visibility = View.GONE
- loading.visibility = View.VISIBLE
- }
-
- private fun showIcon(iconImage: Drawable) {
- loading.visibility = View.GONE
- icon.setImageDrawable(iconImage)
- icon.visibility = View.VISIBLE
- }
-
- private fun loadIcon(info: AppInfo) {
- jobTracker.newUiJob("load icon for ${info.info.packageName}") {
- val iconImage = jobTracker.runOnBackground {
- packageManager.getApplicationIcon(info.info)
- }
-
- info.icon = iconImage
-
- showIcon(iconImage)
- }
- }
-}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt
index e266f3d044..ebfbc1f379 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsIconManager.kt
@@ -10,6 +10,7 @@ class ApplicationsIconManager(private val packageManager: PackageManager) {
private val iconsCache = LruCache<String, Drawable>(500)
@WorkerThread
+ @Throws(PackageManager.NameNotFoundException::class)
fun getAppIcon(packageName: String): Drawable {
check(!Looper.getMainLooper().isCurrentThread) { "Should not be called from MainThread" }
iconsCache.get(packageName)?.let {
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt
index a045986ca8..2b26b5b4d4 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.CompletableDeferred
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.model.Settings
import net.mullvad.mullvadvpn.ui.customdns.CustomDnsAdapter
+import net.mullvad.mullvadvpn.ui.fragments.SplitTunnelingFragment
import net.mullvad.mullvadvpn.ui.widget.CellSwitch
import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView
import net.mullvad.mullvadvpn.ui.widget.MtuCell
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt
index bb30626cb7..1e39d45235 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemsAdapter.kt
@@ -9,6 +9,7 @@ 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
@@ -33,6 +34,7 @@ class ListItemsAdapter : RecyclerView.Adapter<ListItemsAdapter.ViewHolder>() {
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")
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SplitTunnelingFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SplitTunnelingFragment.kt
deleted file mode 100644
index 9750cea495..0000000000
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SplitTunnelingFragment.kt
+++ /dev/null
@@ -1,182 +0,0 @@
-package net.mullvad.mullvadvpn.ui
-
-import android.animation.Animator
-import android.animation.Animator.AnimatorListener
-import android.animation.ObjectAnimator
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.recyclerview.widget.LinearLayoutManager
-import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.applist.AppListAdapter
-import net.mullvad.mullvadvpn.ui.widget.CellSwitch
-import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView
-import net.mullvad.mullvadvpn.ui.widget.ToggleCell
-import net.mullvad.mullvadvpn.util.AdapterWithHeader
-
-class SplitTunnelingFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) {
- private val excludeApplicationsFadeOutListener = object : AnimatorListener {
- override fun onAnimationCancel(animation: Animator) {}
- override fun onAnimationRepeat(animation: Animator) {}
- override fun onAnimationStart(animation: Animator) {}
-
- override fun onAnimationEnd(animation: Animator) {
- if (!appListAdapter.enabled && appListAdapter.isListReady) {
- excludeApplications.visibility = View.GONE
- }
- }
- }
-
- private val loadingSpinnerFadeOutListener = object : AnimatorListener {
- override fun onAnimationCancel(animation: Animator) {}
- override fun onAnimationRepeat(animation: Animator) {}
- override fun onAnimationStart(animation: Animator) {}
-
- override fun onAnimationEnd(animation: Animator) {
- if (appListAdapter.isListReady) {
- appListAdapter.enabled = true
- loadingSpinner.visibility = View.GONE
- }
- }
- }
-
- private lateinit var appListAdapter: AppListAdapter
- private lateinit var enabledToggle: ToggleCell
- private lateinit var excludeApplicationsFadeOut: ObjectAnimator
- private lateinit var loadingSpinnerFadeIn: ObjectAnimator
- private lateinit var titleController: CollapsibleTitleController
-
- private lateinit var excludeApplications: View
- private lateinit var loadingSpinner: View
-
- override fun onSafelyCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- val view = inflater.inflate(R.layout.split_tunneling, container, false)
-
- view.findViewById<View>(R.id.back).setOnClickListener {
- activity?.onBackPressed()
- }
-
- titleController = CollapsibleTitleController(view, R.id.app_list)
-
- appListAdapter = AppListAdapter(parentActivity, splitTunneling)
-
- view.findViewById<CustomRecyclerView>(R.id.app_list).apply {
- layoutManager = LinearLayoutManager(parentActivity)
-
- adapter = AdapterWithHeader(appListAdapter, R.layout.split_tunneling_header).apply {
- onHeaderAvailable = { headerView ->
- configureHeader(headerView)
- titleController.expandedTitleView = headerView.findViewById(R.id.expanded_title)
- }
- }
-
- addItemDecoration(
- ListItemDividerDecoration(
- bottomOffset = resources.getDimensionPixelSize(R.dimen.list_item_divider)
- )
- )
- }
-
- return view
- }
-
- override fun onSafelyStop() {
- jobTracker.newBackgroundJob("persistExcludedApps") {
- splitTunneling.persist()
- }
- }
-
- override fun onSafelyDestroyView() {
- titleController.onDestroy()
- }
-
- private fun configureHeader(header: View) {
- excludeApplications = header.findViewById(R.id.exclude_applications)
- loadingSpinner = header.findViewById(R.id.loading_spinner)
-
- excludeApplicationsFadeOut =
- ObjectAnimator.ofFloat(excludeApplications, "alpha", 1.0f, 0.0f).apply {
- addListener(excludeApplicationsFadeOutListener)
- setDuration(200)
- }
-
- loadingSpinnerFadeIn =
- ObjectAnimator.ofFloat(loadingSpinner, "alpha", 0.0f, 1.0f).apply {
- addListener(loadingSpinnerFadeOutListener)
- setDuration(200)
- }
-
- if (configureSpinner()) {
- jobTracker.newUiJob("enableAdapter") {
- loadingSpinner.visibility = View.GONE
- appListAdapter.enabled = true
- }
- }
-
- if (splitTunneling.enabled) {
- jobTracker.newUiJob("showExcludedApplications") {
- excludeApplications.visibility = View.VISIBLE
- }
- }
-
- enabledToggle = header.findViewById<ToggleCell>(R.id.enabled).apply {
- if (splitTunneling.enabled) {
- forcefullySetState(CellSwitch.State.ON)
- } else {
- forcefullySetState(CellSwitch.State.OFF)
- }
-
- listener = { toggleState ->
- when (toggleState) {
- CellSwitch.State.ON -> enable()
- CellSwitch.State.OFF -> disable()
- }
- }
- }
-
- header.findViewById<View>(R.id.enabled).setOnClickListener {
- enabledToggle.toggle()
- }
- }
-
- private fun enable() {
- splitTunneling.enabled = true
- appListAdapter.enabled = configureSpinner()
- excludeApplications.visibility = View.VISIBLE
- excludeApplicationsFadeOut.reverse()
- }
-
- private fun disable() {
- splitTunneling.enabled = false
- appListAdapter.enabled = false
- excludeApplicationsFadeOut.start()
- }
-
- private fun configureSpinner(): Boolean {
- if (splitTunneling.enabled && !appListAdapter.isListReady) {
- showLoadingSpinner()
-
- appListAdapter.onListReady = {
- hideLoadingSpinner()
- }
-
- return false
- } else {
- return splitTunneling.enabled
- }
- }
-
- private fun showLoadingSpinner() {
- loadingSpinner.visibility = View.VISIBLE
- loadingSpinnerFadeIn.start()
- }
-
- private fun hideLoadingSpinner() {
- loadingSpinnerFadeIn.reverse()
- }
-}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragment.kt
new file mode 100644
index 0000000000..82d647fc3b
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragment.kt
@@ -0,0 +1,127 @@
+package net.mullvad.mullvadvpn.ui.fragments
+
+import android.os.Build
+import android.os.Bundle
+import android.view.KeyCharacterMap
+import android.view.KeyEvent
+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 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.ui.ListItemDividerDecoration
+import net.mullvad.mullvadvpn.ui.ListItemListener
+import net.mullvad.mullvadvpn.ui.ListItemsAdapter
+import net.mullvad.mullvadvpn.util.setMargins
+import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
+import org.koin.android.ext.android.getKoin
+import org.koin.androidx.viewmodel.ViewModelOwner
+import org.koin.androidx.viewmodel.scope.viewModel
+import org.koin.core.qualifier.named
+import org.koin.core.scope.Scope
+
+class SplitTunnelingFragment : BaseFragment(R.layout.collapsed_title_layout) {
+ private val listItemsAdapter = ListItemsAdapter()
+ private val scope: Scope = getKoin().getOrCreateScope(APPS_SCOPE, named(APPS_SCOPE))
+ .also { appsScope ->
+ getKoin().getScopeOrNull(SERVICE_CONNECTION_SCOPE)?.let { serviceConnectionScope ->
+ appsScope.linkTo(serviceConnectionScope)
+ }
+ }
+ private val viewModel by scope.viewModel<SplitTunnelingViewModel>(
+ owner = {
+ ViewModelOwner.from(this, this)
+ }
+ )
+ private val toggleExcludeChannel = Channel<ListItemData>(Channel.BUFFERED)
+ private val listItemListener = object : ListItemListener {
+ override fun onItemAction(item: ListItemData) {
+ toggleExcludeChannel.offer(item)
+ }
+ }
+
+ private var recyclerView: RecyclerView? = null
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ view.findViewById<CollapsingToolbarLayout>(R.id.collapsing_toolbar).apply {
+ title = resources.getString(R.string.split_tunneling)
+ }
+ listItemsAdapter.listItemListener = listItemListener
+ listItemsAdapter.setHasStableIds(true)
+ recyclerView = view.findViewById<RecyclerView>(R.id.recyclerView).apply {
+ adapter = listItemsAdapter
+ addItemDecoration(
+ ListItemDividerDecoration(
+ topOffset = resources.getDimensionPixelSize(R.dimen.list_item_divider)
+ )
+ )
+ 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) }
+ )
+
+ 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/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ApplicationListItemView.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ApplicationListItemView.kt
new file mode 100644
index 0000000000..798d812802
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/listitemview/ApplicationListItemView.kt
@@ -0,0 +1,79 @@
+package net.mullvad.mullvadvpn.ui.listitemview
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.view.isVisible
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.applist.ApplicationsIconManager
+import net.mullvad.mullvadvpn.di.APPS_SCOPE
+import org.koin.core.component.KoinApiExtension
+import org.koin.core.scope.KoinScopeComponent
+import org.koin.core.scope.Scope
+import org.koin.core.scope.inject
+
+@OptIn(KoinApiExtension::class)
+class ApplicationListItemView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = R.attr.applicationListItemViewStyle,
+ defStyleRes: Int = 0
+) : ActionListItemView(context, attrs, defStyleAttr, defStyleRes), KoinScopeComponent {
+ private val viewScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private val iconManager: ApplicationsIconManager by inject()
+
+ private var updateImageJob: Job? = null
+
+ override val scope: Scope = getKoin().getScope(APPS_SCOPE)
+
+ init {
+ itemText.setTextAppearance(R.style.TextAppearance_Mullvad_Title2)
+ updateImage(ResourcesCompat.getDrawable(resources, R.drawable.ic_icons_missing, null)!!)
+ }
+
+ override fun updateImage() {
+ itemIcon.isVisible = true
+ 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()
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ updateImageJob?.cancel()
+ viewScope.coroutineContext.cancelChildren()
+ }
+
+ private suspend fun loadImage(): Drawable? = withContext(Dispatchers.Default) {
+ try {
+ iconManager.getAppIcon(itemData.identifier)
+ } catch (e: PackageManager.NameNotFoundException) {
+ null
+ }
+ }
+
+ private fun updateImage(drawable: Drawable) = itemIcon.setImageDrawable(drawable)
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/ViewKtx.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/ViewKtx.kt
new file mode 100644
index 0000000000..fb4a4c65b6
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/ViewKtx.kt
@@ -0,0 +1,15 @@
+package net.mullvad.mullvadvpn.util
+
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup.MarginLayoutParams
+
+fun View.setMargins(l: Int? = null, t: Int? = null, r: Int? = null, b: Int? = null) {
+ if (this.layoutParams is MarginLayoutParams) {
+ val p = this.layoutParams as MarginLayoutParams
+ p.setMargins(l ?: p.leftMargin, t ?: p.topMargin, r ?: p.rightMargin, b ?: p.bottomMargin)
+ this.requestLayout()
+ } else {
+ Log.w("mullvad", "setMargins is not supported")
+ }
+}
diff --git a/android/src/main/res/layout/collapsed_title_layout.xml b/android/src/main/res/layout/collapsed_title_layout.xml
new file mode 100644
index 0000000000..64ad3ed2d6
--- /dev/null
+++ b/android/src/main/res/layout/collapsed_title_layout.xml
@@ -0,0 +1,65 @@
+<?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_advanced" />
+ </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/src/main/res/layout/list_item_widget_edit_text.xml b/android/src/main/res/layout/list_item_widget_edit_text.xml
deleted file mode 100644
index 13b24c9d80..0000000000
--- a/android/src/main/res/layout/list_item_widget_edit_text.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.appcompat.widget.AppCompatEditText xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_gravity="center"
- android:background="@drawable/cell_input_background"
- android:digits="0123456789"
- android:gravity="center"
- android:hint="@string/hint_default"
- android:imeOptions="flagNoPersonalizedLearning"
- android:inputType="number"
- android:maxWidth="130dp"
- android:maxLength="4"
- android:minWidth="@dimen/cell_input_width"
- android:paddingHorizontal="8dp"
- android:paddingVertical="4dp"
- android:singleLine="true"
- android:textColor="@color/white"
- android:textColorHint="@color/white80"
- android:textCursorDrawable="@drawable/cell_input_cursor"
- android:textSize="@dimen/text_medium_plus" />
diff --git a/android/src/main/res/values/dimensions.xml b/android/src/main/res/values/dimensions.xml
index 3d42e74afd..c8e8b2ff33 100644
--- a/android/src/main/res/values/dimensions.xml
+++ b/android/src/main/res/values/dimensions.xml
@@ -45,6 +45,7 @@
<dimen name="progress_size">60dp</dimen>
<dimen name="icon_size">24dp</dimen>
<dimen name="widget_padding">16dp</dimen>
+ <dimen name="expanded_toolbar_height">104dp</dimen>
<!-- Switch Dimens-->
<dimen name="switch_width">46dp</dimen>
<dimen name="switch_height">30dp</dimen>
diff --git a/android/src/main/res/values/styles.xml b/android/src/main/res/values/styles.xml
index 2a2734505a..742cc42c4f 100644
--- a/android/src/main/res/values/styles.xml
+++ b/android/src/main/res/values/styles.xml
@@ -8,6 +8,8 @@
<item name="switchStyle">@style/AppTheme.Switch</item>
<item name="actionListItemViewStyle">@style/ListItem.Action</item>
<item name="applicationListItemViewStyle">@style/ListItem.Action.Application</item>
+ <item name="android:spotShadowAlpha">0</item>
+ <item name="actionBarSize">48dp</item>
</style>
<style name="InputText"
parent="Widget.AppCompat.EditText">