summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-06-01 10:11:10 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-07-12 14:32:30 +0200
commit4fc7e95a2a26a1f8d221959a417a5b3832b46ede (patch)
tree2ed1a6ab96bd607329ede46c5bfe96fc7ba9bf08 /android/app/src
parent7ffe7307ca2a969193d0eec4853248d5cdaa4fa7 (diff)
downloadmullvadvpn-4fc7e95a2a26a1f8d221959a417a5b3832b46ede.tar.xz
mullvadvpn-4fc7e95a2a26a1f8d221959a417a5b3832b46ede.zip
Add search bar to select location screen
- Filter countries, cities and relays based on search string - Remove collapsable toolbar and replace with search bar - Improve expand behavior for relay location cells - Remove cirular dependency for relays
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt82
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt126
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/SearchTextField.kt107
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/ContentType.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt125
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt132
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt83
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt121
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt206
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt36
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt44
-rw-r--r--android/app/src/main/res/drawable/icon_relay_active.xml7
-rw-r--r--android/app/src/main/res/drawable/icon_relay_inactive.xml7
-rw-r--r--android/app/src/main/res/drawable/icons_search.xml22
-rw-r--r--android/app/src/main/res/layout/relay_list_item.xml53
-rw-r--r--android/app/src/main/res/values/dimensions.xml3
-rw-r--r--android/app/src/main/res/values/strings.xml5
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt85
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt5
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt84
28 files changed, 781 insertions, 614 deletions
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 e67e08249e..1bf4d5edbe 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
@@ -3,18 +3,22 @@ package net.mullvad.mullvadvpn.compose.screen
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTextInput
import io.mockk.MockKAnnotations
+import io.mockk.mockk
+import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
import net.mullvad.mullvadvpn.compose.theme.AppTheme
import net.mullvad.mullvadvpn.model.PortRange
import net.mullvad.mullvadvpn.model.RelayEndpointData
+import net.mullvad.mullvadvpn.model.RelayList
import net.mullvad.mullvadvpn.model.RelayListCity
import net.mullvad.mullvadvpn.model.RelayListCountry
import net.mullvad.mullvadvpn.model.WireguardEndpointData
import net.mullvad.mullvadvpn.model.WireguardRelayEndpointData
-import net.mullvad.mullvadvpn.relaylist.RelayList
+import net.mullvad.mullvadvpn.relaylist.toRelayCountries
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -38,13 +42,7 @@ class SelectLocationScreenTest {
}
// Assert
- composeTestRule.apply {
- onNodeWithText(
- "While connected, your real location is masked with a private and secure location in the selected region."
- )
- .assertExists()
- onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists()
- }
+ composeTestRule.apply { onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() }
}
@Test
@@ -54,7 +52,7 @@ class SelectLocationScreenTest {
SelectLocationScreen(
uiState =
SelectLocationUiState.ShowData(
- countries = DUMMY_RELAY_LIST.countries,
+ countries = DUMMY_RELAY_COUNTRIES,
selectedRelay = null
),
uiCloseAction = MutableSharedFlow()
@@ -63,10 +61,6 @@ class SelectLocationScreenTest {
// Assert
composeTestRule.apply {
- onNodeWithText(
- "While connected, your real location is masked with a private and secure location in the selected region."
- )
- .assertExists()
onNodeWithText("Relay Country 1").assertExists()
onNodeWithText("Relay City 1").assertDoesNotExist()
onNodeWithText("Relay host 1").assertDoesNotExist()
@@ -85,11 +79,11 @@ class SelectLocationScreenTest {
uiState =
SelectLocationUiState.ShowData(
countries =
- DUMMY_RELAY_LIST.countries.apply {
+ DUMMY_RELAY_COUNTRIES.apply {
this[0].expanded = true
this[0].cities[0].expanded = true
},
- selectedRelay = DUMMY_RELAY_LIST.countries[0].cities[0].relays[0]
+ selectedRelay = DUMMY_RELAY_COUNTRIES[0].cities[0].relays[0]
),
uiCloseAction = MutableSharedFlow()
)
@@ -98,10 +92,6 @@ class SelectLocationScreenTest {
// Assert
composeTestRule.apply {
- onNodeWithText(
- "While connected, your real location is masked with a private and secure location in the selected region."
- )
- .assertExists()
onNodeWithText("Relay Country 1").assertExists()
onNodeWithText("Relay City 1").assertExists()
onNodeWithText("Relay host 1").assertExists()
@@ -111,6 +101,55 @@ class SelectLocationScreenTest {
}
}
+ @Test
+ fun testSearchInput() {
+ // Arrange
+ val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ AppTheme {
+ SelectLocationScreen(
+ uiState =
+ SelectLocationUiState.ShowData(
+ countries = emptyList(),
+ selectedRelay = null
+ ),
+ uiCloseAction = MutableSharedFlow(),
+ onSearchTermInput = mockedSearchTermInput
+ )
+ }
+ }
+ val mockSearchString = "SEARCH"
+
+ // Act
+ composeTestRule.apply { onNodeWithText("Search for...").performTextInput(mockSearchString) }
+
+ // Assert
+ verify { mockedSearchTermInput.invoke(mockSearchString) }
+ }
+
+ @Test
+ fun testSearchTermNotFound() {
+ // Arrange
+ val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true)
+ val mockSearchString = "SEARCH"
+ composeTestRule.setContent {
+ AppTheme {
+ SelectLocationScreen(
+ uiState =
+ SelectLocationUiState.NoSearchResultFound(searchTerm = mockSearchString),
+ uiCloseAction = MutableSharedFlow(),
+ onSearchTermInput = mockedSearchTermInput
+ )
+ }
+ }
+
+ // Assert
+ composeTestRule.apply {
+ onNodeWithText("No result for $mockSearchString.", substring = true).assertExists()
+ onNodeWithText("Try a different search", substring = true).assertExists()
+ }
+ }
+
companion object {
private val DUMMY_RELAY_1 =
net.mullvad.mullvadvpn.model.Relay(
@@ -137,12 +176,11 @@ class SelectLocationScreenTest {
private val DUMMY_WIREGUARD_ENDPOINT_DATA =
WireguardEndpointData(DUMMY_WIREGUARD_PORT_RANGES)
- private val DUMMY_RELAY_LIST =
+ private val DUMMY_RELAY_COUNTRIES =
RelayList(
- net.mullvad.mullvadvpn.model.RelayList(
arrayListOf(DUMMY_RELAY_COUNTRY_1, DUMMY_RELAY_COUNTRY_2),
DUMMY_WIREGUARD_ENDPOINT_DATA
)
- )
+ .toRelayCountries()
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt
index 2a47581056..3965422df9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt
@@ -19,7 +19,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -33,6 +33,7 @@ import net.mullvad.mullvadvpn.compose.theme.AlphaInvisible
import net.mullvad.mullvadvpn.compose.theme.AlphaVisible
import net.mullvad.mullvadvpn.compose.theme.AppTheme
import net.mullvad.mullvadvpn.compose.theme.Dimens
+import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
import net.mullvad.mullvadvpn.relaylist.Relay
import net.mullvad.mullvadvpn.relaylist.RelayCity
import net.mullvad.mullvadvpn.relaylist.RelayCountry
@@ -44,74 +45,101 @@ import net.mullvad.mullvadvpn.relaylist.RelayItemType
private fun PreviewRelayLocationCell() {
AppTheme {
Column(Modifier.background(color = MaterialTheme.colorScheme.background)) {
- val relayCountry =
+ val countryActive =
RelayCountry(
- name = "Relay only country",
- code = "ROC",
+ name = "Relay country Active",
+ code = "RC1",
expanded = false,
- cities = emptyList()
- )
- val relayCity =
- RelayCity(
- name = "Relay only city",
- code = "RCC",
- expanded = false,
- country = relayCountry,
- relays = emptyList()
- )
- val relay =
- Relay(
- name = "Relay",
- city = relayCity,
- active = false,
- )
- val relayCountryAndCity =
- RelayCountry(
- name = "Relay Country",
- code = "RC",
- expanded = true,
cities =
listOf(
RelayCity(
- country = relayCountry,
- "Relay City",
- code = "RCI",
+ name = "Relay city 1",
+ code = "RI1",
expanded = false,
- emptyList()
+ location = GeographicLocationConstraint.City("RC1", "RI1"),
+ relays =
+ listOf(
+ Relay(
+ name = "Relay 1",
+ active = true,
+ locationName = "",
+ location =
+ GeographicLocationConstraint.Hostname(
+ "RC1",
+ "RI1",
+ "NER"
+ )
+ )
+ )
+ ),
+ RelayCity(
+ name = "Relay city 2",
+ code = "RI2",
+ expanded = true,
+ location = GeographicLocationConstraint.City("RC1", "RI2"),
+ relays =
+ listOf(
+ Relay(
+ name = "Relay 2",
+ active = true,
+ locationName = "",
+ location =
+ GeographicLocationConstraint.Hostname(
+ "RC1",
+ "RI2",
+ "NER"
+ )
+ ),
+ Relay(
+ name = "Relay 3",
+ active = true,
+ locationName = "",
+ location =
+ GeographicLocationConstraint.Hostname(
+ "RC1",
+ "RI1",
+ "NER"
+ )
+ )
+ )
)
)
)
- val fullRelayList =
+ val countryNotActive =
RelayCountry(
- name = "Relay Country",
- code = "RC",
+ name = "Not Enabled Relay country",
+ code = "RC3",
expanded = true,
cities =
listOf(
RelayCity(
- country = relayCountry,
- "Relay City",
- code = "RCI",
+ name = "Not Enabled city",
+ code = "RI3",
expanded = true,
+ location = GeographicLocationConstraint.City("RC3", "RI3"),
relays =
listOf(
- Relay(city = relayCity, name = "Relay Item", active = false)
+ Relay(
+ name = "Not Enabled Relay",
+ active = false,
+ locationName = "",
+ location =
+ GeographicLocationConstraint.Hostname(
+ "RC3",
+ "RI3",
+ "NER"
+ )
+ )
)
)
)
)
- // Relay only country
- RelayLocationCell(relayCountry)
- // Relay country and city
- RelayLocationCell(relayCountryAndCity)
- // Relay country, city and relay
- RelayLocationCell(fullRelayList)
- // Relay only city not expanded
- RelayLocationCell(relayCity)
- // Relay only not active
- RelayLocationCell(relay)
- // Relay only active
- RelayLocationCell(relay = relay, selectedItem = relay)
+ // Active relay list not expanded
+ RelayLocationCell(countryActive)
+ // Not Active Relay
+ RelayLocationCell(countryNotActive)
+ // Relay expanded country and city
+ RelayLocationCell(countryActive.copy(expanded = true))
}
}
}
@@ -132,7 +160,7 @@ fun RelayLocationCell(
RelayItemType.Relay -> Dimens.relayRowPadding
}
val selected = selectedItem == relay
- val expanded = rememberSaveable { mutableStateOf(relay.expanded) }
+ val expanded = remember(key1 = relay.expanded.toString()) { mutableStateOf(relay.expanded) }
val backgroundColor =
when {
selected -> MaterialTheme.colorScheme.inversePrimary
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/SearchTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/SearchTextField.kt
new file mode 100644
index 0000000000..c7e24883a1
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/SearchTextField.kt
@@ -0,0 +1,107 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
+import net.mullvad.mullvadvpn.compose.theme.Dimens
+import net.mullvad.mullvadvpn.compose.theme.MullvadWhite10
+
+@Preview
+@Composable
+private fun PreviewSearchTextField() {
+ AppTheme {
+ Column(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ SearchTextField(placeHolder = "Search for...") {}
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchTextField(
+ modifier: Modifier = Modifier,
+ placeHolder: String = stringResource(id = R.string.search_placeholder),
+ backgroundColor: Color = MullvadWhite10,
+ enabled: Boolean = true,
+ singleLine: Boolean = true,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ onValueChange: (String) -> Unit
+) {
+ var searchTerm by rememberSaveable { mutableStateOf("") }
+
+ BasicTextField(
+ value = searchTerm,
+ textStyle =
+ MaterialTheme.typography.labelLarge.copy(color = MaterialTheme.colorScheme.onSecondary),
+ onValueChange = { text: String ->
+ searchTerm = text
+ onValueChange.invoke(text)
+ },
+ singleLine = singleLine,
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.onSecondary),
+ decorationBox =
+ @Composable { innerTextField ->
+ TextFieldDefaults.TextFieldDecorationBox(
+ value = searchTerm,
+ innerTextField = innerTextField,
+ enabled = enabled,
+ singleLine = singleLine,
+ interactionSource = interactionSource,
+ visualTransformation = visualTransformation,
+ leadingIcon = {
+ Image(
+ painter = painterResource(id = R.drawable.icons_search),
+ contentDescription = null,
+ modifier =
+ Modifier.size(
+ width = Dimens.searchIconSize,
+ height = Dimens.searchIconSize
+ ),
+ colorFilter =
+ ColorFilter.tint(color = MaterialTheme.colorScheme.onSecondary)
+ )
+ },
+ placeholder = {
+ Text(text = placeHolder, style = MaterialTheme.typography.labelLarge)
+ },
+ shape = MaterialTheme.shapes.medium,
+ colors =
+ TextFieldDefaults.textFieldColors(
+ textColor = MaterialTheme.colorScheme.onSecondary,
+ containerColor = backgroundColor,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ cursorColor = MaterialTheme.colorScheme.onSecondary,
+ placeholderColor = MaterialTheme.colorScheme.onSecondary
+ ),
+ contentPadding = PaddingValues(),
+ )
+ },
+ modifier = modifier
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/ContentType.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/ContentType.kt
index df55278d07..04b29268a5 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/ContentType.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/ContentType.kt
@@ -8,4 +8,5 @@ object ContentType {
const val DESCRIPTION = 4
const val SPACER = 5
const val PROGRESS = 6
+ const val EMPTY_TEXT = 7
}
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 0af698e61c..447b8c6768 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
@@ -1,8 +1,14 @@
package net.mullvad.mullvadvpn.compose.screen
import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -14,18 +20,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
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.tooling.preview.Preview
+import androidx.core.text.HtmlCompat
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
-import me.onebone.toolbar.ScrollStrategy
-import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.cell.RelayLocationCell
-import net.mullvad.mullvadvpn.compose.component.CollapsingToolbarScaffold
-import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar
+import net.mullvad.mullvadvpn.compose.component.SearchTextField
+import net.mullvad.mullvadvpn.compose.component.textResource
import net.mullvad.mullvadvpn.compose.constant.ContentType
+import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
import net.mullvad.mullvadvpn.compose.theme.AppTheme
@@ -49,45 +60,52 @@ fun SelectLocationScreen(
uiState: SelectLocationUiState,
uiCloseAction: SharedFlow<Unit>,
onSelectRelay: (item: RelayItem) -> Unit = {},
+ onSearchTermInput: (searchTerm: String) -> Unit = {},
onBackClick: () -> Unit = {}
) {
- val state = rememberCollapsingToolbarScaffoldState()
- val progress = state.toolbarState.progress
LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } }
- CollapsingToolbarScaffold(
- backgroundColor = MaterialTheme.colorScheme.background,
- modifier = Modifier.fillMaxSize(),
- state = state,
- scrollStrategy = ScrollStrategy.ExitUntilCollapsed,
- isEnabledWhenCollapsable = true,
- toolbar = {
- val scaffoldModifier =
- Modifier.road(
- whenCollapsed = Alignment.TopCenter,
- whenExpanded = Alignment.BottomStart
- )
- CollapsingTopBar(
- backgroundColor = MaterialTheme.colorScheme.background,
- onBackClicked = { onBackClick() },
- title = stringResource(id = R.string.switch_location),
- progress = progress,
- modifier = scaffoldModifier,
- backTitle = "",
- backIcon = R.drawable.icon_close
+ Column(
+ modifier =
+ Modifier.background(MaterialTheme.colorScheme.background).fillMaxWidth().fillMaxHeight()
+ ) {
+ Row(
+ modifier =
+ Modifier.padding(
+ horizontal = Dimens.selectLocationTitlePadding,
+ vertical = Dimens.selectLocationTitlePadding
+ )
+ .fillMaxWidth()
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.icon_back),
+ contentDescription = null,
+ modifier =
+ Modifier.size(Dimens.titleIconSize).rotate(270f).clickable { onBackClick() }
+ )
+ Text(
+ text = stringResource(id = R.string.select_location),
+ modifier =
+ Modifier.align(Alignment.CenterVertically)
+ .weight(weight = 1f)
+ .padding(end = Dimens.titleIconSize),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onPrimary
)
}
- ) {
- LazyColumn(horizontalAlignment = Alignment.CenterHorizontally) {
- item(contentType = ContentType.DESCRIPTION) {
- Text(
- text = stringResource(id = R.string.select_location_description),
- style = MaterialTheme.typography.labelMedium,
- modifier = Modifier.padding(horizontal = Dimens.sideMargin)
- )
- }
- item(contentType = ContentType.SPACER) {
- Spacer(modifier = Modifier.height(height = Dimens.verticalSpace))
- }
+ SearchTextField(
+ modifier =
+ Modifier.fillMaxWidth()
+ .height(Dimens.searchFieldHeight)
+ .padding(horizontal = Dimens.searchFieldHorizontalPadding)
+ ) { searchString ->
+ onSearchTermInput.invoke(searchString)
+ }
+ Spacer(modifier = Modifier.height(height = Dimens.verticalSpace))
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().fillMaxHeight(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
when (uiState) {
SelectLocationUiState.Loading -> {
item(contentType = ContentType.PROGRESS) {
@@ -105,7 +123,7 @@ fun SelectLocationScreen(
is SelectLocationUiState.ShowData -> {
items(
count = uiState.countries.size,
- key = { index -> uiState.countries[index].code },
+ key = { index -> uiState.countries[index].hashCode() },
contentType = { ContentType.ITEM }
) { index ->
val country = uiState.countries[index]
@@ -117,6 +135,33 @@ fun SelectLocationScreen(
)
}
}
+ 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(
+ textResource(
+ id = R.string.select_location_empty_text_second_row
+ )
+ )
+ },
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
}
}
}
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 3c90cd784a..61b3abffa2 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
@@ -7,4 +7,6 @@ sealed interface SelectLocationUiState {
object Loading : SelectLocationUiState
data class ShowData(val countries: List<RelayCountry>, val selectedRelay: RelayItem?) :
SelectLocationUiState
+
+ data class NoSearchResultFound(val searchTerm: String) : SelectLocationUiState
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
index a09697d0b7..3a0980e922 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
@@ -25,9 +25,14 @@ data class Dimensions(
val progressIndicatorSize: Dp = 60.dp,
val relayCircleSize: Dp = 16.dp,
val relayRowPadding: Dp = 50.dp,
+ val searchFieldHeight: Dp = 42.dp,
+ val searchFieldHorizontalPadding: Dp = 22.dp,
+ val searchIconSize: Dp = 24.dp,
+ val selectLocationTitlePadding: Dp = 12.dp,
val selectableCellTextMargin: Dp = 12.dp,
val sideMargin: Dp = 22.dp,
val smallPadding: Dp = 8.dp,
+ val titleIconSize: Dp = 24.dp,
val verticalSpace: Dp = 20.dp
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt
index 6f7b6760b0..f3c3a3cc7c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt
@@ -2,18 +2,16 @@ package net.mullvad.mullvadvpn.relaylist
import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
-data class Relay(val city: RelayCity, override val name: String, override val active: Boolean) :
- RelayItem {
+data class Relay(
+ override val name: String,
+ override val location: GeographicLocationConstraint,
+ override val locationName: String,
+ override val active: Boolean
+) : RelayItem {
override val code = name
override val type = RelayItemType.Relay
- override val location =
- GeographicLocationConstraint.Hostname(city.country.code, city.code, name)
override val hasChildren = false
- override val visibleChildCount = 0
-
- override val locationName = "${city.name} ($name)"
-
override var expanded
get() = false
set(_) {}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt
index c6244101f6..4817b401bf 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt
@@ -2,15 +2,14 @@ package net.mullvad.mullvadvpn.relaylist
import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
-class RelayCity(
- val country: RelayCountry,
+data class RelayCity(
override val name: String,
override val code: String,
+ override val location: GeographicLocationConstraint,
override var expanded: Boolean,
val relays: List<Relay>
) : RelayItem {
override val type = RelayItemType.City
- override val location = GeographicLocationConstraint.City(country.code, code)
override val active
get() = relays.any { relay -> relay.active }
@@ -18,15 +17,6 @@ class RelayCity(
override val hasChildren
get() = relays.isNotEmpty()
- override val visibleChildCount: Int
- get() {
- return if (expanded) {
- relays.size
- } else {
- 0
- }
- }
-
fun getItem(position: Int): GetItemResult {
if (position == 0) {
return GetItemResult.Item(this)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt
index d8424cacad..e9487c7b4c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt
@@ -2,7 +2,7 @@ package net.mullvad.mullvadvpn.relaylist
import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
-class RelayCountry(
+data class RelayCountry(
override val name: String,
override val code: String,
override var expanded: Boolean,
@@ -17,15 +17,6 @@ class RelayCountry(
override val hasChildren
get() = cities.isNotEmpty()
- override val visibleChildCount: Int
- get() {
- return if (expanded) {
- cities.sumOf { city -> city.visibleItemCount }
- } else {
- 0
- }
- }
-
fun getItem(position: Int): GetItemResult {
if (position == 0) {
return GetItemResult.Item(this)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
index fde283fcdf..f4387e9647 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
@@ -9,10 +9,6 @@ interface RelayItem {
val location: GeographicLocationConstraint
val active: Boolean
val hasChildren: Boolean
- val visibleChildCount: Int
-
- val visibleItemCount: Int
- get() = visibleChildCount + 1
val locationName: String
get() = name
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt
deleted file mode 100644
index a16313f797..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt
+++ /dev/null
@@ -1,132 +0,0 @@
-package net.mullvad.mullvadvpn.relaylist
-
-import android.view.View
-import android.view.ViewGroup.MarginLayoutParams
-import android.widget.ImageButton
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView.ViewHolder
-import net.mullvad.mullvadvpn.R
-
-class RelayItemHolder(
- private val view: View,
- private val adapter: RelayListAdapter,
- var itemPosition: RelayListAdapterPosition
-) : ViewHolder(view) {
- private val name: TextView = view.findViewById(R.id.name)
- private val chevron: ImageButton = view.findViewById(R.id.chevron)
- private val clickArea: View = view.findViewById(R.id.click_area)
- private val relayStatus: View = view.findViewById(R.id.status)
- private val relayActive: ImageView = view.findViewById(R.id.relay_active)
- private val selectedIcon: View = view.findViewById(R.id.selected)
-
- private val context = view.context
- private val countryColor = context.getColor(R.color.blue)
- private val cityColor = context.getColor(R.color.blue40)
- private val relayColor = context.getColor(R.color.blue20)
- private val selectedColor = context.getColor(R.color.green)
-
- private val resources = view.resources
- private val countryPadding = resources.getDimensionPixelSize(R.dimen.country_row_padding)
- private val cityPadding = resources.getDimensionPixelSize(R.dimen.city_row_padding)
- private val relayPadding = resources.getDimensionPixelSize(R.dimen.relay_row_padding)
-
- var item: RelayItem? = null
- set(value) {
- field = value
- updateView()
- }
-
- var selected = false
- set(value) {
- field = value
- updateView()
- }
-
- init {
- chevron.setOnClickListener { toggle() }
- clickArea.setOnClickListener { adapter.selectItem(item, this) }
- }
-
- private fun updateView() {
- val item = this.item
-
- if (item != null) {
- name.text = item.name
-
- if (item.active) {
- name.alpha = 1.0F
- } else {
- name.alpha = 0.5F
- }
-
- if (selected) {
- relayActive.visibility = View.INVISIBLE
- selectedIcon.visibility = View.VISIBLE
- } else {
- relayActive.visibility = View.VISIBLE
- selectedIcon.visibility = View.INVISIBLE
-
- if (item.active) {
- relayActive.setImageDrawable(adapter.activeRelayIcon)
- } else {
- relayActive.setImageDrawable(adapter.inactiveRelayIcon)
- }
- }
-
- clickArea.setEnabled(item.active)
-
- when (item.type) {
- RelayItemType.Country -> setViewStyle(countryColor, countryPadding)
- RelayItemType.City -> setViewStyle(cityColor, cityPadding)
- RelayItemType.Relay -> setViewStyle(relayColor, relayPadding)
- }
-
- if (item.hasChildren) {
- chevron.visibility = View.VISIBLE
-
- if (item.expanded) {
- chevron.rotation = 180.0F
- } else {
- chevron.rotation = 0.0F
- }
- } else {
- chevron.visibility = View.GONE
- }
- } else {
- name.text = ""
- chevron.visibility = View.GONE
- }
- }
-
- private fun setViewStyle(rowColor: Int, padding: Int) {
- var backgroundColor = rowColor
-
- if (selected) {
- backgroundColor = selectedColor
- }
-
- (relayStatus.layoutParams as? MarginLayoutParams)?.let { parameters ->
- parameters.leftMargin = padding
- relayStatus.layoutParams = parameters
- }
-
- view.setBackgroundColor(backgroundColor)
- }
-
- private fun toggle() {
- item?.let { item ->
- if (!item.expanded) {
- item.expanded = true
- chevron.rotation = 180.0F
- adapter.expandItem(itemPosition, item.visibleChildCount)
- } else {
- val childCount = item.visibleChildCount
-
- item.expanded = false
- chevron.rotation = 0.0F
- adapter.collapseItem(itemPosition, childCount)
- }
- }
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt
deleted file mode 100644
index 60cbdd46cf..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package net.mullvad.mullvadvpn.relaylist
-
-import net.mullvad.mullvadvpn.model.Constraint
-import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
-
-class RelayList {
- val countries: List<RelayCountry>
-
- constructor(model: net.mullvad.mullvadvpn.model.RelayList) {
- var relayCountries =
- model.countries
- .map { country ->
- val cities = mutableListOf<RelayCity>()
- val relayCountry = RelayCountry(country.name, country.code, false, cities)
-
- for (city in country.cities) {
- val relays = mutableListOf<Relay>()
- val relayCity = RelayCity(relayCountry, city.name, city.code, false, relays)
-
- val validCityRelays = city.relays.filter { relay -> relay.isWireguardRelay }
-
- for (relay in validCityRelays) {
- relays.add(Relay(relayCity, relay.hostname, relay.active))
- }
- relays.sortWith(RelayNameComparator)
-
- if (relays.isNotEmpty()) {
- cities.add(relayCity)
- }
- }
-
- cities.sortBy({ it.name })
- relayCountry
- }
- .filter { country -> country.cities.isNotEmpty() }
- .toMutableList()
-
- relayCountries.sortBy({ it.name })
-
- countries = relayCountries.toList()
- }
-
- fun findItemForLocation(
- constraint: Constraint<GeographicLocationConstraint>,
- expand: Boolean = false
- ): RelayItem? {
- when (constraint) {
- is Constraint.Any -> return null
- is Constraint.Only -> {
- val location = constraint.value
-
- when (location) {
- is GeographicLocationConstraint.Country -> {
- return countries.find { country -> country.code == location.countryCode }
- }
- is GeographicLocationConstraint.City -> {
- val country =
- countries.find { country -> country.code == location.countryCode }
-
- if (expand) {
- country?.expanded = true
- }
-
- return country?.cities?.find { city -> city.code == location.cityCode }
- }
- is GeographicLocationConstraint.Hostname -> {
- val country =
- countries.find { country -> country.code == location.countryCode }
-
- val city = country?.cities?.find { city -> city.code == location.cityCode }
-
- if (expand) {
- country?.expanded = true
- city?.expanded = true
- }
-
- return city?.relays?.find { relay -> relay.name == location.hostname }
- }
- }
- }
- }
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt
deleted file mode 100644
index 0937592399..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-package net.mullvad.mullvadvpn.relaylist
-
-import android.content.res.Resources
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView.Adapter
-import java.lang.ref.WeakReference
-import java.util.LinkedList
-import net.mullvad.mullvadvpn.R
-
-class RelayListAdapter(private val resources: Resources) : Adapter<RelayItemHolder>() {
- private var relayList: RelayList? = null
- private var selectedItem: RelayItem? = null
- private val activeIndices = LinkedList<WeakReference<RelayListAdapterPosition>>()
- private var selectedItemHolder: RelayItemHolder? = null
-
- val activeRelayIcon = resources.getDrawable(R.drawable.icon_relay_active, null)
- val inactiveRelayIcon = resources.getDrawable(R.drawable.icon_relay_inactive, null)
-
- var onSelect: ((RelayItem?) -> Unit)? = null
-
- override fun onCreateViewHolder(parentView: ViewGroup, type: Int): RelayItemHolder {
- val inflater = LayoutInflater.from(parentView.context)
- val view = inflater.inflate(R.layout.relay_list_item, parentView, false)
- val index = RelayListAdapterPosition(0)
-
- activeIndices.add(WeakReference(index))
-
- return RelayItemHolder(view, this, index)
- }
-
- override fun onBindViewHolder(holder: RelayItemHolder, position: Int) {
- val relayList = this.relayList
-
- if (relayList != null) {
- var remaining = position
-
- for (country in relayList.countries) {
- val itemOrCount = country.getItem(remaining)
-
- when (itemOrCount) {
- is GetItemResult.Item -> {
- bindHolderToItem(holder, itemOrCount.item, position)
- return
- }
- is GetItemResult.Count -> remaining -= itemOrCount.count
- }
- }
- }
- }
-
- override fun getItemCount() =
- relayList?.countries?.map { country -> country.visibleItemCount }?.sum() ?: 0
-
- fun onRelayListChange(newRelayList: RelayList, newSelectedItem: RelayItem?) {
- val initializedRelayList = relayList == null
-
- relayList = newRelayList
- selectedItem = newSelectedItem
-
- if (initializedRelayList) {
- notifyItemRangeInserted(0, getItemCount())
- } else {
- notifyDataSetChanged()
- }
- }
-
- fun selectItem(item: RelayItem?, holder: RelayItemHolder?) {
- selectedItemHolder?.selected = false
-
- selectedItem = item
- selectedItemHolder = holder
- selectedItemHolder?.apply { selected = true }
-
- onSelect?.invoke(item)
- }
-
- fun expandItem(itemIndex: RelayListAdapterPosition, childCount: Int) {
- val position = itemIndex.position
-
- updateActiveIndices(position, childCount)
- notifyItemRangeInserted(position + 1, childCount)
- }
-
- fun collapseItem(itemIndex: RelayListAdapterPosition, childCount: Int) {
- val position = itemIndex.position
-
- updateActiveIndices(position, -childCount)
- notifyItemRangeRemoved(position + 1, childCount)
- }
-
- private fun updateActiveIndices(position: Int, delta: Int) {
- val activeIndicesIterator = activeIndices.iterator()
-
- while (activeIndicesIterator.hasNext()) {
- val index = activeIndicesIterator.next().get()
-
- if (index == null) {
- activeIndicesIterator.remove()
- } else {
- val indexPosition = index.position
-
- if (indexPosition > position) {
- index.position = indexPosition + delta
- }
- }
- }
- }
-
- private fun bindHolderToItem(holder: RelayItemHolder, item: RelayItem, position: Int) {
- holder.item = item
- holder.itemPosition.position = position
-
- if (selectedItem != null && selectedItem == item) {
- holder.selected = true
- selectedItemHolder = holder
- } else {
- holder.selected = false
- }
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt
new file mode 100644
index 0000000000..401f03d744
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt
@@ -0,0 +1,206 @@
+package net.mullvad.mullvadvpn.relaylist
+
+import net.mullvad.mullvadvpn.model.Constraint
+import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
+import net.mullvad.mullvadvpn.model.RelayList
+
+/**
+ * Convert from a model.RelayList to list of relaylist.RelayCountry Non-wiregaurd relays are
+ * filtered out So are also cities that only contains non-wireguard relays Countries, cities and
+ * relays are ordered by name
+ */
+fun RelayList.toRelayCountries(): List<RelayCountry> {
+ val relayCountries =
+ this.countries
+ .map { country ->
+ val cities = mutableListOf<RelayCity>()
+ val relayCountry = RelayCountry(country.name, country.code, false, cities)
+
+ for (city in country.cities) {
+ val relays = mutableListOf<Relay>()
+ val relayCity =
+ RelayCity(
+ name = city.name,
+ code = city.code,
+ location = GeographicLocationConstraint.City(country.code, city.code),
+ expanded = false,
+ relays = relays
+ )
+
+ val validCityRelays = city.relays.filter { relay -> relay.isWireguardRelay }
+
+ for (relay in validCityRelays) {
+ relays.add(
+ Relay(
+ name = relay.hostname,
+ location =
+ GeographicLocationConstraint.Hostname(
+ country.code,
+ city.code,
+ relay.hostname
+ ),
+ locationName = "${city.name} (${relay.hostname})",
+ active = relay.active
+ )
+ )
+ }
+ relays.sortWith(RelayNameComparator)
+
+ if (relays.isNotEmpty()) {
+ cities.add(relayCity)
+ }
+ }
+
+ cities.sortBy { it.name }
+ relayCountry
+ }
+ .filter { country -> country.cities.isNotEmpty() }
+ .toMutableList()
+
+ relayCountries.sortBy { it.name }
+
+ return relayCountries.toList()
+}
+
+fun List<RelayCountry>.findItemForLocation(
+ constraint: Constraint<GeographicLocationConstraint>
+): RelayItem? {
+ return when (constraint) {
+ is Constraint.Any -> null
+ is Constraint.Only -> {
+ when (val location = constraint.value) {
+ is GeographicLocationConstraint.Country -> {
+ this.find { country -> country.code == location.countryCode }
+ }
+ is GeographicLocationConstraint.City -> {
+ val country = this.find { country -> country.code == location.countryCode }
+
+ country?.cities?.find { city -> city.code == location.cityCode }
+ }
+ is GeographicLocationConstraint.Hostname -> {
+ val country = this.find { country -> country.code == location.countryCode }
+
+ val city = country?.cities?.find { city -> city.code == location.cityCode }
+
+ city?.relays?.find { relay -> relay.name == location.hostname }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Filter and expand the list based on search terms If a country is matched, that country and all
+ * its children are added to the list, but the country is not expanded If a city is matched, its
+ * parent country is added and expanded if needed and its children are added, but the city is not
+ * expanded If a relay is matched, its parents are added and expanded and itself is also added.
+ */
+fun List<RelayCountry>.filterOnSearchTerm(
+ searchTerm: String,
+ selectedItem: RelayItem?
+): List<RelayCountry> {
+ return if (searchTerm.length >= MIN_SEARCH_LENGTH) {
+ val filteredCountries = mutableMapOf<String, RelayCountry>()
+ this.forEach { relayCountry ->
+ val cities = mutableListOf<RelayCity>()
+
+ // Try to match the search term with a country
+ // If we match a country, add that country and all cities and relays in that country
+ // Do not currently expand the country or any city
+ if (relayCountry.name.contains(other = searchTerm, ignoreCase = true)) {
+ cities.addAll(relayCountry.cities.map { city -> city.copy(expanded = false) })
+ filteredCountries[relayCountry.code] =
+ relayCountry.copy(expanded = false, cities = cities)
+ }
+
+ // Go through and try to match the search term with every city
+ relayCountry.cities.forEach { relayCity ->
+ val relays = mutableListOf<Relay>()
+ // If we match and we already added the country to the filtered list just expand the
+ // country.
+ // If the country is not currently in the filtered list, add it and expand it.
+ // Finally if the city has not already been added to the filtered list, add it, but
+ // do not expand it yet.
+ if (relayCity.name.contains(other = searchTerm, ignoreCase = true)) {
+ if (filteredCountries.containsKey(relayCountry.code)) {
+ filteredCountries[relayCountry.code]?.expanded = true
+ } else {
+ filteredCountries[relayCountry.code] =
+ relayCountry.copy(expanded = true, cities = cities)
+ }
+ if (cities.none { city -> city.code == relayCity.code }) {
+ cities.add(relayCity.copy(expanded = false))
+ }
+ }
+
+ // Go through and try to match the search term with every relay
+ relayCity.relays.forEach { relay ->
+ // If we match a relay, check if the county the relay is in already is added, if
+ // so expand, if not add and expand the country.
+ // Check if the city that the relay is in is already added to the filtered list,
+ // if so expand it, if not add it to the filtered list and expand it.
+ // Finally add the relay to the list.
+ if (relay.name.contains(other = searchTerm, ignoreCase = true)) {
+ if (filteredCountries.containsKey(relayCountry.code)) {
+ filteredCountries[relayCountry.code]?.expanded = true
+ } else {
+ filteredCountries[relayCountry.code] =
+ relayCountry.copy(expanded = true, cities = cities)
+ }
+ val city = cities.find { it.code == relayCity.code }
+ city?.let { city.expanded = true }
+ ?: run { cities.add(relayCity.copy(expanded = true, relays = relays)) }
+ relays.add(relay.copy())
+ }
+ }
+ }
+ }
+ filteredCountries.values.sortedBy { it.name }
+ } else {
+ this.expandItemForSelection(selectedItem)
+ }
+}
+
+/** Expand the parent(s), if any, for the current selected item */
+private fun List<RelayCountry>.expandItemForSelection(
+ selectedItem: RelayItem?
+): List<RelayCountry> {
+ return selectedItem?.let {
+ when (val location = selectedItem.location) {
+ is GeographicLocationConstraint.Country -> {
+ this
+ }
+ is GeographicLocationConstraint.City -> {
+ this.map { country ->
+ if (country.code == location.countryCode) {
+ country.copy(expanded = true)
+ } else {
+ country
+ }
+ }
+ }
+ is GeographicLocationConstraint.Hostname -> {
+ this.map { country ->
+ if (country.code == location.countryCode) {
+ country.copy(
+ expanded = true,
+ cities =
+ country.cities.map { city ->
+ if (city.code == location.cityCode) {
+ city.copy(expanded = true)
+ } else {
+ city
+ }
+ }
+ )
+ } else {
+ country
+ }
+ }
+ }
+ }
+ }
+ ?: this
+}
+
+private const val MIN_SEARCH_LENGTH = 2
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 03aaef1c84..12f135ffed 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
@@ -4,8 +4,10 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.ComposeView
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.screen.SelectLocationScreen
import net.mullvad.mullvadvpn.compose.theme.AppTheme
@@ -26,11 +28,16 @@ class SelectLocationFragment : BaseFragment(), StatusBarPainter, NavigationBarPa
return inflater.inflate(R.layout.fragment_compose, container, false).apply {
findViewById<ComposeView>(R.id.compose_view).setContent {
AppTheme {
+ val systemUiController = rememberSystemUiController()
+ systemUiController.setStatusBarColor(MaterialTheme.colorScheme.background)
+ systemUiController.setNavigationBarColor(MaterialTheme.colorScheme.background)
+
val state = vm.uiState.collectAsState().value
SelectLocationScreen(
uiState = state,
uiCloseAction = vm.uiCloseAction,
onSelectRelay = vm::selectRelay,
+ onSearchTermInput = vm::onSearchTermInput,
onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt
index eb31a264ac..6db223530d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt
@@ -10,8 +10,10 @@ import net.mullvad.mullvadvpn.model.PortRange
import net.mullvad.mullvadvpn.model.RelayConstraints
import net.mullvad.mullvadvpn.model.RelaySettings
import net.mullvad.mullvadvpn.model.WireguardConstraints
+import net.mullvad.mullvadvpn.relaylist.RelayCountry
import net.mullvad.mullvadvpn.relaylist.RelayItem
-import net.mullvad.mullvadvpn.relaylist.RelayList
+import net.mullvad.mullvadvpn.relaylist.findItemForLocation
+import net.mullvad.mullvadvpn.relaylist.toRelayCountries
import net.mullvad.mullvadvpn.util.toGeographicLocationConstraint
class RelayListListener(
@@ -19,7 +21,7 @@ class RelayListListener(
eventDispatcher: EventDispatcher,
private val settingsListener: SettingsListener
) {
- private var relayList: RelayList? = null
+ private var relayCountries: List<RelayCountry>? = null
private var relaySettings: RelaySettings? = null
private var portRanges: List<PortRange> = emptyList()
@@ -49,15 +51,15 @@ class RelayListListener(
connection.send(Request.SetWireguardConstraints(value).message)
}
- var onRelayListChange: ((RelayList, RelayItem?) -> Unit)? = null
+ var onRelayCountriesChange: ((List<RelayCountry>, RelayItem?) -> Unit)? = null
set(value) {
field = value
synchronized(this) {
- val relayList = this.relayList
+ val relayCountries = this.relayCountries
- if (relayList != null) {
- value?.invoke(relayList, selectedRelayItem)
+ if (relayCountries != null) {
+ value?.invoke(relayCountries, selectedRelayItem)
}
}
}
@@ -72,7 +74,7 @@ class RelayListListener(
init {
eventDispatcher.registerHandler(Event.NewRelayList::class) { event ->
event.relayList?.let { relayLocations ->
- relayListChanged(RelayList(relayLocations))
+ relayListChanged(relayLocations.toRelayCountries())
portRangesChanged(relayLocations.wireguardEndpointData.portRanges)
}
}
@@ -84,12 +86,12 @@ class RelayListListener(
fun onDestroy() {
settingsListener.relaySettingsNotifier.unsubscribe(this)
- onRelayListChange = null
+ onRelayCountriesChange = null
}
private fun relaySettingsChanged(newRelaySettings: RelaySettings?) {
synchronized(this) {
- val relayList = this.relayList
+ val relayCountries = this.relayCountries
val portRanges = this.portRanges
relaySettings =
@@ -98,19 +100,19 @@ class RelayListListener(
RelayConstraints(Constraint.Any(), WireguardConstraints(Constraint.Any()))
)
- if (relayList != null) {
- relayListChanged(relayList)
+ if (relayCountries != null) {
+ relayListChanged(relayCountries)
}
portRangesChanged(portRanges)
}
}
- private fun relayListChanged(newRelayList: RelayList) {
+ private fun relayListChanged(newRelayCountries: List<RelayCountry>) {
synchronized(this) {
- relayList = newRelayList
+ relayCountries = newRelayCountries
selectedRelayItem = findSelectedRelayItem()
- onRelayListChange?.invoke(newRelayList, selectedRelayItem)
+ onRelayCountriesChange?.invoke(newRelayCountries, selectedRelayItem)
}
}
@@ -129,10 +131,8 @@ class RelayListListener(
is RelaySettings.CustomTunnelEndpoint -> return null
is RelaySettings.Normal -> {
val location = relaySettings.relayConstraints.location
-
- return relayList?.findItemForLocation(
- location.toGeographicLocationConstraint(),
- true
+ return relayCountries?.findItemForLocation(
+ location.toGeographicLocationConstraint()
)
}
else -> {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt
index fe8e2662e8..fb2702628d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt
@@ -76,8 +76,8 @@ class ConnectViewModel(serviceConnectionManager: ServiceConnectionManager) : Vie
}
private fun RelayListListener.relayListCallbackFlow() = callbackFlow {
- onRelayListChange = { _, item -> this.trySend(item) }
- awaitClose { onRelayListChange = null }
+ onRelayCountriesChange = { _, item -> this.trySend(item) }
+ awaitClose { onRelayCountriesChange = null }
}
private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> =
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 9994f02546..31950af0cd 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
@@ -4,16 +4,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm
import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
@@ -23,21 +27,37 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.relayListListener
class SelectLocationViewModel(private val serviceConnectionManager: ServiceConnectionManager) :
ViewModel() {
private val _closeAction = MutableSharedFlow<Unit>()
+ private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM)
val uiState =
serviceConnectionManager.connectionState
.flatMapLatest { state ->
if (state is ServiceConnectionState.ConnectedReady) {
- state.container.relayListListener.relayListCallbackFlow()
+ flowOf(state.container)
} else {
emptyFlow()
}
}
- .map { (relayList, relayItem) ->
- SelectLocationUiState.ShowData(
- countries = relayList.countries,
- selectedRelay = relayItem
- )
+ .flatMapLatest { serviceConnection ->
+ combine(serviceConnection.relayListListener.relayListCallbackFlow(), _searchTerm) {
+ (relayCountries, relayItem),
+ searchTerm ->
+ Triple(
+ relayCountries.filterOnSearchTerm(searchTerm, relayItem),
+ relayItem,
+ searchTerm
+ )
+ }
+ }
+ .map { (relayCountries, relayItem, searchTerm) ->
+ if (searchTerm.isNotEmpty() && relayCountries.isEmpty()) {
+ SelectLocationUiState.NoSearchResultFound(searchTerm = searchTerm)
+ } else {
+ SelectLocationUiState.ShowData(
+ countries = relayCountries,
+ selectedRelay = relayItem
+ )
+ }
}
.stateIn(
viewModelScope,
@@ -53,8 +73,16 @@ class SelectLocationViewModel(private val serviceConnectionManager: ServiceConne
viewModelScope.launch { _closeAction.emit(Unit) }
}
+ fun onSearchTermInput(searchTerm: String) {
+ viewModelScope.launch { _searchTerm.emit(searchTerm) }
+ }
+
private fun RelayListListener.relayListCallbackFlow() = callbackFlow {
- onRelayListChange = { list, item -> this.trySend(list to item) }
- awaitClose { onRelayListChange = null }
+ onRelayCountriesChange = { list, item -> this.trySend(list to item) }
+ awaitClose { onRelayCountriesChange = null }
+ }
+
+ companion object {
+ private const val EMPTY_SEARCH_TERM = ""
}
}
diff --git a/android/app/src/main/res/drawable/icon_relay_active.xml b/android/app/src/main/res/drawable/icon_relay_active.xml
deleted file mode 100644
index 68b77b0641..0000000000
--- a/android/app/src/main/res/drawable/icon_relay_active.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
- android:shape="oval">
- <solid android:color="@color/green" />
- <size android:width="16dp"
- android:height="16dp" />
-</shape>
diff --git a/android/app/src/main/res/drawable/icon_relay_inactive.xml b/android/app/src/main/res/drawable/icon_relay_inactive.xml
deleted file mode 100644
index d01dc83f11..0000000000
--- a/android/app/src/main/res/drawable/icon_relay_inactive.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
- android:shape="oval">
- <solid android:color="@color/red" />
- <size android:width="16dp"
- android:height="16dp" />
-</shape>
diff --git a/android/app/src/main/res/drawable/icons_search.xml b/android/app/src/main/res/drawable/icons_search.xml
new file mode 100644
index 0000000000..732c13f4d2
--- /dev/null
+++ b/android/app/src/main/res/drawable/icons_search.xml
@@ -0,0 +1,22 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path android:fillColor="#D8D8D8"
+ android:fillType="evenOdd"
+ android:pathData="M10.5,3C14.642,3 18,6.358 18,10.5c0,1.77 -0.613,3.397 -1.64,4.68l4.169,4.294c0.55,0.566 0.54,1.487 -0.02,2.057 -0.56,0.57 -1.46,0.574 -2.01,0.008l-4.352,-4.484c-1.08,0.602 -2.323,0.945 -3.647,0.945C6.358,18 3,14.642 3,10.5 3,6.358 6.358,3 10.5,3zM10.5,5C7.462,5 5,7.462 5,10.5S7.462,16 10.5,16 16,13.538 16,10.5 13.538,5 10.5,5z" />
+ <group>
+ <clip-path android:pathData="M10.5,3C14.642,3 18,6.358 18,10.5c0,1.77 -0.613,3.397 -1.64,4.68l4.169,4.294c0.55,0.566 0.54,1.487 -0.02,2.057 -0.56,0.57 -1.46,0.574 -2.01,0.008l-4.352,-4.484c-1.08,0.602 -2.323,0.945 -3.647,0.945C6.358,18 3,14.642 3,10.5 3,6.358 6.358,3 10.5,3zM10.5,5C7.462,5 5,7.462 5,10.5S7.462,16 10.5,16 16,13.538 16,10.5 13.538,5 10.5,5z" />
+ <path android:fillColor="#294D73"
+ android:fillType="evenOdd"
+ android:pathData="M0,0H24V24H0z" />
+ </group>
+ <group>
+ <clip-path android:pathData="M11.604,3.299C15.715,3.804 18.639,7.546 18.134,11.657c-0.216,1.757 -1.022,3.297 -2.198,4.445l3.615,4.77c0.477,0.629 0.355,1.542 -0.271,2.039 -0.625,0.498 -1.519,0.392 -1.996,-0.237l-3.773,-4.981c-1.145,0.466 -2.421,0.655 -3.735,0.493C5.665,17.682 2.741,13.94 3.246,9.829 3.751,5.718 7.493,2.794 11.604,3.299zM11.36,5.284C8.345,4.914 5.601,7.057 5.231,10.073S7.004,15.832 10.02,16.202 15.779,14.429 16.149,11.413 14.376,5.654 11.36,5.284z" />
+ <path android:fillAlpha="0.2"
+ android:fillColor="#FBFCFD"
+ android:fillType="evenOdd"
+ android:pathData="M11.22,15.518s-3.072,-0.351 -4.658,-2.381c-0.509,-0.652 -0.698,-2.087 -0.566,-4.305 0.349,1.468 0.949,2.774 1.802,3.917 1.325,1.697 3.422,2.77 3.422,2.77z" />
+ </group>
+</vector>
diff --git a/android/app/src/main/res/layout/relay_list_item.xml b/android/app/src/main/res/layout/relay_list_item.xml
deleted file mode 100644
index e0b084901c..0000000000
--- a/android/app/src/main/res/layout/relay_list_item.xml
+++ /dev/null
@@ -1,53 +0,0 @@
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:background="@color/blue"
- android:orientation="horizontal">
- <RelativeLayout android:layout_width="wrap_content"
- android:layout_height="match_parent"
- android:layout_weight="1">
- <View android:id="@+id/click_area"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_alignParentLeft="true"
- android:layout_alignParentRight="true"
- android:focusable="true"
- android:clickable="true"
- android:background="?android:attr/selectableItemBackground" />
- <FrameLayout android:id="@+id/status"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentLeft="true"
- android:layout_centerVertical="true"
- android:layout_marginLeft="@dimen/country_row_padding">
- <ImageView android:id="@+id/relay_active"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_gravity="center"
- android:src="@drawable/icon_relay_active" />
- <ImageView android:id="@+id/selected"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:src="@drawable/icon_tick"
- android:visibility="invisible" />
- </FrameLayout>
- <TextView android:id="@+id/name"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="8dp"
- android:layout_marginVertical="14dp"
- android:layout_alignParentRight="true"
- android:layout_toRightOf="@id/status"
- android:textColor="@color/white"
- android:textSize="@dimen/text_medium_plus"
- android:textStyle="bold"
- android:text="" />
- </RelativeLayout>
- <ImageButton android:id="@+id/chevron"
- android:layout_width="wrap_content"
- android:layout_height="match_parent"
- android:layout_weight="0"
- android:paddingHorizontal="16dp"
- android:background="?android:attr/selectableItemBackground"
- android:src="@drawable/icon_chevron_expand" />
-</LinearLayout>
diff --git a/android/app/src/main/res/values/dimensions.xml b/android/app/src/main/res/values/dimensions.xml
index 7142006718..7c55e8f310 100644
--- a/android/app/src/main/res/values/dimensions.xml
+++ b/android/app/src/main/res/values/dimensions.xml
@@ -1,7 +1,4 @@
<resources>
- <dimen name="country_row_padding">18dp</dimen>
- <dimen name="city_row_padding">34dp</dimen>
- <dimen name="relay_row_padding">50dp</dimen>
<dimen name="dialog_margin">14dp</dimen>
<dimen name="account_login_input_height">48dp</dimen>
<dimen name="account_login_corner_radius">4dp</dimen>
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 0c3ba602a1..3fd1c760d3 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -204,4 +204,9 @@
<string name="quantum_secure_connection">QUANTUM SECURE CONNECTION</string>
<string name="wireguard_port_title">WireGuard Port</string>
<string name="wireguard_port_info_description">The automatic setting will randomly choose from the valid port ranges shown below.</string>
+ <string name="search_placeholder">Search for...</string>
+ <string name="select_location_empty_text_first_row">
+ <![CDATA[No result for <b>%s</b>.]]>
+ </string>
+ <string name="select_location_empty_text_second_row">Try a different search</string>
</resources>
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt
index a3c96349d9..2ef1d1f02c 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt
@@ -8,8 +8,6 @@ import org.junit.Test
class RelayNameComparatorTest {
- private val mockedCity = mockk<RelayCity>(relaxed = true)
-
@After
fun tearDown() {
unmockkAll()
@@ -17,16 +15,25 @@ class RelayNameComparatorTest {
@Test
fun test_compare_respect_numbers_in_name() {
- val relay9 = Relay(mockedCity, "se9-wireguard", false)
- val relay10 = Relay(mockedCity, "se10-wireguard", false)
+ val relay9 =
+ Relay(name = "se9-wireguard", location = mockk(), locationName = "mock", active = false)
+ val relay10 =
+ Relay(
+ name = "se10-wireguard",
+ location = mockk(),
+ locationName = "mock",
+ active = false
+ )
relay9 assertOrderBothDirection relay10
}
@Test
fun test_compare_same_name() {
- val relay9a = Relay(mockedCity, "se9-wireguard", false)
- val relay9b = Relay(mockedCity, "se9-wireguard", false)
+ val relay9a =
+ Relay(name = "se9-wireguard", location = mockk(), locationName = "mock", active = false)
+ val relay9b =
+ Relay(name = "se9-wireguard", location = mockk(), locationName = "mock", active = false)
assertTrue(RelayNameComparator.compare(relay9a, relay9b) == 0)
assertTrue(RelayNameComparator.compare(relay9b, relay9a) == 0)
@@ -34,10 +41,12 @@ class RelayNameComparatorTest {
@Test
fun test_compare_only_numbers_in_name() {
- val relay001 = Relay(mockedCity, "001", false)
- val relay1 = Relay(mockedCity, "1", false)
- val relay3 = Relay(mockedCity, "3", false)
- val relay100 = Relay(mockedCity, "100", false)
+ val relay001 =
+ Relay(name = "001", location = mockk(), locationName = "mock", active = false)
+ val relay1 = Relay(name = "1", location = mockk(), locationName = "mock", active = false)
+ val relay3 = Relay(name = "3", location = mockk(), locationName = "mock", active = false)
+ val relay100 =
+ Relay(name = "100", location = mockk(), locationName = "mock", active = false)
relay001 assertOrderBothDirection relay1
relay001 assertOrderBothDirection relay3
@@ -47,8 +56,10 @@ class RelayNameComparatorTest {
@Test
fun test_compare_without_numbers_in_name() {
- val relay9a = Relay(mockedCity, "se-wireguard", false)
- val relay9b = Relay(mockedCity, "se-wireguard", false)
+ val relay9a =
+ Relay(name = "se-wireguard", location = mockk(), locationName = "mock", active = false)
+ val relay9b =
+ Relay(name = "se-wireguard", location = mockk(), locationName = "mock", active = false)
assertTrue(RelayNameComparator.compare(relay9a, relay9b) == 0)
assertTrue(RelayNameComparator.compare(relay9b, relay9a) == 0)
@@ -56,18 +67,39 @@ class RelayNameComparatorTest {
@Test
fun test_compare_with_trailing_zeros_in_name() {
- val relay001 = Relay(mockedCity, "se001-wireguard", false)
- val relay005 = Relay(mockedCity, "se005-wireguard", false)
+ val relay001 =
+ Relay(
+ name = "se001-wireguard",
+ location = mockk(),
+ locationName = "mock",
+ active = false
+ )
+ val relay005 =
+ Relay(
+ name = "se005-wireguard",
+ location = mockk(),
+ locationName = "mock",
+ active = false
+ )
relay001 assertOrderBothDirection relay005
}
@Test
fun test_compare_prefix_and_numbers() {
- val relayAr2 = Relay(mockedCity, "ar2-wireguard", false)
- val relayAr8 = Relay(mockedCity, "ar8-wireguard", false)
- val relaySe5 = Relay(mockedCity, "se5-wireguard", false)
- val relaySe10 = Relay(mockedCity, "se10-wireguard", false)
+ val relayAr2 =
+ Relay(name = "ar2-wireguard", location = mockk(), locationName = "mock", active = false)
+ val relayAr8 =
+ Relay(name = "ar8-wireguard", location = mockk(), locationName = "mock", active = false)
+ val relaySe5 =
+ Relay(name = "se5-wireguard", location = mockk(), locationName = "mock", active = false)
+ val relaySe10 =
+ Relay(
+ name = "se10-wireguard",
+ location = mockk(),
+ locationName = "mock",
+ active = false
+ )
relayAr2 assertOrderBothDirection relayAr8
relayAr8 assertOrderBothDirection relaySe5
@@ -76,16 +108,25 @@ class RelayNameComparatorTest {
@Test
fun test_compare_suffix_and_numbers() {
- val relay2c = Relay(mockedCity, "se2-cloud", false)
- val relay2w = Relay(mockedCity, "se2-wireguard", false)
+ val relay2c =
+ Relay(name = "se2-cloud", location = mockk(), locationName = "mock", active = false)
+ val relay2w =
+ Relay(name = "se2-wireguard", location = mockk(), locationName = "mock", active = false)
relay2c assertOrderBothDirection relay2w
}
@Test
fun test_compare_different_length() {
- val relay22a = Relay(mockedCity, "se22", false)
- val relay22b = Relay(mockedCity, "se22-wireguard", false)
+ val relay22a =
+ Relay(name = "se22", location = mockk(), locationName = "mock", active = false)
+ val relay22b =
+ Relay(
+ name = "se22-wireguard",
+ location = mockk(),
+ locationName = "mock",
+ active = false
+ )
relay22a assertOrderBothDirection relay22b
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt
index 8c101e97a8..8e2bd28c52 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt
@@ -17,7 +17,6 @@ import net.mullvad.mullvadvpn.model.GeoIpLocation
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.relaylist.RelayCountry
import net.mullvad.mullvadvpn.relaylist.RelayItem
-import net.mullvad.mullvadvpn.relaylist.RelayList
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache
import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy
@@ -60,7 +59,7 @@ class ConnectViewModelTest {
// Captures
private val locationSlot = slot<((GeoIpLocation?) -> Unit)>()
- private val relaySlot = slot<(RelayList, RelayItem?) -> Unit>()
+ private val relaySlot = slot<(List<RelayCountry>, RelayItem?) -> Unit>()
// Event notifiers
private val eventNotifierTunnelUiState = EventNotifier<TunnelState>(TunnelState.Disconnected)
@@ -85,7 +84,7 @@ class ConnectViewModelTest {
every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState
// Listeners
every { mockLocationInfoCache.onNewLocation = capture(locationSlot) } answers {}
- every { mockRelayListListener.onRelayListChange = capture(relaySlot) } answers {}
+ every { mockRelayListListener.onRelayCountriesChange = capture(relaySlot) } answers {}
every { mockAppVersionInfoCache.onUpdate = any() } answers {}
viewModel = ConnectViewModel(mockServiceConnectionManager)
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 ba7ea30c41..4c14c2519d 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
@@ -19,7 +19,7 @@ import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
import net.mullvad.mullvadvpn.relaylist.RelayCountry
import net.mullvad.mullvadvpn.relaylist.RelayItem
-import net.mullvad.mullvadvpn.relaylist.RelayList
+import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm
import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy
import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
@@ -43,7 +43,7 @@ class SelectLocationViewModelTest {
private val mockRelayListListener: RelayListListener = mockk(relaxUnitFun = true)
// Captures
- private val relaySlot = slot<(RelayList, RelayItem?) -> Unit>()
+ private val relaySlot = slot<(List<RelayCountry>, RelayItem?) -> Unit>()
private val serviceConnectionState =
MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
@@ -53,10 +53,11 @@ class SelectLocationViewModelTest {
every { mockServiceConnectionManager.connectionState } returns serviceConnectionState
every { mockServiceConnectionContainer.relayListListener } returns mockRelayListListener
- every { mockRelayListListener.onRelayListChange = capture(relaySlot) } answers {}
- every { mockRelayListListener.onRelayListChange = null } answers {}
+ every { mockRelayListListener.onRelayCountriesChange = capture(relaySlot) } answers {}
+ every { mockRelayListListener.onRelayCountriesChange = null } answers {}
mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS)
+ mockkStatic(RELAY_LIST_EXTENSIONS)
viewModel = SelectLocationViewModel(mockServiceConnectionManager)
}
@@ -77,14 +78,13 @@ class SelectLocationViewModelTest {
// Arrange
val mockCountries = listOf<RelayCountry>(mockk(), mockk())
val selectedRelay: RelayItem = mockk()
- val mockRelayList: RelayList = mockk()
- every { mockRelayList.countries } returns mockCountries
+ every { mockCountries.filterOnSearchTerm(any(), selectedRelay) } returns mockCountries
// Act, Assert
viewModel.uiState.test {
serviceConnectionState.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
- relaySlot.captured.invoke(mockRelayList, selectedRelay)
+ relaySlot.captured.invoke(mockCountries, selectedRelay)
assertEquals(SelectLocationUiState.Loading, awaitItem())
val actualState = awaitItem()
@@ -99,14 +99,13 @@ class SelectLocationViewModelTest {
// Arrange
val mockCountries = listOf<RelayCountry>(mockk(), mockk())
val selectedRelay: RelayItem? = null
- val mockRelayList: RelayList = mockk()
- every { mockRelayList.countries } returns mockCountries
+ every { mockCountries.filterOnSearchTerm(any(), selectedRelay) } returns mockCountries
// Act, Assert
viewModel.uiState.test {
serviceConnectionState.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
- relaySlot.captured.invoke(mockRelayList, selectedRelay)
+ relaySlot.captured.invoke(mockCountries, selectedRelay)
assertEquals(SelectLocationUiState.Loading, awaitItem())
val actualState = awaitItem()
@@ -138,8 +137,73 @@ class SelectLocationViewModelTest {
}
}
+ @Test
+ fun testFilterRelay() = runTest {
+ // Arrange
+ val mockCountries = listOf<RelayCountry>(mockk(), mockk())
+ val selectedRelay: RelayItem? = null
+ val mockRelayList: List<RelayCountry> = mockk(relaxed = true)
+ val mockSearchString = "SEARCH"
+ every { mockRelayList.filterOnSearchTerm(mockSearchString, selectedRelay) } returns
+ mockCountries
+
+ // Act, Assert
+ viewModel.uiState.test {
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ relaySlot.captured.invoke(mockRelayList, selectedRelay)
+
+ // Wait for loading
+ assertEquals(SelectLocationUiState.Loading, awaitItem())
+ // Wait for first data
+ assertIs<SelectLocationUiState.ShowData>(awaitItem())
+
+ // Update search string
+ viewModel.onSearchTermInput(mockSearchString)
+
+ // Assert
+ val actualState = awaitItem()
+ assertIs<SelectLocationUiState.ShowData>(actualState)
+ assertLists(mockCountries, actualState.countries)
+ assertEquals(selectedRelay, actualState.selectedRelay)
+ }
+ }
+
+ @Test
+ fun testFilterNotFound() = runTest {
+ // Arrange
+ val mockCountries = emptyList<RelayCountry>()
+ val selectedRelay: RelayItem? = null
+ val mockRelayList: List<RelayCountry> = mockk(relaxed = true)
+ val mockSearchString = "SEARCH"
+ every { mockRelayList.filterOnSearchTerm(mockSearchString, selectedRelay) } returns
+ mockCountries
+
+ // Act, Assert
+ viewModel.uiState.test {
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ relaySlot.captured.invoke(mockRelayList, selectedRelay)
+
+ // Wait for loading
+ assertEquals(SelectLocationUiState.Loading, awaitItem())
+ // Wait for first data
+ assertIs<SelectLocationUiState.ShowData>(awaitItem())
+
+ // Update search string
+ viewModel.onSearchTermInput(mockSearchString)
+
+ // Assert
+ val actualState = awaitItem()
+ assertIs<SelectLocationUiState.NoSearchResultFound>(actualState)
+ assertEquals(mockSearchString, actualState.searchTerm)
+ }
+ }
+
companion object {
private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS =
"net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt"
+ private const val RELAY_LIST_EXTENSIONS =
+ "net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt"
}
}