diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-05-08 15:29:00 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2023-05-23 16:27:32 +0200 |
| commit | af8ebd0abb958c026d6e6e672ed651289b59b8b9 (patch) | |
| tree | ec6e6475b36f58dd02a9a12d258d80ebfe5e9d44 /android | |
| parent | dfab0c483fbb737a4aa9b59a47d4eba8320c9a4d (diff) | |
| download | mullvadvpn-af8ebd0abb958c026d6e6e672ed651289b59b8b9.tar.xz mullvadvpn-af8ebd0abb958c026d6e6e672ed651289b59b8b9.zip | |
Migrate split tunneling fragment to compose
- Create SplitTunnelingScreen
- Improve SplitTunnelingViewModel
- Improve List compose component
Diffstat (limited to 'android')
11 files changed, 480 insertions, 240 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt new file mode 100644 index 0000000000..13e31a9337 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt @@ -0,0 +1,74 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.ListItem +import net.mullvad.mullvadvpn.compose.theme.MullvadBlue40 +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite40 +import net.mullvad.mullvadvpn.ui.widget.ApplicationImageView + +@Preview +@Composable +fun PreviewTunnelingCell() { + Column(modifier = Modifier.background(color = MullvadWhite40)) { + SplitTunnelingCell("Mullvad VPN", "", false) {} + SplitTunnelingCell("Mullvad VPN", "", true) {} + } +} + +@Composable +fun SplitTunnelingCell( + title: String, + packageName: String?, + isSelected: Boolean, + onCellClicked: () -> Unit +) { + val startPadding = dimensionResource(id = R.dimen.cell_left_padding) + val endPadding = dimensionResource(id = R.dimen.cell_right_padding) + val iconSize = dimensionResource(id = R.dimen.icon_size) + Box( + modifier = + Modifier.background(MullvadBlue40) + .padding(top = 1.dp, bottom = 1.dp, start = startPadding, end = endPadding) + .clickable { onCellClicked() } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + packageName?.let { + AndroidView( + factory = { context -> ApplicationImageView(context) }, + update = { applicationImageView -> + applicationImageView.packageName = packageName + }, + modifier = Modifier.size(width = iconSize, height = iconSize) + ) + } + ListItem( + text = title, + isLoading = false, + iconResourceId = + if (isSelected) { + R.drawable.ic_icons_remove + } else { + R.drawable.ic_icons_add + }, + background = MullvadBlue40, + onClick = null + ) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt index 23fba8981f..88fb4ccee8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -88,15 +89,17 @@ fun ListItem( height: Dp = Dimens.listItemHeight, isLoading: Boolean, @DrawableRes iconResourceId: Int? = null, - onClick: () -> Unit + background: Color? = null, + onClick: (() -> Unit)? ) { + val itemColor = background ?: MaterialTheme.colorScheme.primary Box( modifier = Modifier.fillMaxWidth() .padding(vertical = Dimens.listItemDivider) .wrapContentHeight() .defaultMinSize(minHeight = height) - .background(MaterialTheme.colorScheme.primary), + .background(itemColor), ) { Column( modifier = @@ -133,7 +136,9 @@ fun ListItem( Image( painter = painterResource(id = iconResourceId), contentDescription = "Remove", - modifier = Modifier.align(Alignment.CenterEnd).clickable { onClick() } + modifier = + onClick?.let { Modifier.align(Alignment.CenterEnd).clickable { onClick() } } + ?: Modifier.align(Alignment.CenterEnd) ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt index 5fe6a6d509..453ab50cdf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/LazyListExtensions.kt @@ -6,8 +6,10 @@ import androidx.compose.material.Divider import androidx.compose.runtime.Composable inline fun LazyListScope.itemWithDivider( + contentType: Any? = null, crossinline itemContent: @Composable LazyItemScope.() -> Unit -) = item { - itemContent() - Divider() -} +) = + item(contentType = contentType) { + itemContent() + Divider() + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt new file mode 100644 index 0000000000..22ecea3c71 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt @@ -0,0 +1,202 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import me.onebone.toolbar.ScrollStrategy +import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.applist.AppData +import net.mullvad.mullvadvpn.compose.cell.BaseCell +import net.mullvad.mullvadvpn.compose.cell.SplitTunnelingCell +import net.mullvad.mullvadvpn.compose.cell.SwitchComposeCell +import net.mullvad.mullvadvpn.compose.component.CollapsableAwareToolbarScaffold +import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar +import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider +import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState +import net.mullvad.mullvadvpn.compose.theme.MullvadBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadDarkBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite60 + +@Preview +@Composable +fun PreviewSplitTunnelingScreen() { + SplitTunnelingScreen( + uiState = + SplitTunnelingUiState.Data( + excludedApps = + listOf( + AppData( + packageName = "Package C", + name = "TitleA", + iconRes = R.drawable.icon_alert + ), + AppData( + packageName = "Package G", + name = "TitleB", + iconRes = R.drawable.icon_chevron, + ) + ), + includedApps = + listOf( + AppData( + packageName = "Package I", + name = "TitleC", + iconRes = R.drawable.icon_alert + ) + ), + showSystemApps = true + ) + ) +} + +@Composable +fun SplitTunnelingScreen( + uiState: SplitTunnelingUiState = SplitTunnelingUiState.Loading, + onShowSystemAppsClicked: (show: Boolean) -> Unit = {}, + addToExcluded: (packageName: String) -> Unit = {}, + removeFromExcluded: (packageName: String) -> Unit = {}, + onBackClick: () -> Unit = {}, +) { + val state = rememberCollapsingToolbarScaffoldState() + val progress = state.toolbarState.progress + val mediumPadding = dimensionResource(id = R.dimen.medium_padding) + val progressSize = dimensionResource(id = R.dimen.progress_size) + + CollapsableAwareToolbarScaffold( + backgroundColor = MullvadDarkBlue, + modifier = Modifier.fillMaxSize(), + state = state, + scrollStrategy = ScrollStrategy.ExitUntilCollapsed, + isEnabledWhenCollapsable = true, + toolbar = { + val scaffoldModifier = + Modifier.road( + whenCollapsed = Alignment.TopCenter, + whenExpanded = Alignment.BottomStart + ) + CollapsingTopBar( + backgroundColor = MullvadDarkBlue, + onBackClicked = { onBackClick() }, + title = stringResource(id = R.string.split_tunneling), + progress = progress, + modifier = scaffoldModifier, + backTitle = stringResource(id = R.string.settings_advanced) + ) + }, + ) { + LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) { + item(contentType = TYPE_DESCRIPTION) { + Text( + color = MullvadWhite60, + text = stringResource(id = R.string.split_tunneling_description), + fontSize = 13.sp, + modifier = + Modifier.padding( + start = mediumPadding, + end = mediumPadding, + bottom = mediumPadding + ) + ) + } + when (uiState) { + SplitTunnelingUiState.Loading -> { + item { + CircularProgressIndicator( + color = MullvadWhite, + modifier = Modifier.size(width = progressSize, height = progressSize) + ) + } + } + is SplitTunnelingUiState.Data -> { + if (uiState.excludedApps.isNotEmpty()) { + itemWithDivider(contentType = TYPE_TITLE) { + BaseCell( + title = { + Text( + text = stringResource(id = R.string.exclude_applications), + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ) + }, + bodyView = {}, + background = MullvadBlue, + ) + } + items( + items = uiState.excludedApps, + key = { listItem -> listItem.packageName }, + contentType = { TYPE_APPLICATION } + ) { listItem -> + SplitTunnelingCell( + title = listItem.name, + packageName = listItem.packageName, + isSelected = true + ) { + removeFromExcluded(listItem.packageName) + } + } + item { Spacer(modifier = Modifier.height(mediumPadding)) } + } + + itemWithDivider(contentType = TYPE_SWITCH_CELL) { + SwitchComposeCell( + title = stringResource(id = R.string.show_system_apps), + isToggled = uiState.showSystemApps, + onCellClicked = { newValue -> onShowSystemAppsClicked(newValue) } + ) + } + itemWithDivider(contentType = TYPE_TITLE) { + BaseCell( + title = { + Text( + text = stringResource(id = R.string.all_applications), + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ) + }, + bodyView = {}, + background = MullvadBlue, + ) + } + items( + items = uiState.includedApps, + key = { listItem -> listItem.packageName }, + contentType = { TYPE_APPLICATION } + ) { listItem -> + SplitTunnelingCell( + title = listItem.name, + packageName = listItem.packageName, + isSelected = false + ) { + addToExcluded(listItem.packageName) + } + } + } + } + } + } +} + +const val TYPE_DESCRIPTION = 1 +const val TYPE_TITLE = 2 +const val TYPE_SWITCH_CELL = 3 +const val TYPE_APPLICATION = 4 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt new file mode 100644 index 0000000000..903bb7afdd --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SplitTunnelingUiState.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.applist.AppData + +sealed interface SplitTunnelingUiState { + object Loading : SplitTunnelingUiState + data class Data( + val excludedApps: List<AppData> = emptyList(), + val includedApps: List<AppData> = emptyList(), + val showSystemApps: Boolean = false + ) : SplitTunnelingUiState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt index 8ed9346c2b..77e82280bb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color val MullvadBeige = Color(0xFFFFCD86) val MullvadBlue = Color(0xFF294D73) val MullvadBlue60 = Color(0x99294D73) +val MullvadBlue40 = Color(0x66294D73) val MullvadBlue20 = Color(0x33294D73) val MullvadBrown = Color(0xFFD2943B) val MullvadDarkBlue = Color(0xFF192E45) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt index 27db561ee9..cc7eea32f3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt @@ -1,33 +1,16 @@ package net.mullvad.mullvadvpn.ui.fragment -import android.os.Build import android.os.Bundle -import android.view.KeyCharacterMap -import android.view.KeyEvent +import android.view.LayoutInflater import android.view.View -import android.view.ViewConfiguration -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.appbar.CollapsingToolbarLayout -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.applist.ViewIntent +import net.mullvad.mullvadvpn.compose.screen.SplitTunnelingScreen +import net.mullvad.mullvadvpn.compose.theme.AppTheme import net.mullvad.mullvadvpn.di.APPS_SCOPE import net.mullvad.mullvadvpn.di.SERVICE_CONNECTION_SCOPE -import net.mullvad.mullvadvpn.model.ListItemData -import net.mullvad.mullvadvpn.model.WidgetState.ImageState -import net.mullvad.mullvadvpn.model.WidgetState.SwitchState -import net.mullvad.mullvadvpn.ui.ListItemDividerDecoration -import net.mullvad.mullvadvpn.ui.ListItemListener -import net.mullvad.mullvadvpn.ui.ListItemsAdapter -import net.mullvad.mullvadvpn.util.setMargins import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import org.koin.android.ext.android.getKoin import org.koin.androidx.viewmodel.ViewModelOwner @@ -36,7 +19,6 @@ 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 -> @@ -45,84 +27,30 @@ class SplitTunnelingFragment : BaseFragment(R.layout.collapsed_title_layout) { } private val viewModel by scope.viewModel<SplitTunnelingViewModel>(owner = { ViewModelOwner.from(this, this) }) - private val toggleSystemAppsVisibility = Channel<Boolean>(Channel.CONFLATED) - private val toggleExcludeChannel = Channel<ListItemData>(Channel.BUFFERED) - private val listItemListener = - object : ListItemListener { - override fun onItemAction(item: ListItemData) { - when (item.widget) { - is ImageState -> toggleExcludeChannel.trySend(item) - is SwitchState -> toggleSystemAppsVisibility.trySend(!item.widget.isChecked) - else -> { - /* NOOP */ - } - } - } - } - private var recyclerView: RecyclerView? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.findViewById<CollapsingToolbarLayout>(R.id.collapsing_toolbar).apply { - title = resources.getString(R.string.split_tunneling) - } - listItemsAdapter.listItemListener = listItemListener - listItemsAdapter.setHasStableIds(true) - recyclerView = - view.findViewById<RecyclerView>(R.id.recyclerView).apply { - adapter = listItemsAdapter - addItemDecoration( - ListItemDividerDecoration( - topOffset = resources.getDimensionPixelSize(R.dimen.list_item_divider) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val state = viewModel.uiState.collectAsState().value + SplitTunnelingScreen( + uiState = state, + onShowSystemAppsClicked = viewModel::setShowSystemApps, + addToExcluded = viewModel::addToExcluded, + removeFromExcluded = viewModel::removeFromExcluded, + onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() } ) - ) - tweakMargin(this) + } } - view.findViewById<View>(R.id.back).setOnClickListener { requireActivity().onBackPressed() } - - lifecycleScope.launchWhenStarted { - viewModel.listItems.onEach { listItemsAdapter.setItems(it) }.catch {}.collect() - } - lifecycleScope.launchWhenResumed { - // pass view intent to view model - intents().onEach { viewModel.processIntent(it) }.collect() } } override fun onDestroy() { - listItemsAdapter.listItemListener = null - recyclerView?.adapter = null scope.close() super.onDestroy() } - - private fun intents(): Flow<ViewIntent> = - merge( - transitionFinishedFlow.map { ViewIntent.ViewIsReady }, - toggleExcludeChannel.consumeAsFlow().map { ViewIntent.ChangeApplicationGroup(it) }, - toggleSystemAppsVisibility.consumeAsFlow().map { ViewIntent.ShowSystemApps(it) } - ) - - private fun tweakMargin(view: View) { - if (!hasNavigationBar()) { - view.setMargins(b = 0) - } - } - - private fun hasNavigationBar(): Boolean { - // Emulator - if (Build.FINGERPRINT.contains("generic")) { - return true - } - - val hasMenuKey = ViewConfiguration.get(requireContext()).hasPermanentMenuKey() - val hasBackKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK) - val hasNoCapacitiveKeys = !hasMenuKey && !hasBackKey - - val id = resources.getIdentifier("config_showNavigationBar", "bool", "android") - val hasOnScreenNavBar = id > 0 && resources.getBoolean(id) - - return hasOnScreenNavBar || hasNoCapacitiveKeys - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt index 877d847abc..de36ab9689 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt @@ -7,7 +7,8 @@ import net.mullvad.mullvadvpn.ipc.EventDispatcher import net.mullvad.mullvadvpn.ipc.Request class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDispatcher) { - private var excludedApps: Set<String> = emptySet() + private var _excludedApps by + observable(emptySet<String>()) { _, _, apps -> excludedAppsChange.invoke(apps) } var enabled by observable(false) { _, wasEnabled, isEnabled -> @@ -16,19 +17,23 @@ class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDi } } + var excludedAppsChange: (apps: Set<String>) -> Unit = {} + set(value) { + field = value + synchronized(this) { value.invoke(_excludedApps) } + } + init { eventDispatcher.registerHandler(Event.SplitTunnelingUpdate::class) { event -> if (event.excludedApps != null) { enabled = true - excludedApps = event.excludedApps.toSet() + _excludedApps = event.excludedApps.toSet() } else { enabled = false } } } - fun isAppExcluded(appPackageName: String): Boolean = excludedApps.contains(appPackageName) - fun excludeApp(appPackageName: String) = connection.send(Request.ExcludeApp(appPackageName).message) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt new file mode 100644 index 0000000000..53970b7eef --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ApplicationImageView.kt @@ -0,0 +1,73 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.content.res.ResourcesCompat +import 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.scope.KoinScopeComponent +import org.koin.core.scope.Scope +import org.koin.core.scope.inject + +class ApplicationImageView +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.applicationListItemViewStyle, +) : AppCompatImageView(context, attrs, defStyleAttr), 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) + + var packageName: String = "" + set(value) { + field = value + updateImage() + } + + init { + updateImage(ResourcesCompat.getDrawable(resources, R.drawable.ic_icons_missing, null)!!) + } + + private fun updateImage() { + updateImageJob?.cancel() + updateImageJob = viewScope.launch { loadImage()?.let { drawable -> updateImage(drawable) } } + } + + 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(packageName) + } catch (e: PackageManager.NameNotFoundException) { + null + } + } + + private fun updateImage(drawable: Drawable) = setImageDrawable(drawable) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt index d2f43a77f6..e2309dbabb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt @@ -1,176 +1,86 @@ package net.mullvad.mullvadvpn.viewmodel -import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.applist.AppData import net.mullvad.mullvadvpn.applist.ApplicationsProvider -import net.mullvad.mullvadvpn.applist.ViewIntent -import net.mullvad.mullvadvpn.model.ListItemData -import net.mullvad.mullvadvpn.model.WidgetState +import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling class SplitTunnelingViewModel( private val appsProvider: ApplicationsProvider, private val splitTunneling: SplitTunneling, - dispatcher: CoroutineDispatcher + private val dispatcher: CoroutineDispatcher ) : ViewModel() { - private val listItemsSink = MutableSharedFlow<List<ListItemData>>(replay = 1) - // read-only public view - val listItems: SharedFlow<List<ListItemData>> = listItemsSink.asSharedFlow() + private val allApps = MutableStateFlow<List<AppData>?>(null) + private val showSystemApps = MutableStateFlow(false) - private val intentFlow = MutableSharedFlow<ViewIntent>() - private val isUIReady = CompletableDeferred<Unit>() - private val excludedApps: MutableMap<String, AppData> = mutableMapOf() - private val notExcludedApps: MutableMap<String, AppData> = mutableMapOf() + private val vmState = + combine(splitTunneling.excludedAppsCallbackFlow(), allApps, showSystemApps) { + excludedApps, + allApps, + showSystemApps -> + SplitTunnelingViewModelState( + excludedApps = excludedApps, + allApps = allApps, + showSystemApps = showSystemApps + ) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + SplitTunnelingViewModelState() + ) - private val defaultListItems: List<ListItemData> = - listOf( - createTextItem(R.string.split_tunneling_description) - // We will have search item in future + val uiState = + vmState + .map(SplitTunnelingViewModelState::toUiState) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + SplitTunnelingUiState.Loading ) - private var isSystemAppsVisible = false init { viewModelScope.launch(dispatcher) { - listItemsSink.emit(defaultListItems + createDivider(0) + createProgressItem()) - // this will be removed after changes on native to ignore enable parameter if (!splitTunneling.enabled) splitTunneling.enabled = true - fetchData() - } - viewModelScope.launch(dispatcher) { - intentFlow - .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) - .collect(::handleIntents) + fetchApps() } } - suspend fun processIntent(intent: ViewIntent) = intentFlow.emit(intent) - override fun onCleared() { splitTunneling.persist() super.onCleared() } - private suspend fun handleIntents(viewIntent: ViewIntent) { - when (viewIntent) { - is ViewIntent.ChangeApplicationGroup -> { - viewIntent.item.action?.let { - if (excludedApps.containsKey(it.identifier)) { - removeFromExcluded(it.identifier) - } else { - addToExcluded(it.identifier) - } - publishList() - } - } - is ViewIntent.ViewIsReady -> isUIReady.complete(Unit) - is ViewIntent.ShowSystemApps -> { - isSystemAppsVisible = viewIntent.show - publishList() - } - } + fun removeFromExcluded(packageName: String) { + viewModelScope.launch(dispatcher) { splitTunneling.includeApp(packageName) } } - private fun removeFromExcluded(packageName: String) { - excludedApps.remove(packageName)?.let { appInfo -> - notExcludedApps[packageName] = appInfo - splitTunneling.includeApp(packageName) - } + fun addToExcluded(packageName: String) { + viewModelScope.launch(dispatcher) { splitTunneling.excludeApp(packageName) } } - private fun addToExcluded(packageName: String) { - notExcludedApps.remove(packageName)?.let { appInfo -> - excludedApps[packageName] = appInfo - splitTunneling.excludeApp(packageName) - } + fun setShowSystemApps(show: Boolean) { + viewModelScope.launch(dispatcher) { showSystemApps.emit(show) } } - private suspend fun fetchData() { - appsProvider - .getAppsList() - .partition { app -> splitTunneling.isAppExcluded(app.packageName) } - .let { (excludedAppsList, notExcludedAppsList) -> - // TODO: remove potential package names from splitTunneling list - // if they already uninstalled or filtered; but not in ViewModel - excludedAppsList.map { it.packageName to it }.toMap(excludedApps) - notExcludedAppsList.map { it.packageName to it }.toMap(notExcludedApps) - } - isUIReady.await() - publishList() + private suspend fun fetchApps() { + appsProvider.getAppsList().let { appsList -> allApps.emit(appsList) } } - private suspend fun publishList() { - val listItems = ArrayList(defaultListItems) - if (excludedApps.isNotEmpty()) { - listItems += createDivider(0) - listItems += createMainItem(R.string.exclude_applications) - listItems += - excludedApps.values - .sortedBy { it.name } - .map { info -> createApplicationItem(info, true) } - } - val shownNotExcludedApps = - notExcludedApps.filter { app -> !app.value.isSystemApp || isSystemAppsVisible } - if (shownNotExcludedApps.isNotEmpty()) { - listItems += createDivider(1) - listItems += createSwitchItem(R.string.show_system_apps, isSystemAppsVisible) - listItems += createMainItem(R.string.all_applications) - listItems += - shownNotExcludedApps.values - .sortedBy { it.name } - .map { info -> createApplicationItem(info, false) } - } - listItemsSink.emit(listItems) + private fun SplitTunneling.excludedAppsCallbackFlow() = callbackFlow { + excludedAppsChange = { apps -> trySend(apps) } + awaitClose { emptySet<String>() } } - - private fun createApplicationItem(appData: AppData, checked: Boolean): ListItemData = - ListItemData.build(appData.packageName) { - type = ListItemData.APPLICATION - text = appData.name - iconRes = appData.iconRes - action = ListItemData.ItemAction(appData.packageName) - widget = - WidgetState.ImageState( - if (checked) R.drawable.ic_icons_remove else R.drawable.ic_icons_add - ) - } - - private fun createDivider(id: Int): ListItemData = - ListItemData.build("space_$id") { type = ListItemData.DIVIDER } - - private fun createMainItem(@StringRes text: Int): ListItemData = - ListItemData.build("header_$text") { - type = ListItemData.ACTION - textRes = text - } - - private fun createTextItem(@StringRes text: Int): ListItemData = - ListItemData.build("text_$text") { - type = ListItemData.PLAIN - textRes = text - action = ListItemData.ItemAction(text.toString()) - } - - private fun createProgressItem(): ListItemData = - ListItemData.build(identifier = "progress") { type = ListItemData.PROGRESS } - - private fun createSwitchItem(@StringRes text: Int, checked: Boolean): ListItemData = - ListItemData.build(identifier = "switch_$text") { - type = ListItemData.ACTION - textRes = text - action = ListItemData.ItemAction(text.toString()) - widget = WidgetState.SwitchState(checked) - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt new file mode 100644 index 0000000000..8ea7106247 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt @@ -0,0 +1,28 @@ +package net.mullvad.mullvadvpn.viewmodel + +import net.mullvad.mullvadvpn.applist.AppData +import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState + +data class SplitTunnelingViewModelState( + val excludedApps: Set<String> = emptySet(), + val allApps: List<AppData>? = null, + val showSystemApps: Boolean = false +) { + fun toUiState(): SplitTunnelingUiState { + return allApps + ?.partition { appData -> excludedApps.contains(appData.packageName) } + ?.let { (excluded, included) -> + SplitTunnelingUiState.Data( + excludedApps = excluded, + includedApps = + if (showSystemApps) { + included + } else { + included.filter { appData -> !appData.isSystemApp } + }, + showSystemApps = showSystemApps + ) + } + ?: SplitTunnelingUiState.Loading + } +} |
