diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-12-04 12:00:49 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-12-04 12:00:49 +0100 |
| commit | e119d9bf6fb5445c993db3aac009e08e72c51611 (patch) | |
| tree | ab768099b0fe8c7288b0d73624152aa1832a3f1c /android/app/src | |
| parent | f929009e91e44c0c174abde053f4f2ea721c5a06 (diff) | |
| parent | 62fa2db4f196adfda37b2cb0dc01492c07849e9a (diff) | |
| download | mullvadvpn-e119d9bf6fb5445c993db3aac009e08e72c51611.tar.xz mullvadvpn-e119d9bf6fb5445c993db3aac009e08e72c51611.zip | |
Merge branch 'filter-providers'
Diffstat (limited to 'android/app/src')
20 files changed, 1304 insertions, 72 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt new file mode 100644 index 0000000000..b5f762b89b --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt @@ -0,0 +1,191 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.RelayFilterState +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.Provider +import org.junit.Rule +import org.junit.Test + +class FilterScreenTest { + @get:Rule val composeTestRule = createComposeRule() + + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun testDefaultState() { + composeTestRule.setContentWithTheme { + FilterScreen( + uiState = + RelayFilterState( + allProviders = DUMMY_RELAY_ALL_PROVIDERS, + selectedOwnership = null, + selectedProviders = DUMMY_SELECTED_PROVIDERS, + ), + uiCloseAction = MutableSharedFlow(), + onSelectedProviders = { _, _ -> } + ) + } + composeTestRule.apply { + onNodeWithText("Ownership").assertExists() + onNodeWithText("Providers").assertExists() + } + } + + @Test + fun testIsAnyCellShowing() { + composeTestRule.setContentWithTheme { + FilterScreen( + uiState = + RelayFilterState( + allProviders = DUMMY_RELAY_ALL_PROVIDERS, + selectedOwnership = null, + selectedProviders = DUMMY_SELECTED_PROVIDERS + ), + uiCloseAction = MutableSharedFlow(), + onSelectedProviders = { _, _ -> } + ) + } + composeTestRule.apply { + onNodeWithText("Ownership").performClick() + onNodeWithText("Any").assertExists() + } + } + + @Test + fun testIsMullvadCellShowing() { + composeTestRule.setContentWithTheme { + FilterScreen( + uiState = + RelayFilterState( + allProviders = DUMMY_RELAY_ALL_PROVIDERS, + selectedOwnership = Ownership.MullvadOwned, + selectedProviders = DUMMY_SELECTED_PROVIDERS + ), + uiCloseAction = MutableSharedFlow(), + onSelectedProviders = { _, _ -> } + ) + } + composeTestRule.apply { + onNodeWithText("Ownership").performClick() + onNodeWithText("Mullvad owned only").assertExists() + } + } + + @Test + fun testIsRentedCellShowing() { + composeTestRule.setContentWithTheme { + FilterScreen( + uiState = + RelayFilterState( + allProviders = DUMMY_RELAY_ALL_PROVIDERS, + selectedOwnership = Ownership.Rented, + selectedProviders = DUMMY_SELECTED_PROVIDERS + ), + uiCloseAction = MutableSharedFlow(), + onSelectedProviders = { _, _ -> } + ) + } + composeTestRule.apply { + onNodeWithText("Ownership").performClick() + onNodeWithText("Rented only").assertExists() + } + } + + @Test + fun testShowProviders() { + composeTestRule.setContentWithTheme { + FilterScreen( + uiState = + RelayFilterState( + allProviders = DUMMY_RELAY_ALL_PROVIDERS, + selectedOwnership = null, + selectedProviders = DUMMY_SELECTED_PROVIDERS + ), + uiCloseAction = MutableSharedFlow(), + onSelectedProviders = { _, _ -> } + ) + } + + composeTestRule.apply { + onNodeWithText("Providers").performClick() + onNodeWithText("Creanova").assertExists() + onNodeWithText("Creanova").assertExists() + onNodeWithText("100TB").assertExists() + } + } + + @Test + fun testApplyButtonClick() { + val mockClickListener: () -> Unit = mockk(relaxed = true) + composeTestRule.setContentWithTheme { + FilterScreen( + uiState = + RelayFilterState( + allProviders = listOf(), + selectedOwnership = null, + selectedProviders = listOf(Provider("31173", true)) + ), + uiCloseAction = MutableSharedFlow(), + onSelectedProviders = { _, _ -> }, + onApplyClick = mockClickListener + ) + } + composeTestRule.onNodeWithText("Apply").performClick() + verify { mockClickListener() } + } + + companion object { + + private val DUMMY_RELAY_ALL_PROVIDERS = + listOf( + Provider("31173", true), + Provider("100TB", false), + Provider("Blix", true), + Provider("Creanova", true), + Provider("DataPacket", false), + Provider("HostRoyale", false), + Provider("hostuniversal", false), + Provider("iRegister", false), + Provider("M247", false), + Provider("Makonix", false), + Provider("PrivateLayer", false), + Provider("ptisp", false), + Provider("Qnax", false), + Provider("Quadranet", false), + Provider("techfutures", false), + Provider("Tzulo", false), + Provider("xtom", false) + ) + + private val DUMMY_SELECTED_PROVIDERS = + listOf( + Provider("31173", true), + Provider("100TB", false), + Provider("Blix", true), + Provider("Creanova", true), + Provider("DataPacket", false), + Provider("HostRoyale", false), + Provider("hostuniversal", false), + Provider("iRegister", false), + Provider("M247", false), + Provider("Makonix", false), + Provider("PrivateLayer", false), + Provider("ptisp", false), + Provider("Qnax", false), + Provider("Quadranet", false), + Provider("techfutures", false), + Provider("Tzulo", false), + Provider("xtom", false) + ) + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt index 3b5da50d33..7e66bc24d9 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt @@ -56,7 +56,10 @@ class SelectLocationScreenTest { uiState = SelectLocationUiState.ShowData( countries = DUMMY_RELAY_COUNTRIES, - selectedRelay = null + selectedRelay = null, + selectedOwnership = null, + selectedProvidersCount = 0, + searchTerm = "" ), uiCloseAction = MutableSharedFlow(), enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow() @@ -93,7 +96,10 @@ class SelectLocationScreenTest { uiState = SelectLocationUiState.ShowData( countries = updatedDummyList, - selectedRelay = updatedDummyList[0].cities[0].relays[0] + selectedRelay = updatedDummyList[0].cities[0].relays[0], + selectedOwnership = null, + selectedProvidersCount = 0, + searchTerm = "" ), uiCloseAction = MutableSharedFlow(), enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow() @@ -118,7 +124,13 @@ class SelectLocationScreenTest { composeTestRule.setContentWithTheme { SelectLocationScreen( uiState = - SelectLocationUiState.ShowData(countries = emptyList(), selectedRelay = null), + SelectLocationUiState.ShowData( + countries = emptyList(), + selectedRelay = null, + selectedOwnership = null, + selectedProvidersCount = 0, + searchTerm = "" + ), uiCloseAction = MutableSharedFlow(), enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow(), onSearchTermInput = mockedSearchTermInput @@ -140,7 +152,14 @@ class SelectLocationScreenTest { val mockSearchString = "SEARCH" composeTestRule.setContentWithTheme { SelectLocationScreen( - uiState = SelectLocationUiState.NoSearchResultFound(searchTerm = mockSearchString), + uiState = + SelectLocationUiState.ShowData( + countries = emptyList(), + selectedRelay = null, + selectedOwnership = null, + selectedProvidersCount = 0, + searchTerm = mockSearchString + ), uiCloseAction = MutableSharedFlow(), enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow(), onSearchTermInput = mockedSearchTermInput @@ -187,7 +206,7 @@ class SelectLocationScreenTest { private val DUMMY_RELAY_COUNTRIES = RelayList( arrayListOf(DUMMY_RELAY_COUNTRY_1, DUMMY_RELAY_COUNTRY_2), - DUMMY_WIREGUARD_ENDPOINT_DATA + DUMMY_WIREGUARD_ENDPOINT_DATA, ) .toRelayCountries(ownership = Constraint.Any(), providers = Constraint.Any()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ApplyButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ApplyButton.kt new file mode 100644 index 0000000000..bea3064548 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ApplyButton.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.SpacedColumn +import net.mullvad.mullvadvpn.lib.theme.AppTheme + +@Preview +@Composable +private fun PreviewApplyButton() { + AppTheme { + SpacedColumn { + ApplyButton(onClick = {}, isEnabled = true) + ApplyButton(onClick = {}, isEnabled = false) + } + } +} + +@Composable +fun ApplyButton( + modifier: Modifier = Modifier, + background: Color = MaterialTheme.colorScheme.background, + onClick: () -> Unit, + isEnabled: Boolean +) { + VariantButton( + background = background, + text = stringResource(id = R.string.apply), + onClick = onClick, + modifier = modifier, + isEnabled = isEnabled, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt new file mode 100644 index 0000000000..5c6157e032 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt @@ -0,0 +1,83 @@ +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.MullvadGreen + +@Preview +@Composable +private fun PreviewCheckboxCell() { + AppTheme { CheckboxCell(providerName = "", checked = false, onCheckedChange = {}) } +} + +@Composable +internal fun CheckboxCell( + modifier: Modifier = Modifier, + providerName: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + background: Color = MaterialTheme.colorScheme.secondaryContainer, + startPadding: Dp = Dimens.cellStartPadding, + endPadding: Dp = Dimens.cellEndPadding, + minHeight: Dp = Dimens.cellHeight +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .clickable { onCheckedChange(!checked) } + .defaultMinSize(minHeight = minHeight) + .fillMaxWidth() + .background(background) + .padding(start = startPadding, end = endPadding) + ) { + Box( + modifier = + Modifier.size(Dimens.checkBoxSize) + .background(Color.White, MaterialTheme.shapes.small) + ) { + Checkbox( + modifier = Modifier.fillMaxSize(), + checked = checked, + onCheckedChange = onCheckedChange, + colors = + CheckboxDefaults.colors( + checkedColor = Color.Transparent, + uncheckedColor = Color.Transparent, + checkmarkColor = MullvadGreen + ), + ) + } + + Spacer(modifier = Modifier.size(Dimens.mediumPadding)) + + Text( + text = providerName, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondary, + modifier = + Modifier.weight(1f) + .padding(top = Dimens.mediumPadding, bottom = Dimens.mediumPadding) + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt new file mode 100644 index 0000000000..6566a9f30e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt @@ -0,0 +1,82 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.MullvadFilterChip +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.model.Ownership + +@Preview +@Composable +private fun PreviewFilterCell() { + AppTheme { + FilterCell( + ownershipFilter = Ownership.MullvadOwned, + selectedProviderFilter = 3, + removeOwnershipFilter = {}, + removeProviderFilter = {} + ) + } +} + +@Composable +fun FilterCell( + ownershipFilter: Ownership?, + selectedProviderFilter: Int?, + removeOwnershipFilter: () -> Unit, + removeProviderFilter: () -> Unit +) { + val scrollState = rememberScrollState() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.horizontalScroll(scrollState) + .padding( + horizontal = Dimens.searchFieldHorizontalPadding, + vertical = Dimens.selectLocationTitlePadding + ) + .fillMaxWidth(), + ) { + Text( + modifier = Modifier.padding(end = Dimens.filterTittlePadding), + text = stringResource(id = R.string.filtered), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium + ) + + if (selectedProviderFilter != null) { + MullvadFilterChip( + text = stringResource(id = R.string.number_of_providers, selectedProviderFilter), + onRemoveClick = removeProviderFilter + ) + Spacer(modifier = Modifier.size(Dimens.chipSpace)) + } + + if (ownershipFilter != null) { + MullvadFilterChip( + text = stringResource(ownershipFilter.stringResources()), + onRemoveClick = removeOwnershipFilter + ) + } + } +} + +private fun Ownership.stringResources(): Int = + when (this) { + Ownership.MullvadOwned -> R.string.owned + Ownership.Rented -> R.string.rented + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FilterChip.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FilterChip.kt new file mode 100644 index 0000000000..0443a7267e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FilterChip.kt @@ -0,0 +1,65 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.MullvadBlue +import net.mullvad.mullvadvpn.lib.theme.shape.chipShape + +@Preview +@Composable +private fun PreviewMullvadFilterChip() { + AppTheme { + MullvadFilterChip( + text = stringResource(id = R.string.number_of_providers), + onRemoveClick = {} + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MullvadFilterChip(text: String, onRemoveClick: () -> Unit) { + FilterChip( + modifier = Modifier.padding(vertical = Dimens.chipVerticalPadding), + shape = MaterialTheme.shapes.chipShape, + colors = FilterChipDefaults.filterChipColors(containerColor = MullvadBlue), + border = + FilterChipDefaults.filterChipBorder( + borderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + selected = false, + onClick = {}, + label = { + Text( + text = text, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Image( + painter = painterResource(id = R.drawable.icon_close), + contentDescription = null, + modifier = Modifier.size(Dimens.smallIconSize).clickable { onRemoveClick() } + ) + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt new file mode 100644 index 0000000000..844360c16c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt @@ -0,0 +1,193 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ApplyButton +import net.mullvad.mullvadvpn.compose.cell.CheckboxCell +import net.mullvad.mullvadvpn.compose.cell.ExpandableComposeCell +import net.mullvad.mullvadvpn.compose.cell.SelectableCell +import net.mullvad.mullvadvpn.compose.state.RelayFilterState +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.Provider + +@Preview +@Composable +private fun PreviewFilterScreen() { + val state = + RelayFilterState( + selectedOwnership = null, + allProviders = listOf(), + selectedProviders = listOf(), + ) + AppTheme { + FilterScreen( + uiState = state, + onSelectedOwnership = {}, + onSelectedProviders = { _, _ -> }, + onAllProviderCheckChange = {}, + uiCloseAction = MutableSharedFlow() + ) + } +} + +@Composable +fun FilterScreen( + uiState: RelayFilterState, + onBackClick: () -> Unit = {}, + uiCloseAction: SharedFlow<Unit>, + onApplyClick: () -> Unit = {}, + onSelectedOwnership: (ownership: Ownership?) -> Unit = {}, + onAllProviderCheckChange: (isChecked: Boolean) -> Unit = {}, + onSelectedProviders: (checked: Boolean, provider: Provider) -> Unit +) { + var providerExpanded by rememberSaveable { mutableStateOf(false) } + var ownershipExpanded by rememberSaveable { mutableStateOf(false) } + + val backgroundColor = MaterialTheme.colorScheme.background + + LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } } + Scaffold( + topBar = { + Row( + Modifier.padding( + horizontal = Dimens.selectFilterTitlePadding, + vertical = Dimens.selectFilterTitlePadding + ) + .fillMaxWidth(), + ) { + Image( + painter = painterResource(id = R.drawable.icon_back), + contentDescription = null, + modifier = Modifier.size(Dimens.titleIconSize).clickable(onClick = onBackClick) + ) + Text( + text = stringResource(R.string.filter), + modifier = + Modifier.align(Alignment.CenterVertically) + .weight(weight = 1f) + .padding(end = Dimens.titleIconSize), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimary + ) + } + }, + bottomBar = { + Box( + modifier = + Modifier.fillMaxWidth() + .padding(top = Dimens.screenVerticalMargin) + .clickable(enabled = false, onClick = onApplyClick) + .background(color = backgroundColor), + contentAlignment = Alignment.BottomCenter + ) { + ApplyButton( + onClick = onApplyClick, + isEnabled = uiState.isApplyButtonEnabled, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + ) + } + }, + ) { contentPadding -> + LazyColumn( + modifier = Modifier.padding(contentPadding).background(backgroundColor).fillMaxSize() + ) { + item { + Divider() + ExpandableComposeCell( + title = stringResource(R.string.ownership), + isExpanded = ownershipExpanded, + isEnabled = true, + onInfoClicked = null, + onCellClicked = { ownershipExpanded = !ownershipExpanded } + ) + } + if (ownershipExpanded) { + item { + SelectableCell( + title = stringResource(id = R.string.any), + isSelected = uiState.selectedOwnership == null, + onCellClicked = { onSelectedOwnership(null) } + ) + } + items(uiState.filteredOwnershipByProviders) { ownership -> + Divider() + SelectableCell( + title = stringResource(id = ownership.stringResource()), + isSelected = ownership == uiState.selectedOwnership, + onCellClicked = { onSelectedOwnership(ownership) } + ) + } + } + item { + Divider() + ExpandableComposeCell( + title = stringResource(R.string.providers), + isExpanded = providerExpanded, + isEnabled = true, + onInfoClicked = null, + onCellClicked = { providerExpanded = !providerExpanded } + ) + } + if (providerExpanded) { + item { + Divider() + CheckboxCell( + providerName = stringResource(R.string.all_providers), + checked = uiState.isAllProvidersChecked, + onCheckedChange = { isChecked -> onAllProviderCheckChange(isChecked) } + ) + } + items(uiState.filteredProvidersByOwnership) { provider -> + Divider() + CheckboxCell( + providerName = provider.name, + checked = provider in uiState.selectedProviders, + onCheckedChange = { checked -> onSelectedProviders(checked, provider) } + ) + } + } + } + } +} + +private fun Ownership.stringResource(): Int = + when (this) { + Ownership.MullvadOwned -> R.string.mullvad_owned_only + Ownership.Rented -> R.string.rented_only + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index c09a0b986a..55936b392a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -30,16 +30,16 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp import androidx.core.text.HtmlCompat import com.google.accompanist.systemuicontroller.rememberSystemUiController import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.FilterCell import net.mullvad.mullvadvpn.compose.cell.RelayLocationCell import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar @@ -60,8 +60,11 @@ import net.mullvad.mullvadvpn.relaylist.RelayItem private fun PreviewSelectLocationScreen() { val state = SelectLocationUiState.ShowData( + searchTerm = "", countries = listOf(RelayCountry("Country 1", "Code 1", false, emptyList())), - selectedRelay = null + selectedRelay = null, + selectedOwnership = null, + selectedProvidersCount = 0 ) AppTheme { SelectLocationScreen( @@ -80,8 +83,12 @@ fun SelectLocationScreen( enterTransitionEndAction: SharedFlow<Unit>, onSelectRelay: (item: RelayItem) -> Unit = {}, onSearchTermInput: (searchTerm: String) -> Unit = {}, - onBackClick: () -> Unit = {} + onBackClick: () -> Unit = {}, + onFilterClick: () -> Unit = {}, + removeOwnershipFilter: () -> Unit = {}, + removeProviderFilter: () -> Unit = {} ) { + val backgroundColor = MaterialTheme.colorScheme.background val systemUiController = rememberSystemUiController() @@ -121,10 +128,29 @@ fun SelectLocationScreen( .weight(weight = 1f) .padding(end = Dimens.titleIconSize), textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp), + style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onPrimary ) + Image( + painter = painterResource(id = R.drawable.icons_more_circle), + contentDescription = null, + modifier = Modifier.size(Dimens.titleIconSize).clickable { onFilterClick() } + ) + } + when (uiState) { + SelectLocationUiState.Loading -> {} + is SelectLocationUiState.ShowData -> { + if (uiState.hasFilter) { + FilterCell( + ownershipFilter = uiState.selectedOwnership, + selectedProviderFilter = uiState.selectedProvidersCount, + removeOwnershipFilter = removeOwnershipFilter, + removeProviderFilter = removeProviderFilter + ) + } + } } + SearchTextField( modifier = Modifier.fillMaxWidth() @@ -146,7 +172,7 @@ fun SelectLocationScreen( MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) ), state = lazyListState, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { when (uiState) { SelectLocationUiState.Loading -> { @@ -157,45 +183,56 @@ fun SelectLocationScreen( } } is SelectLocationUiState.ShowData -> { - items( - count = uiState.countries.size, - key = { index -> uiState.countries[index].hashCode() }, - contentType = { ContentType.ITEM } - ) { index -> - val country = uiState.countries[index] - RelayLocationCell( - relay = country, - selectedItem = uiState.selectedRelay, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } - } - is SelectLocationUiState.NoSearchResultFound -> { - item(contentType = ContentType.EMPTY_TEXT) { - val firstRow = - HtmlCompat.fromHtml( - textResource( - id = R.string.select_location_empty_text_first_row, - uiState.searchTerm - ), - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) - Text( - text = - buildAnnotatedString { - append(firstRow) - appendLine() - append( + if (uiState.countries.isEmpty()) { + item(contentType = ContentType.EMPTY_TEXT) { + val firstRow = + HtmlCompat.fromHtml( textResource( - id = R.string.select_location_empty_text_second_row - ) + id = R.string.select_location_empty_text_first_row, + uiState.searchTerm + ), + HtmlCompat.FROM_HTML_MODE_COMPACT ) - }, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center - ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + val secondRow = + textResource(id = R.string.select_location_empty_text_second_row) + Column( + modifier = + Modifier.padding( + horizontal = Dimens.selectLocationTitlePadding + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = firstRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = secondRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary + ) + } + } + } else { + items( + count = uiState.countries.size, + key = { index -> uiState.countries[index].hashCode() }, + contentType = { ContentType.ITEM } + ) { index -> + val country = uiState.countries[index] + RelayLocationCell( + relay = country, + selectedItem = uiState.selectedRelay, + onSelectRelay = onSelectRelay, + modifier = Modifier.animateContentSize() + ) + } } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt new file mode 100644 index 0000000000..8a65c64b01 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.model.Providers +import net.mullvad.mullvadvpn.relaylist.Provider + +fun Constraint<Ownership>.toNullableOwnership(): Ownership? = + when (this) { + is Constraint.Any -> null + is Constraint.Only -> this.value + } + +fun Ownership?.toOwnershipConstraint(): Constraint<Ownership> = + when (this) { + null -> Constraint.Any() + else -> Constraint.Only(this) + } + +fun Constraint<Providers>.toSelectedProviders(allProviders: List<Provider>): List<Provider> = + when (this) { + is Constraint.Any -> allProviders + is Constraint.Only -> + this.value.providers.toList().mapNotNull { providerName -> + allProviders.firstOrNull { it.name == providerName } + } + } + +fun List<Provider>.toConstraintProviders(allProviders: List<Provider>): Constraint<Providers> = + if (size == allProviders.size) { + Constraint.Any() + } else { + Constraint.Only(Providers(map { provider -> provider.name }.toHashSet())) + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt new file mode 100644 index 0000000000..664f03ce40 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.Provider + +data class RelayFilterState( + val selectedOwnership: Ownership? = null, + val allProviders: List<Provider> = emptyList(), + val selectedProviders: List<Provider> = allProviders +) { + val isApplyButtonEnabled = selectedProviders.isNotEmpty() + + val filteredOwnershipByProviders = + if (selectedProviders.isEmpty()) { + Ownership.entries + } else { + Ownership.entries.filter { ownership -> + selectedProviders.any { provider -> + if (provider.mullvadOwned) { + ownership == Ownership.MullvadOwned + } else { + ownership == Ownership.Rented + } + } + } + } + val filteredProvidersByOwnership = + when (selectedOwnership) { + Ownership.MullvadOwned -> allProviders.filter { it.mullvadOwned } + Ownership.Rented -> allProviders.filterNot { it.mullvadOwned } + else -> allProviders + } + + val isAllProvidersChecked = allProviders.size == selectedProviders.size +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt index fece45f0aa..123bf821e6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt @@ -1,13 +1,20 @@ package net.mullvad.mullvadvpn.compose.state +import net.mullvad.mullvadvpn.model.Ownership import net.mullvad.mullvadvpn.relaylist.RelayCountry import net.mullvad.mullvadvpn.relaylist.RelayItem sealed interface SelectLocationUiState { - data object Loading : SelectLocationUiState - data class ShowData(val countries: List<RelayCountry>, val selectedRelay: RelayItem?) : - SelectLocationUiState + data object Loading : SelectLocationUiState - data class NoSearchResultFound(val searchTerm: String) : SelectLocationUiState + data class ShowData( + val searchTerm: String, + val countries: List<RelayCountry>, + val selectedRelay: RelayItem?, + val selectedOwnership: Ownership?, + val selectedProvidersCount: Int? + ) : SelectLocationUiState { + val hasFilter: Boolean = (selectedProvidersCount != null || selectedOwnership != null) + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/SearchTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/SearchTextField.kt index 2f743a8d23..bbee4a969b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/SearchTextField.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/SearchTextField.kt @@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.compose.textfield import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -80,15 +81,28 @@ fun SearchTextField( modifier = Modifier.size( width = Dimens.searchIconSize, - height = Dimens.searchIconSize + height = Dimens.searchIconSize, ), colorFilter = - ColorFilter.tint(color = MaterialTheme.colorScheme.onSecondary) + ColorFilter.tint(color = MaterialTheme.colorScheme.onSecondary), ) }, placeholder = { Text(text = placeHolder, style = MaterialTheme.typography.labelLarge) }, + trailingIcon = { + if (searchTerm.isNotEmpty()) { + Image( + modifier = + Modifier.size(Dimens.smallIconSize).clickable { + searchTerm = "" + onValueChange.invoke(searchTerm) + }, + painter = painterResource(id = R.drawable.icon_close), + contentDescription = null, + ) + } + }, shape = MaterialTheme.shapes.medium, colors = TextFieldDefaults.colors( 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 b39d16b0aa..220097a731 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 @@ -29,6 +29,7 @@ import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase import net.mullvad.mullvadvpn.usecase.PortRangeUseCase +import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase @@ -39,6 +40,7 @@ import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel +import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel @@ -103,6 +105,7 @@ val uiModule = module { single<IChangelogDataProvider> { ChangelogDataProvider(get()) } + single { RelayListFilterUseCase(get(), get()) } single { RelayListListener(get()) } // Will be resolved using from either of the two PaymentModule.kt classes. @@ -129,7 +132,7 @@ val uiModule = module { viewModel { DeviceRevokedViewModel(get(), get()) } viewModel { LoginViewModel(get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get()) } - viewModel { SelectLocationViewModel(get(), get()) } + viewModel { SelectLocationViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } @@ -137,6 +140,7 @@ val uiModule = module { viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get(), get(), get()) } + viewModel { FilterViewModel(get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index f299b8c956..f5e24dacf1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -42,6 +42,7 @@ import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.ui.fragment.AccountFragment import net.mullvad.mullvadvpn.ui.fragment.ConnectFragment import net.mullvad.mullvadvpn.ui.fragment.DeviceRevokedFragment +import net.mullvad.mullvadvpn.ui.fragment.FilterFragment import net.mullvad.mullvadvpn.ui.fragment.LoadingFragment import net.mullvad.mullvadvpn.ui.fragment.LoginFragment import net.mullvad.mullvadvpn.ui.fragment.OutOfTimeFragment @@ -55,7 +56,6 @@ import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules -import org.koin.dsl.bind open class MainActivity : FragmentActivity() { private val requestNotificationPermissionLauncher = @@ -174,6 +174,20 @@ open class MainActivity : FragmentActivity() { } } + fun openFilter() { + supportFragmentManager.beginTransaction().apply { + setCustomAnimations( + R.anim.fragment_enter_from_right, + R.anim.do_nothing, + R.anim.do_nothing, + R.anim.fragment_exit_to_right + ) + replace(R.id.main_fragment, FilterFragment()) + addToBackStack(null) + commitAllowingStateLoss() + } + } + private fun launchDeviceStateHandler(): Job { return lifecycleScope.launch { launch { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.kt new file mode 100644 index 0000000000..17357d8cbf --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.kt @@ -0,0 +1,42 @@ +package net.mullvad.mullvadvpn.ui.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.screen.FilterScreen +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.FilterViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class FilterFragment : Fragment() { + + private val vm by viewModel<FilterViewModel>() + + 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 = vm.uiState.collectAsState().value + FilterScreen( + uiState = state, + onSelectedOwnership = vm::setSelectedOwnership, + onAllProviderCheckChange = vm::setAllProviders, + onSelectedProviders = vm::setSelectedProvider, + uiCloseAction = vm.uiSideEffect, + onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, + onApplyClick = vm::onApplyButtonClicked + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt index d1c4ac72bf..64fdee71f6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.screen.SelectLocationScreen import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.ui.MainActivity import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -32,12 +33,19 @@ class SelectLocationFragment : BaseFragment() { onSelectRelay = vm::selectRelay, onSearchTermInput = vm::onSearchTermInput, onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, + removeOwnershipFilter = vm::removeOwnerFilter, + removeProviderFilter = vm::removeProviderFilter, + onFilterClick = ::openFilterView ) } } } } + private fun openFilterView() { + (context as? MainActivity)?.openFilter() + } + override fun onEnterTransitionAnimationEnd() { vm.onTransitionAnimationEnd() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt new file mode 100644 index 0000000000..9178a22110 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt @@ -0,0 +1,107 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.RelayFilterState +import net.mullvad.mullvadvpn.compose.state.toConstraintProviders +import net.mullvad.mullvadvpn.compose.state.toNullableOwnership +import net.mullvad.mullvadvpn.compose.state.toOwnershipConstraint +import net.mullvad.mullvadvpn.compose.state.toSelectedProviders +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.Provider +import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase + +class FilterViewModel( + private val relayListFilterUseCase: RelayListFilterUseCase, +) : ViewModel() { + private val _uiSideEffect = MutableSharedFlow<Unit>() + val uiSideEffect = _uiSideEffect.asSharedFlow() + + private val selectedOwnership = MutableStateFlow<Ownership?>(null) + private val selectedProviders = MutableStateFlow<List<Provider>>(emptyList()) + + init { + viewModelScope.launch { + selectedProviders.value = + combine( + relayListFilterUseCase.availableProviders(), + relayListFilterUseCase.selectedProviders(), + ) { allProviders, selectedConstraintProviders -> + selectedConstraintProviders.toSelectedProviders(allProviders) + } + .first() + + val ownershipConstraint = relayListFilterUseCase.selectedOwnership().first() + selectedOwnership.value = ownershipConstraint.toNullableOwnership() + } + } + + val uiState: StateFlow<RelayFilterState> = + combine( + selectedOwnership, + relayListFilterUseCase.availableProviders(), + selectedProviders, + ) { selectedOwnership, allProviders, selectedProviders -> + RelayFilterState( + selectedOwnership = selectedOwnership, + allProviders = allProviders, + selectedProviders = selectedProviders + ) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + RelayFilterState( + allProviders = emptyList(), + selectedOwnership = null, + selectedProviders = emptyList() + ), + ) + + fun setSelectedOwnership(ownership: Ownership?) { + selectedOwnership.value = ownership + } + + fun setSelectedProvider(checked: Boolean, provider: Provider) { + selectedProviders.value = + if (checked) { + selectedProviders.value + provider + } else { + selectedProviders.value - provider + } + } + + fun setAllProviders(isChecked: Boolean) { + viewModelScope.launch { + selectedProviders.value = + if (isChecked) { + relayListFilterUseCase.availableProviders().first() + } else { + emptyList() + } + } + } + + fun onApplyButtonClicked() { + val newSelectedOwnership = selectedOwnership.value.toOwnershipConstraint() + val newSelectedProviders = + selectedProviders.value.toConstraintProviders(uiState.value.allProviders) + + viewModelScope.launch { + relayListFilterUseCase.updateOwnershipAndProviderFilter( + newSelectedOwnership, + newSelectedProviders + ) + _uiSideEffect.emit(Unit) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt index 5e95674e0a..caddae313b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt @@ -7,37 +7,75 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState +import net.mullvad.mullvadvpn.compose.state.toNullableOwnership +import net.mullvad.mullvadvpn.compose.state.toSelectedProviders +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.Provider import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase class SelectLocationViewModel( private val serviceConnectionManager: ServiceConnectionManager, - private val relayListUseCase: RelayListUseCase + private val relayListUseCase: RelayListUseCase, + private val relayListFilterUseCase: RelayListFilterUseCase ) : ViewModel() { + private val _closeAction = MutableSharedFlow<Unit>() private val _enterTransitionEndAction = MutableSharedFlow<Unit>() private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) val uiState = - combine(relayListUseCase.relayListWithSelection(), _searchTerm) { + combine( + relayListUseCase.relayListWithSelection(), + _searchTerm, + relayListFilterUseCase.selectedOwnership(), + relayListFilterUseCase.availableProviders(), + relayListFilterUseCase.selectedProviders() + ) { (relayCountries, relayItem), - searchTerm -> + searchTerm, + selectedOwnership, + allProviders, + selectedConstraintProviders -> + val selectedProviders = + selectedConstraintProviders.toSelectedProviders(allProviders) + + val selectedProvidersByOwnershipList = + filterSelectedProvidersByOwnership( + selectedProviders, + selectedOwnership.toNullableOwnership() + ) + + val allProvidersByOwnershipListList = + filterAllProvidersByOwnership( + allProviders, + selectedOwnership.toNullableOwnership() + ) + val filteredRelayCountries = relayCountries.filterOnSearchTerm(searchTerm, relayItem) - if (searchTerm.isNotEmpty() && filteredRelayCountries.isEmpty()) { - SelectLocationUiState.NoSearchResultFound(searchTerm = searchTerm) - } else { - SelectLocationUiState.ShowData( - countries = filteredRelayCountries, - selectedRelay = relayItem - ) - } + SelectLocationUiState.ShowData( + searchTerm = searchTerm, + countries = filteredRelayCountries, + selectedRelay = relayItem, + selectedOwnership = selectedOwnership.toNullableOwnership(), + selectedProvidersCount = + if ( + selectedProvidersByOwnershipList.size == + allProvidersByOwnershipListList.size + ) + null + else selectedProvidersByOwnershipList.size + ) } .stateIn( viewModelScope, @@ -47,6 +85,7 @@ class SelectLocationViewModel( @Suppress("konsist.ensure public properties use permitted names") val uiCloseAction = _closeAction.asSharedFlow() + @Suppress("konsist.ensure public properties use permitted names") val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() @@ -64,6 +103,46 @@ class SelectLocationViewModel( viewModelScope.launch { _searchTerm.emit(searchTerm) } } + private fun filterSelectedProvidersByOwnership( + selectedProviders: List<Provider>, + selectedOwnership: Ownership? + ): List<Provider> { + return when (selectedOwnership) { + Ownership.MullvadOwned -> selectedProviders.filter { it.mullvadOwned } + Ownership.Rented -> selectedProviders.filterNot { it.mullvadOwned } + else -> selectedProviders + } + } + + private fun filterAllProvidersByOwnership( + allProviders: List<Provider>, + selectedOwnership: Ownership? + ): List<Provider> { + return when (selectedOwnership) { + Ownership.MullvadOwned -> allProviders.filter { it.mullvadOwned } + Ownership.Rented -> allProviders.filterNot { it.mullvadOwned } + else -> allProviders + } + } + + fun removeOwnerFilter() { + viewModelScope.launch { + relayListFilterUseCase.updateOwnershipAndProviderFilter( + Constraint.Any(), + relayListFilterUseCase.selectedProviders().first() + ) + } + } + + fun removeProviderFilter() { + viewModelScope.launch { + relayListFilterUseCase.updateOwnershipAndProviderFilter( + relayListFilterUseCase.selectedOwnership().first(), + Constraint.Any() + ) + } + } + companion object { private const val EMPTY_SEARCH_TERM = "" } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModelTest.kt new file mode 100644 index 0000000000..9f61ea4a91 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModelTest.kt @@ -0,0 +1,129 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlin.test.assertEquals +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.toConstraintProviders +import net.mullvad.mullvadvpn.compose.state.toOwnershipConstraint +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.model.Constraint +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.model.Providers +import net.mullvad.mullvadvpn.relaylist.Provider +import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class FilterViewModelTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + private val mockRelayListFilterUseCase: RelayListFilterUseCase = mockk(relaxed = true) + private lateinit var viewModel: FilterViewModel + private val selectedOwnership = + MutableStateFlow<Constraint<Ownership>>(Constraint.Only(Ownership.MullvadOwned)) + private val dummyListOfAllProviders = + listOf( + Provider("31173", true), + Provider("100TB", false), + Provider("Blix", true), + Provider("Creanova", true), + Provider("DataPacket", false), + Provider("HostRoyale", false), + Provider("hostuniversal", false), + Provider("iRegister", false), + Provider("M247", false), + Provider("Makonix", false), + Provider("PrivateLayer", false), + Provider("ptisp", false), + Provider("Qnax", false), + Provider("Quadranet", false), + Provider("techfutures", false), + Provider("Tzulo", false), + Provider("xtom", false) + ) + private val mockSelectedProviders: List<Provider> = + listOf(Provider("31173", true), Provider("Blix", true), Provider("Creanova", true)) + + @Before + fun setup() { + every { mockRelayListFilterUseCase.selectedOwnership() } returns selectedOwnership + every { mockRelayListFilterUseCase.availableProviders() } returns + flowOf(dummyListOfAllProviders) + every { mockRelayListFilterUseCase.selectedProviders() } returns + flowOf(Constraint.Only(Providers(mockSelectedProviders.map { it.name }.toHashSet()))) + viewModel = FilterViewModel(mockRelayListFilterUseCase) + } + + @After + fun teardown() { + viewModel.viewModelScope.coroutineContext.cancel() + unmockkAll() + } + + @Test + fun testSetSelectedOwnership() = runTest { + // Arrange + val mockOwnership = Ownership.Rented + // Assert + viewModel.uiState.test { + assertEquals(awaitItem().selectedOwnership, Ownership.MullvadOwned) + viewModel.setSelectedOwnership(mockOwnership) + assertEquals(mockOwnership, awaitItem().selectedOwnership) + } + } + + @Test + fun testSetSelectedProvider() = runTest { + // Arrange + val mockSelectedProvidersList = Provider("ptisp", false) + // Assert + viewModel.uiState.test { + assertLists(awaitItem().selectedProviders, mockSelectedProviders) + viewModel.setSelectedProvider(true, mockSelectedProvidersList) + assertLists( + listOf(mockSelectedProvidersList) + mockSelectedProviders, + awaitItem().selectedProviders + ) + } + } + + @Test + fun testSetAllProviders() = runTest { + // Arrange + val mockProvidersList = dummyListOfAllProviders + // Act + viewModel.setAllProviders(true) + // Assert + viewModel.uiState.test { + val state = awaitItem() + assertEquals(mockProvidersList, state.selectedProviders) + } + } + + @Test + fun testOnApplyButtonClicked() = runTest { + // Arrange + val mockOwnership = Ownership.MullvadOwned.toOwnershipConstraint() + val mockSelectedProviders = + mockSelectedProviders.toConstraintProviders(dummyListOfAllProviders) + // Act + viewModel.onApplyButtonClicked() + // Assert + coVerify { + mockRelayListFilterUseCase.updateOwnershipAndProviderFilter( + mockOwnership, + mockSelectedProviders + ) + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt index 44be67fa64..74d7d80c19 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt @@ -15,7 +15,11 @@ import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.model.Constraint import net.mullvad.mullvadvpn.model.GeographicLocationConstraint +import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.model.Providers +import net.mullvad.mullvadvpn.relaylist.Provider import net.mullvad.mullvadvpn.relaylist.RelayCountry import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.relaylist.RelayList @@ -23,6 +27,7 @@ import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase import org.junit.After import org.junit.Before @@ -32,21 +37,31 @@ import org.junit.Test class SelectLocationViewModelTest { @get:Rule val testCoroutineRule = TestCoroutineRule() + private val mockRelayListFilterUseCase: RelayListFilterUseCase = mockk(relaxed = true) private val mockServiceConnectionManager: ServiceConnectionManager = mockk() private lateinit var viewModel: SelectLocationViewModel - private val relayListWithSelectionFlow = MutableStateFlow(RelayList(emptyList(), null)) - private val mockRelayListUseCase: RelayListUseCase = mockk() + private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any()) + private val selectedProvider = MutableStateFlow<Constraint<Providers>>(Constraint.Any()) + private val allProvider = MutableStateFlow<List<Provider>>(emptyList()) @Before fun setup() { + + every { mockRelayListFilterUseCase.selectedOwnership() } returns selectedOwnership + every { mockRelayListFilterUseCase.selectedProviders() } returns selectedProvider + every { mockRelayListFilterUseCase.availableProviders() } returns allProvider every { mockRelayListUseCase.relayListWithSelection() } returns relayListWithSelectionFlow mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) mockkStatic(RELAY_LIST_EXTENSIONS) - - viewModel = SelectLocationViewModel(mockServiceConnectionManager, mockRelayListUseCase) + viewModel = + SelectLocationViewModel( + mockServiceConnectionManager, + mockRelayListUseCase, + mockRelayListFilterUseCase + ) } @After @@ -164,11 +179,47 @@ class SelectLocationViewModelTest { // Assert val actualState = awaitItem() - assertIs<SelectLocationUiState.NoSearchResultFound>(actualState) + assertIs<SelectLocationUiState.ShowData>(actualState) assertEquals(mockSearchString, actualState.searchTerm) } } + @Test + fun testRemoveOwnerFilter() = runTest { + // Arrange + val mockSelectedProviders: Constraint<Providers> = mockk() + every { mockRelayListFilterUseCase.selectedProviders() } returns + MutableStateFlow(mockSelectedProviders) + + // Act + viewModel.removeOwnerFilter() + // Assert + verify { + mockRelayListFilterUseCase.updateOwnershipAndProviderFilter( + any<Constraint.Any<Ownership>>(), + mockSelectedProviders + ) + } + } + + @Test + fun testRemoveProviderFilter() = runTest { + // Arrange + val mockSelectedOwnership: Constraint<Ownership> = mockk() + every { mockRelayListFilterUseCase.selectedOwnership() } returns + MutableStateFlow(mockSelectedOwnership) + + // Act + viewModel.removeProviderFilter() + // Assert + verify { + mockRelayListFilterUseCase.updateOwnershipAndProviderFilter( + mockSelectedOwnership, + any<Constraint.Any<Providers>>() + ) + } + } + companion object { private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt" |
