diff options
| author | David Göransson <david.goransson@mullvad.net> | 2026-02-09 14:52:39 +0100 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2026-02-09 14:52:39 +0100 |
| commit | 65e1e0bc25fce156600934d1061c7f8efd7dcb53 (patch) | |
| tree | de2f334cb053b10b6783a6cc3d7ebc8926a2a1e6 | |
| parent | dce76c4e2f01b87922e6b4c540a9883ea7cd5065 (diff) | |
| parent | 04b8e731b0f71ad4b1ff90dada13504207d79da8 (diff) | |
| download | mullvadvpn-pre-investigate-migration-from-cjs-to-esm-tb-4h-des-1739.tar.xz mullvadvpn-pre-investigate-migration-from-cjs-to-esm-tb-4h-des-1739.zip | |
Merge branch 'split-tunneling-feature-module'pre-investigate-migration-from-cjs-to-esm-tb-4h-des-1739
25 files changed, 241 insertions, 211 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9a66c364c0..ae02154e2b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -387,6 +387,7 @@ dependencies { implementation(projects.lib.grpc) implementation(projects.lib.endpoint) implementation(projects.lib.feature.daita.impl) + implementation(projects.lib.feature.splittunneling.impl) implementation(projects.lib.map) implementation(projects.lib.model) implementation(projects.lib.navigation) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/SplitTunnelingContentKey.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/SplitTunnelingContentKey.kt deleted file mode 100644 index 28a123410f..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/SplitTunnelingContentKey.kt +++ /dev/null @@ -1,7 +0,0 @@ -package net.mullvad.mullvadvpn.compose.constant - -object SplitTunnelingContentKey { - const val EXCLUDED_APPLICATIONS = "excluded" - const val SHOW_SYSTEM_APPLICATIONS = "show_system" - const val INCLUDED_APPLICATIONS = "included" -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SplitTunnelingUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SplitTunnelingUiStatePreviewParameterProvider.kt deleted file mode 100644 index adb79292af..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SplitTunnelingUiStatePreviewParameterProvider.kt +++ /dev/null @@ -1,43 +0,0 @@ -package net.mullvad.mullvadvpn.compose.preview - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.applist.AppData -import net.mullvad.mullvadvpn.lib.common.Lc -import net.mullvad.mullvadvpn.lib.common.toLc -import net.mullvad.mullvadvpn.viewmodel.Loading -import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingUiState - -class SplitTunnelingUiStatePreviewParameterProvider : - PreviewParameterProvider<Lc<Loading, SplitTunnelingUiState>> { - override val values = - sequenceOf( - SplitTunnelingUiState( - enabled = true, - excludedApps = - listOf( - AppData( - packageName = "my.package.a", - name = "TitleA", - iconRes = R.drawable.icon_android, - ), - AppData( - packageName = "my.package.b", - name = "TitleB", - iconRes = R.drawable.icon_android, - ), - ), - includedApps = - listOf( - AppData( - packageName = "my.package.c", - name = "TitleC", - iconRes = R.drawable.icon_android, - ) - ), - showSystemApps = true, - ) - .toLc(), - Lc.Loading(Loading(enabled = true)), - ) -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 522920b9ce..667648d4fd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -77,8 +77,8 @@ import com.ramcosta.composedestinations.generated.destinations.OutOfTimeDestinat import com.ramcosta.composedestinations.generated.destinations.SelectLocationDestination import com.ramcosta.composedestinations.generated.destinations.ServerIpOverridesDestination import com.ramcosta.composedestinations.generated.destinations.SettingsDestination -import com.ramcosta.composedestinations.generated.destinations.SplitTunnelingDestination import com.ramcosta.composedestinations.generated.destinations.VpnSettingsDestination +import com.ramcosta.composedestinations.generated.splittunneling.destinations.SplitTunnelingDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.result.ResultRecipient import kotlinx.coroutines.launch diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt index 9bc5d916c0..3f2d6e53c8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -43,6 +43,7 @@ annotation class MainGraph { @ExternalDestination<DaitaDestination> @ExternalDestination<DaitaDirectOnlyInfoDestination> @ExternalDestination<DaitaDirectOnlyConfirmationDestination> + // @ExternalDestination<SplitTunnelingDestination> companion object Includes } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt index 26110c45fe..bce63ad94e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt @@ -28,8 +28,8 @@ import com.ramcosta.composedestinations.generated.destinations.AppearanceDestina import com.ramcosta.composedestinations.generated.destinations.MultihopDestination import com.ramcosta.composedestinations.generated.destinations.NotificationSettingsDestination import com.ramcosta.composedestinations.generated.destinations.ReportProblemDestination -import com.ramcosta.composedestinations.generated.destinations.SplitTunnelingDestination import com.ramcosta.composedestinations.generated.destinations.VpnSettingsDestination +import com.ramcosta.composedestinations.generated.splittunneling.destinations.SplitTunnelingDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.extensions.createUriHook diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 745a220510..a02096cae6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -5,13 +5,14 @@ import android.os.Build import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import net.mullvad.mullvadvpn.BuildConfig -import net.mullvad.mullvadvpn.applist.ApplicationsProvider import net.mullvad.mullvadvpn.compose.screen.location.LocationBottomSheetState import net.mullvad.mullvadvpn.compose.screen.location.RelayListScrollConnection import net.mullvad.mullvadvpn.compose.util.BackstackObserver import net.mullvad.mullvadvpn.constant.IS_FDROID_BUILD import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.feature.daita.impl.DaitaViewModel +import net.mullvad.mullvadvpn.feature.splittunneling.impl.SplitTunnelingViewModel +import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.ApplicationsProvider import net.mullvad.mullvadvpn.lib.model.RelayListType import net.mullvad.mullvadvpn.lib.payment.PaymentProvider import net.mullvad.mullvadvpn.lib.repository.ApiAccessRepository @@ -104,7 +105,6 @@ import net.mullvad.mullvadvpn.viewmodel.SelectPortViewModel import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import net.mullvad.mullvadvpn.viewmodel.SplashViewModel -import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PackageManagerExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PackageManagerExtensions.kt index fc6acfeb6c..20d8f4fc8e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PackageManagerExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PackageManagerExtensions.kt @@ -1,18 +1 @@ package net.mullvad.mullvadvpn.util - -import android.content.pm.PackageManager -import android.graphics.drawable.Drawable - -fun PackageManager.getApplicationIconOrNull(packageName: String): Drawable? = - try { - getApplicationIcon(packageName) - } catch (e: PackageManager.NameNotFoundException) { - // Name not found is thrown if the application is not installed - null - } catch (e: IllegalArgumentException) { - // IllegalArgumentException is thrown if the application has an invalid icon - null - } catch (e: OutOfMemoryError) { - // OutOfMemoryError is thrown if the icon is too large - null - } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt deleted file mode 100644 index fdb828dae0..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt +++ /dev/null @@ -1,44 +0,0 @@ -package net.mullvad.mullvadvpn.viewmodel - -import net.mullvad.mullvadvpn.applist.AppData -import net.mullvad.mullvadvpn.lib.common.Lc -import net.mullvad.mullvadvpn.lib.common.toLc -import net.mullvad.mullvadvpn.lib.model.AppId - -data class SplitTunnelingViewModelState( - val enabled: Boolean = false, - val excludedApps: Set<AppId> = emptySet(), - val allApps: List<AppData>? = null, - val showSystemApps: Boolean = false, -) { - fun toUiState(isModal: Boolean): Lc<Loading, SplitTunnelingUiState> { - return allApps - ?.partition { appData -> - if (enabled) { - excludedApps.contains(AppId(appData.packageName)) - } else { - false - } - } - ?.let { (excluded, included) -> - SplitTunnelingUiState( - enabled = enabled, - excludedApps = excluded.sortedWith(descendingByNameComparator), - includedApps = - if (showSystemApps) { - included - } else { - included.filter { appData -> !appData.isSystemApp } - } - .sortedWith(descendingByNameComparator), - showSystemApps = showSystemApps, - isModal = isModal, - ) - .toLc() - } ?: Lc.Loading(Loading(enabled = enabled, isModal)) - } - - companion object { - private val descendingByNameComparator = compareBy<AppData> { it.name.lowercase() } - } -} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 9a32a80fce..22e4020023 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -71,6 +71,8 @@ protobuf = "4.33.4" protobuf-gradle-plugin = "0.9.6" turbine = "1.2.1" annotation-jvm = "1.9.1" +junit-version = "4.13.2" +material = "1.10.0" [libraries] accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "drawablepainter" } @@ -166,6 +168,8 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +junit = { group = "junit", name = "junit", version.ref = "junit-version" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } diff --git a/android/lib/feature/splittunneling/impl/build.gradle.kts b/android/lib/feature/splittunneling/impl/build.gradle.kts new file mode 100644 index 0000000000..9a4ae9d26d --- /dev/null +++ b/android/lib/feature/splittunneling/impl/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.mullvad.android.library) + alias(libs.plugins.mullvad.android.library.feature.impl) + alias(libs.plugins.mullvad.android.library.compose) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.ksp) +} + +android { + namespace = "net.mullvad.mullvadvpn.feature.splittunneling.impl" + ksp { arg("compose-destinations.moduleName", "splittunneling") } +} + +dependencies { + implementation(projects.lib.repository) + + implementation(libs.koin.compose) + implementation(libs.arrow) + + // Destinations + implementation(libs.compose.destinations) + ksp(libs.compose.destinations.ksp) +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt b/android/lib/feature/splittunneling/impl/src/androidTest/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingScreenTest.kt index b2404a8599..6ab53115a1 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreenTest.kt +++ b/android/lib/feature/splittunneling/impl/src/androidTest/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingScreenTest.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.screen +package net.mullvad.mullvadvpn.feature.splittunneling.impl import android.graphics.drawable.Drawable import androidx.compose.ui.test.ExperimentalTestApi @@ -9,13 +9,11 @@ import io.mockk.MockKAnnotations import io.mockk.mockk import io.mockk.unmockkAll import io.mockk.verify -import net.mullvad.mullvadvpn.applist.AppData +import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.AppData import net.mullvad.mullvadvpn.lib.common.Lc import net.mullvad.mullvadvpn.lib.common.toLc import net.mullvad.mullvadvpn.screen.test.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.screen.test.setContentWithTheme -import net.mullvad.mullvadvpn.viewmodel.Loading -import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingUiState import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test diff --git a/android/lib/feature/splittunneling/impl/src/main/AndroidManifest.xml b/android/lib/feature/splittunneling/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/android/lib/feature/splittunneling/impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + +</manifest> diff --git a/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/ContentType.kt b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/ContentType.kt new file mode 100644 index 0000000000..3b4c2cac79 --- /dev/null +++ b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/ContentType.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.feature.splittunneling.impl + +internal object ContentType { + const val HEADER = 1 + const val ITEM = 2 + const val OTHER_ITEM = 3 + const val DESCRIPTION = 4 + const val SPACER = 5 + const val PROGRESS = 6 +} diff --git a/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingContentKey.kt b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingContentKey.kt new file mode 100644 index 0000000000..9e2baa62fd --- /dev/null +++ b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingContentKey.kt @@ -0,0 +1 @@ +package net.mullvad.mullvadvpn.feature.splittunneling.impl diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingScreen.kt index 394bd0d6d1..9a2ee16158 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt +++ b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingScreen.kt @@ -1,5 +1,6 @@ -package net.mullvad.mullvadvpn.compose.screen +package net.mullvad.mullvadvpn.feature.splittunneling.impl +import android.content.pm.PackageManager import android.graphics.drawable.Drawable import android.os.Parcelable import androidx.compose.animation.AnimatedVisibilityScope @@ -11,8 +12,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,6 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource @@ -34,21 +39,15 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.ExternalModuleGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.applist.AppData -import net.mullvad.mullvadvpn.compose.constant.CommonContentKey -import net.mullvad.mullvadvpn.compose.constant.ContentType -import net.mullvad.mullvadvpn.compose.constant.SplitTunnelingContentKey -import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider -import net.mullvad.mullvadvpn.compose.extensions.itemsIndexedWithDivider -import net.mullvad.mullvadvpn.compose.preview.SplitTunnelingUiStatePreviewParameterProvider -import net.mullvad.mullvadvpn.compose.util.hasValidSize -import net.mullvad.mullvadvpn.compose.util.isBelowMaxByteSize import net.mullvad.mullvadvpn.core.animation.SlideInFromRightTransition +import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.AppData +import net.mullvad.mullvadvpn.feature.splittunneling.impl.extensions.hasValidSize +import net.mullvad.mullvadvpn.feature.splittunneling.impl.extensions.isBelowMaxByteSize import net.mullvad.mullvadvpn.lib.common.Lc import net.mullvad.mullvadvpn.lib.model.FeatureIndicator import net.mullvad.mullvadvpn.lib.ui.component.NavigateBackIconButton @@ -65,10 +64,6 @@ import net.mullvad.mullvadvpn.lib.ui.theme.AppTheme import net.mullvad.mullvadvpn.lib.ui.theme.Dimens import net.mullvad.mullvadvpn.lib.ui.theme.color.AlphaDisabled import net.mullvad.mullvadvpn.lib.ui.theme.color.AlphaVisible -import net.mullvad.mullvadvpn.util.getApplicationIconOrNull -import net.mullvad.mullvadvpn.viewmodel.Loading -import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingUiState -import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import org.koin.androidx.compose.koinViewModel @Preview("ShowAppList|Loading") @@ -93,7 +88,7 @@ private fun PreviewSplitTunnelingScreen( @Parcelize data class SplitTunnelingNavArgs(val isModal: Boolean = false) : Parcelable @OptIn(ExperimentalSharedTransitionApi::class) -@Destination<MainGraph>( +@Destination<ExternalModuleGraph>( style = SlideInFromRightTransition::class, navArgs = SplitTunnelingNavArgs::class, ) @@ -377,3 +372,49 @@ private fun Lc<Loading, SplitTunnelingUiState>.enabled(): Boolean = is Lc.Loading -> this.value.enabled is Lc.Content -> this.value.enabled } + +fun PackageManager.getApplicationIconOrNull(packageName: String): Drawable? = + try { + getApplicationIcon(packageName) + } catch (e: PackageManager.NameNotFoundException) { + // Name not found is thrown if the application is not installed + null + } catch (e: IllegalArgumentException) { + // IllegalArgumentException is thrown if the application has an invalid icon + null + } catch (e: OutOfMemoryError) { + // OutOfMemoryError is thrown if the icon is too large + null + } + +object CommonContentKey { + const val DESCRIPTION = "description" + const val PROGRESS = "progress" +} + +private inline fun <T> LazyListScope.itemsIndexedWithDivider( + items: List<T>, + noinline key: ((index: Int, item: T) -> Any)? = null, + crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null }, + crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit, +) = + itemsIndexed(items = items, key = key, contentType = contentType) { index, item -> + itemContent(index, item) + HorizontalDivider(color = Color.Transparent) + } + +private inline fun LazyListScope.itemWithDivider( + key: Any? = null, + contentType: Any? = null, + crossinline itemContent: @Composable LazyItemScope.() -> Unit, +) = + item(key = key, contentType = contentType) { + itemContent() + HorizontalDivider(color = Color.Transparent) + } + +internal object SplitTunnelingContentKey { + const val EXCLUDED_APPLICATIONS = "excluded" + const val SHOW_SYSTEM_APPLICATIONS = "show_system" + const val INCLUDED_APPLICATIONS = "included" +} diff --git a/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingUiState.kt b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingUiState.kt new file mode 100644 index 0000000000..7bb091ea1c --- /dev/null +++ b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingUiState.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.feature.splittunneling.impl + +import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.AppData + +data class Loading(val enabled: Boolean = false, val isModal: Boolean = false) + +data class SplitTunnelingUiState( + val enabled: Boolean = false, + val excludedApps: List<AppData> = emptyList(), + val includedApps: List<AppData> = emptyList(), + val showSystemApps: Boolean = false, + val isModal: Boolean = false, +) diff --git a/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingUiStatePreviewParameterProvider.kt b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000000..94524e1909 --- /dev/null +++ b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingUiStatePreviewParameterProvider.kt @@ -0,0 +1,51 @@ +package net.mullvad.mullvadvpn.feature.splittunneling.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.AppData +import net.mullvad.mullvadvpn.lib.common.Lc +import net.mullvad.mullvadvpn.lib.common.toLc +import net.mullvad.mullvadvpn.lib.ui.resource.R + +class SplitTunnelingUiStatePreviewParameterProvider : + PreviewParameterProvider<Lc<Loading, SplitTunnelingUiState>> { + override val values = + sequenceOf( + SplitTunnelingUiState( + enabled = true, + excludedApps = excludedApps, + includedApps = includedApps, + showSystemApps = true, + ) + .toLc(), + SplitTunnelingUiState( + enabled = true, + excludedApps = excludedApps, + includedApps = includedApps.filter { !it.isSystemApp }, + showSystemApps = false, + ) + .toLc(), + Lc.Loading(Loading(enabled = true)), + ) +} + +private val excludedApps = + listOf( + AppData(packageName = "my.package.a", name = "TitleA", iconRes = R.drawable.icon_android), + AppData(packageName = "my.package.b", name = "TitleB", iconRes = R.drawable.icon_android), + AppData( + packageName = "my.package.c", + name = "TitleC (System app)", + iconRes = R.drawable.icon_android, + isSystemApp = true, + ), + ) +private val includedApps = + listOf( + AppData(packageName = "my.package.d", name = "TitleD", iconRes = R.drawable.icon_android), + AppData( + packageName = "my.package.e", + name = "TitleE (System app)", + iconRes = R.drawable.icon_android, + isSystemApp = true, + ), + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingViewModel.kt index ce1dc92b75..3a9a4d5b58 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt +++ b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingViewModel.kt @@ -1,22 +1,22 @@ -package net.mullvad.mullvadvpn.viewmodel +package net.mullvad.mullvadvpn.feature.splittunneling.impl import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.destinations.SplitTunnelingDestination +import com.ramcosta.composedestinations.generated.splittunneling.destinations.SplitTunnelingDestination import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.applist.AppData -import net.mullvad.mullvadvpn.applist.ApplicationsProvider +import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.AppData +import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.ApplicationsProvider import net.mullvad.mullvadvpn.lib.common.Lc import net.mullvad.mullvadvpn.lib.common.constant.VIEW_MODEL_STOP_TIMEOUT +import net.mullvad.mullvadvpn.lib.common.toLc import net.mullvad.mullvadvpn.lib.model.AppId import net.mullvad.mullvadvpn.lib.repository.SplitTunnelingRepository @@ -31,29 +31,37 @@ class SplitTunnelingViewModel( private val allApps = MutableStateFlow<List<AppData>?>(null) private val showSystemApps = MutableStateFlow(false) - private val vmState: StateFlow<SplitTunnelingViewModelState> = + val uiState: StateFlow<Lc<Loading, SplitTunnelingUiState>> = combine( splitTunnelingRepository.excludedApps, splitTunnelingRepository.splitTunnelingEnabled, allApps, showSystemApps, ) { excludedApps, enabled, allApps, showSystemApps -> - SplitTunnelingViewModelState( - excludedApps = excludedApps, - enabled = enabled, - allApps = allApps, - showSystemApps = showSystemApps, - ) - } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(VIEW_MODEL_STOP_TIMEOUT), - SplitTunnelingViewModelState(), - ) + if (allApps == null) { + return@combine Lc.Loading(Loading(enabled = enabled, isModal = navArgs.isModal)) + } - val uiState = - vmState - .map { it.toUiState(navArgs.isModal) } + val (excludedApps, includedApps) = + allApps.partition { appData -> + if (enabled) { + excludedApps.contains(AppId(appData.packageName)) + } else { + false + } + } + + SplitTunnelingUiState( + enabled = enabled, + excludedApps = excludedApps, + includedApps = + if (showSystemApps) includedApps + else includedApps.filter { appData -> !appData.isSystemApp }, + showSystemApps = showSystemApps, + isModal = navArgs.isModal, + ) + .toLc() + } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(VIEW_MODEL_STOP_TIMEOUT), @@ -87,16 +95,6 @@ class SplitTunnelingViewModel( } private suspend fun fetchApps() { - appsProvider.getAppsList().let { appsList -> allApps.emit(appsList) } + appsProvider.apps().let { appsList -> allApps.emit(appsList) } } } - -data class Loading(val enabled: Boolean = false, val isModal: Boolean = false) - -data class SplitTunnelingUiState( - val enabled: Boolean = false, - val excludedApps: List<AppData> = emptyList(), - val includedApps: List<AppData> = emptyList(), - val showSystemApps: Boolean = false, - val isModal: Boolean = false, -) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppData.kt b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/applist/AppData.kt index 16b6ce70c3..9c199567bf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppData.kt +++ b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/applist/AppData.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.applist +package net.mullvad.mullvadvpn.feature.splittunneling.impl.applist data class AppData( val packageName: String, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProvider.kt b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/applist/ApplicationsProvider.kt index e38bd77409..c913a865c2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProvider.kt +++ b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/applist/ApplicationsProvider.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.applist +package net.mullvad.mullvadvpn.feature.splittunneling.impl.applist import android.Manifest import android.content.pm.ApplicationInfo @@ -12,7 +12,9 @@ class ApplicationsProvider( hasInternetPermission(appInfo.packageName) && !isSelfApplication(appInfo.packageName) } - fun getAppsList(): List<AppData> { + private val descendingByNameComparator = compareBy<AppData> { it.name.lowercase() } + + fun apps(): List<AppData> { return packageManager .getInstalledApplications(PackageManager.GET_META_DATA) .asSequence() @@ -26,6 +28,7 @@ class ApplicationsProvider( ) } .toList() + .sortedWith(descendingByNameComparator) } private fun hasInternetPermission(packageName: String): Boolean { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Drawable.kt b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/extensions/DrawableExtensions.kt index 7d7e2605eb..08d15fff21 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Drawable.kt +++ b/android/lib/feature/splittunneling/impl/src/main/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/extensions/DrawableExtensions.kt @@ -1,11 +1,11 @@ -package net.mullvad.mullvadvpn.compose.util +package net.mullvad.mullvadvpn.feature.splittunneling.impl.extensions import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable private const val MAX_BITMAP_SIZE_BYTES = 100 * 1024 * 1024 // 100MB -fun Drawable.isBelowMaxByteSize(): Boolean = +internal fun Drawable.isBelowMaxByteSize(): Boolean = if (this is BitmapDrawable) bitmap.byteCount < MAX_BITMAP_SIZE_BYTES else true -fun Drawable.hasValidSize(): Boolean = intrinsicHeight > 0 && intrinsicWidth > 0 +internal fun Drawable.hasValidSize(): Boolean = intrinsicHeight > 0 && intrinsicWidth > 0 diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt b/android/lib/feature/splittunneling/impl/src/test/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingViewModelTest.kt index de0a8656cd..0a3cdcd4ac 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt +++ b/android/lib/feature/splittunneling/impl/src/test/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/SplitTunnelingViewModelTest.kt @@ -1,9 +1,9 @@ -package net.mullvad.mullvadvpn.viewmodel +package net.mullvad.mullvadvpn.feature.splittunneling.impl import androidx.lifecycle.viewModelScope import app.cash.turbine.test import arrow.core.right -import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle +import com.ramcosta.composedestinations.generated.splittunneling.navargs.toSavedStateHandle import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -18,9 +18,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.applist.AppData -import net.mullvad.mullvadvpn.applist.ApplicationsProvider -import net.mullvad.mullvadvpn.compose.screen.SplitTunnelingNavArgs +import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.AppData +import net.mullvad.mullvadvpn.feature.splittunneling.impl.applist.ApplicationsProvider import net.mullvad.mullvadvpn.lib.common.Lc import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.AppId @@ -65,7 +64,7 @@ class SplitTunnelingViewModelTest { assertIs<Lc.Loading<Loading>>(actualState) assertEquals(initialExpectedState, actualState) - verify(exactly = 1) { mockedApplicationsProvider.getAppsList() } + verify(exactly = 1) { mockedApplicationsProvider.apps() } } @Test @@ -199,31 +198,8 @@ class SplitTunnelingViewModelTest { } } - @Test - fun `apps should be sorted by name in descending order`() = runTest { - // Arrange - val app1 = AppData("com.example.app1", 0, "App A") - val app2 = AppData("com.example.app2", 0, "App B") - val app3 = AppData("com.example.app3", 0, "App Z") - val appList = listOf(app2, app1, app3) - val expectedState = - SplitTunnelingUiState( - enabled = true, - includedApps = listOf(app1, app2, app3), - showSystemApps = false, - ) - initTestSubject(appList = appList) - - // Assert - testSubject.uiState.test { - val actualState = awaitItem() - assertIs<Lc.Content<SplitTunnelingUiState>>(actualState) - assertEquals(expectedState, actualState.value) - } - } - private fun initTestSubject(appList: List<AppData>) { - every { mockedApplicationsProvider.getAppsList() } returns appList + every { mockedApplicationsProvider.apps() } returns appList testSubject = SplitTunnelingViewModel( mockedApplicationsProvider, diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProviderTest.kt b/android/lib/feature/splittunneling/impl/src/test/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/applist/ApplicationsProviderTest.kt index efa97c6ab0..2ffbb8f34a 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/applist/ApplicationsProviderTest.kt +++ b/android/lib/feature/splittunneling/impl/src/test/java/net/mullvad/mullvadvpn/feature/splittunneling/impl/applist/ApplicationsProviderTest.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.applist +package net.mullvad.mullvadvpn.feature.splittunneling.impl.applist import android.Manifest import android.annotation.SuppressLint @@ -8,6 +8,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll import io.mockk.verifyAll +import kotlin.test.assertEquals import net.mullvad.mullvadvpn.lib.common.test.assertLists import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -55,7 +56,7 @@ class ApplicationsProviderTest { createApplicationInfo(selfPackageName, internet = true, launch = true), ) - val result = testSubject.getAppsList() + val result = testSubject.apps() val expected = listOf( AppData(launchWithInternetPackageName, 0, launchWithInternetPackageName), @@ -107,6 +108,21 @@ class ApplicationsProviderTest { } } + @SuppressLint("UseCheckPermission") + @Test + fun `apps should be returned in descending order`() { + val packageNames = listOf("b", "d", "c", "a", "e") + + every { + mockedPackageManager.getInstalledApplications(PackageManager.GET_META_DATA) + } returns packageNames.map { createApplicationInfo(it, launch = true, internet = true) } + + val actual = testSubject.apps() + val expected = packageNames.sorted().map { AppData(it, 0, it) } + + assertEquals(expected, actual) + } + private fun createApplicationInfo( packageName: String, launch: Boolean = false, diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index c49aab0e36..b4698eb222 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,17 +20,15 @@ dependencyResolutionManagement { } includeBuild("rust-android-gradle-plugin") + includeBuild("gradle/build-logic") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") rootProject.name = "MullvadVPN" -include( - ":app", - ":service", - ":tile" -) +include(":app", ":service", ":tile") + include( ":lib:billing", ":lib:common", @@ -38,6 +36,7 @@ include( ":lib:grpc", ":lib:endpoint", ":lib:feature:daita:impl", + ":lib:feature:splittunneling:impl", ":lib:map", ":lib:model", ":lib:navigation", @@ -53,8 +52,9 @@ include( ":lib:ui:tag", ":lib:ui:theme", ":lib:ui:util", - ":lib:usecase" + ":lib:usecase", ) + include( ":test", ":test:arch", |
