diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-06-01 10:11:10 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-07-12 14:32:30 +0200 |
| commit | 4fc7e95a2a26a1f8d221959a417a5b3832b46ede (patch) | |
| tree | 2ed1a6ab96bd607329ede46c5bfe96fc7ba9bf08 /android/app/src | |
| parent | 7ffe7307ca2a969193d0eec4853248d5cdaa4fa7 (diff) | |
| download | mullvadvpn-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')
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" } } |
