diff options
| author | Aleksandr Granin <aleksandr@mullvad.net> | 2021-04-06 14:43:30 +0200 |
|---|---|---|
| committer | Aleksandr Granin <aleksandr@mullvad.net> | 2021-04-19 14:36:22 +0200 |
| commit | b3b5da44ec8bf1f2a39cb33ad31d89dd571ac731 (patch) | |
| tree | f47b084bd46361fb26b95fd66091c393ed8151f7 | |
| parent | 585303d6cb04657d7466a4ba5e1bc6c6eb0227d0 (diff) | |
| download | mullvadvpn-b3b5da44ec8bf1f2a39cb33ad31d89dd571ac731.tar.xz mullvadvpn-b3b5da44ec8bf1f2a39cb33ad31d89dd571ac731.zip | |
Refactor SplitTunneling Fragment, add tests.
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"> |
