summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-12-04 12:00:49 +0100
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-12-04 12:00:49 +0100
commite119d9bf6fb5445c993db3aac009e08e72c51611 (patch)
treeab768099b0fe8c7288b0d73624152aa1832a3f1c /android/app/src
parentf929009e91e44c0c174abde053f4f2ea721c5a06 (diff)
parent62fa2db4f196adfda37b2cb0dc01492c07849e9a (diff)
downloadmullvadvpn-e119d9bf6fb5445c993db3aac009e08e72c51611.tar.xz
mullvadvpn-e119d9bf6fb5445c993db3aac009e08e72c51611.zip
Merge branch 'filter-providers'
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt191
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt29
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ApplyButton.kt38
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt83
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt82
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FilterChip.kt65
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt193
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt123
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt34
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt35
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/SearchTextField.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FilterFragment.kt42
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt107
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt101
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModelTest.kt129
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt61
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"