diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-07-12 14:52:55 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-07-12 14:52:55 +0200 |
| commit | 871df2b8749ba21faee60cdc0c9b74ff7d4915ad (patch) | |
| tree | db7c5eae262f88ff5688ac3fa5bf1fceaa46febb | |
| parent | 7ffe7307ca2a969193d0eec4853248d5cdaa4fa7 (diff) | |
| parent | 7dbf420241ca1496c8c9a132635ce255425d95a6 (diff) | |
| download | mullvadvpn-871df2b8749ba21faee60cdc0c9b74ff7d4915ad.tar.xz mullvadvpn-871df2b8749ba21faee60cdc0c9b74ff7d4915ad.zip | |
Merge branch 'add-search-bar-to-local-selection-list-droid-156'
50 files changed, 842 insertions, 617 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index b227b96d45..4b03a51a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Line wrap the file at 100 chars. Th - Prevent incoming connections from outside the VPN in Android 11+ when Local Network Sharing is turned off. - Add quantum resistant tunneling. +- Add search for select location. ### Changed - In the CLI, update the `tunnel` subcommand to resemble `relay` more. For example, by adding a 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/Chevron.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Chevron.kt index b348ca733f..1e34b13896 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Chevron.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Chevron.kt @@ -15,11 +15,11 @@ import net.mullvad.mullvadvpn.R @Composable fun ChevronView(modifier: Modifier = Modifier, isExpanded: Boolean) { val resourceId = R.drawable.icon_chevron - val rotation = remember { Animatable(90f) } + val rotation = remember { Animatable(90f + if (isExpanded) 180f else 0f) } LaunchedEffect(isExpanded) { rotation.animateTo( - targetValue = 90f + if (isExpanded) 0f else 180f, + targetValue = 90f + if (isExpanded) 180f else 0f, animationSpec = tween(100, easing = LinearEasing) ) } 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/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt index f59581f515..dd7e4e22c6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt @@ -303,7 +303,7 @@ fun VpnSettingsScreen( itemWithDivider { ExpandableComposeCell( title = stringResource(R.string.dns_content_blockers_title), - isExpanded = !expandContentBlockersState, + isExpanded = expandContentBlockersState, isEnabled = !uiState.isCustomDnsEnabled, onInfoClicked = { onContentsBlockersInfoClick() }, onCellClicked = { expandContentBlockersState = !expandContentBlockersState } 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-da/strings.xml b/android/app/src/main/res/values-da/strings.xml index 8379e714a5..f82406aa23 100644 --- a/android/app/src/main/res/values-da/strings.xml +++ b/android/app/src/main/res/values-da/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Fjern</string> <string name="report_a_problem">Rapporter et problem</string> <string name="reset_to_default_button">Nulstil til standard</string> + <string name="search_placeholder">Søg efter...</string> <string name="secure_connection">SIKKER TILSLUTNING</string> <string name="secured">Sikret</string> <string name="select_location">Vælg placering</string> <string name="select_location_description">Når du er tilsluttet, maskeres din virkelige placering med en privat og sikker placering i den valgte region.</string> + <string name="select_location_empty_text_first_row">Intet resultat for <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Prøv en anden søgning.</string> <string name="send">Send</string> <string name="send_anyway">Send alligevel</string> <string name="sending">Sender...</string> diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 996bf50648..fc423b7c63 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Entfernen</string> <string name="report_a_problem">Problem melden</string> <string name="reset_to_default_button">Auf Standard zurücksetzen</string> + <string name="search_placeholder">Suchen nach …</string> <string name="secure_connection">SICHERE VERBINDUNG</string> <string name="secured">Gesichert</string> <string name="select_location">Ort auswählen</string> <string name="select_location_description">Wenn Sie verbunden sind, wird Ihr tatsächlicher Standort durch einem privaten und sicheren Standort in der ausgewählten Region maskiert.</string> + <string name="select_location_empty_text_first_row">Keine Ergebnisse für <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Versuchen Sie es mit einer anderen Suchanfrage.</string> <string name="send">Senden</string> <string name="send_anyway">Trotzdem senden</string> <string name="sending">Wird gesendet...</string> diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 14d04fab32..f365d518ae 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Quitar</string> <string name="report_a_problem">Informar de un problema</string> <string name="reset_to_default_button">Restablecer a valores predeterminados</string> + <string name="search_placeholder">Buscar...</string> <string name="secure_connection">CONEXIÓN SEGURA</string> <string name="secured">Protegido</string> <string name="select_location">Seleccionar ubicación</string> <string name="select_location_description">Mientras esté conectado, su ubicación real permanecerá oculta con una ubicación privada y segura en la región seleccionada.</string> + <string name="select_location_empty_text_first_row">No hay resultados para <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Pruebe con otra búsqueda.</string> <string name="send">Enviar</string> <string name="send_anyway">Enviar de todos modos</string> <string name="sending">Enviando…</string> diff --git a/android/app/src/main/res/values-fi/strings.xml b/android/app/src/main/res/values-fi/strings.xml index 6e03fc7cc5..f34f511072 100644 --- a/android/app/src/main/res/values-fi/strings.xml +++ b/android/app/src/main/res/values-fi/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Poista</string> <string name="report_a_problem">Raportoi ongelma</string> <string name="reset_to_default_button">Palauta oletusarvo</string> + <string name="search_placeholder">Hae...</string> <string name="secure_connection">SUOJATTU YHTEYS</string> <string name="secured">Suojattu</string> <string name="select_location">Valitse sijainti</string> <string name="select_location_description">Kun yhteys on muodostettu, yksityinen ja suojattu sijainti valitulta alueelta naamioi todellisen sijaintisi.</string> + <string name="select_location_empty_text_first_row">Ei tuloksia haulle <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Kokeile toista hakua.</string> <string name="send">Lähetä</string> <string name="send_anyway">Lähetä silti</string> <string name="sending">Lähetetään...</string> diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index a312b828f3..e5434473c4 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Supprimer</string> <string name="report_a_problem">Signaler un problème</string> <string name="reset_to_default_button">Réinitialiser à la valeur par défaut</string> + <string name="search_placeholder">Rechercher...</string> <string name="secure_connection">CONNEXION SÉCURISÉE</string> <string name="secured">Sécurisé</string> <string name="select_location">Sélectionner une localisation</string> <string name="select_location_description">Quand vous êtes connecté, votre vraie localisation est masquée par une localisation privée et sécurisée de la région sélectionnée.</string> + <string name="select_location_empty_text_first_row">Aucun résultat pour <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Essayez une autre recherche.</string> <string name="send">Envoyer</string> <string name="send_anyway">Envoyer quand même</string> <string name="sending">Envoi...</string> diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index f9d5eafc01..f005c90a47 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Rimuovi</string> <string name="report_a_problem">Segnala un problema</string> <string name="reset_to_default_button">Ripristina predefiniti</string> + <string name="search_placeholder">Cerca...</string> <string name="secure_connection">CONNESSIONE PROTETTA</string> <string name="secured">Protetto</string> <string name="select_location">Seleziona posizione</string> <string name="select_location_description">Durante la connessione, la tua posizione reale è nascosta da una posizione privata e protetta nell\'area selezionata.</string> + <string name="select_location_empty_text_first_row">Nessun risultato per <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Prova un\'altra ricerca.</string> <string name="send">Invia</string> <string name="send_anyway">Invia comunque</string> <string name="sending">Invio...</string> diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml index f9bc8ac000..e50604b756 100644 --- a/android/app/src/main/res/values-ja/strings.xml +++ b/android/app/src/main/res/values-ja/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">削除</string> <string name="report_a_problem">問題を報告する</string> <string name="reset_to_default_button">デフォルトにリセット</string> + <string name="search_placeholder">検索...</string> <string name="secure_connection">セキュリティ保護された接続</string> <string name="secured">セキュリティ保護されています</string> <string name="select_location">場所を選択する</string> <string name="select_location_description">接続中、あなたの実際の場所は選択した地域内の非公開かつセキュリティ保護された場所で隠されます。</string> + <string name="select_location_empty_text_first_row"><b>%1$s</b>に該当する検索結果はありません。</string> + <string name="select_location_empty_text_second_row">別の検索をお試しください。</string> <string name="send">送信</string> <string name="send_anyway">とにかく送信する</string> <string name="sending">送信中...</string> diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml index d0f3d31ca4..72206d1f2d 100644 --- a/android/app/src/main/res/values-ko/strings.xml +++ b/android/app/src/main/res/values-ko/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">제거</string> <string name="report_a_problem">문제 신고</string> <string name="reset_to_default_button">기본값으로 재설정</string> + <string name="search_placeholder">검색...</string> <string name="secure_connection">보안 연결</string> <string name="secured">안전함</string> <string name="select_location">위치 선택</string> <string name="select_location_description">연결되어 있는 동안 실제 위치는 선택한 지역의 안전한 비공개 위치로 마스킹됩니다.</string> + <string name="select_location_empty_text_first_row"><b>%1$s</b>에 대한 결과가 없습니다.</string> + <string name="select_location_empty_text_second_row">다른 검색어를 시도하세요.</string> <string name="send">전송</string> <string name="send_anyway">그래도 전송</string> <string name="sending">전송 중...</string> diff --git a/android/app/src/main/res/values-my/strings.xml b/android/app/src/main/res/values-my/strings.xml index d7e9372363..ea41d88978 100644 --- a/android/app/src/main/res/values-my/strings.xml +++ b/android/app/src/main/res/values-my/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">ဖယ်ရှားရန်</string> <string name="report_a_problem">ပြဿနာ ရီပို့တ်လုပ်ရန်</string> <string name="reset_to_default_button">ပုံသေသို့ ပြန်လည်သတ်မှတ်ရန်</string> + <string name="search_placeholder">ရှာရန်...</string> <string name="secure_connection">လုံခြုံသည့် ချိတ်ဆက်မှု</string> <string name="secured">လုံခြုံပါသည်</string> <string name="select_location">တည်နေရာ ရွေးရန်</string> <string name="select_location_description">ချိတ်ဆက်ထားချိန်တွင် သင့်တည်နေရာအမှန်ကို ရွေးချယ်ထားသည့် ဒေသရှိ လျှို့ဝှက်လုံခြုံသည့် တည်နေရာဖြင့် ဖုံးကွယ်ထားပါသည်။</string> + <string name="select_location_empty_text_first_row"><b>%1$s</b> အတွက် ရလဒ် မရှိပါ။</string> + <string name="select_location_empty_text_second_row">မတူညီသော ရှာဖွေမှုဖြင့် ကြိုးစားကြည့်ပါ။</string> <string name="send">ပို့ရန်</string> <string name="send_anyway">မည်သို့ပင်ဖြစ်စေ ပို့ရန်</string> <string name="sending">ပို့နေဆဲ...</string> diff --git a/android/app/src/main/res/values-nb/strings.xml b/android/app/src/main/res/values-nb/strings.xml index 03492e41c6..847c9c8978 100644 --- a/android/app/src/main/res/values-nb/strings.xml +++ b/android/app/src/main/res/values-nb/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Fjern</string> <string name="report_a_problem">Rapporter et problem</string> <string name="reset_to_default_button">Tilbakestill til standard</string> + <string name="search_placeholder">Søk etter ...</string> <string name="secure_connection">SIKKER TILKOBLING</string> <string name="secured">Sikret</string> <string name="select_location">Velg plassering</string> <string name="select_location_description">Mens du er tilkoblet vil din egentlige plassering være skjult med en privat og sikker plassering i den valgte regionen.</string> + <string name="select_location_empty_text_first_row">Ingen resultater for <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Prøv et annet søk.</string> <string name="send">Send</string> <string name="send_anyway">Send allikevel</string> <string name="sending">Sender ...</string> diff --git a/android/app/src/main/res/values-nl/strings.xml b/android/app/src/main/res/values-nl/strings.xml index e6f5a6fff6..64a93d45d1 100644 --- a/android/app/src/main/res/values-nl/strings.xml +++ b/android/app/src/main/res/values-nl/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Verwijderen</string> <string name="report_a_problem">Een probleem rapporteren</string> <string name="reset_to_default_button">Standaardwaarde herstellen</string> + <string name="search_placeholder">Zoeken naar...</string> <string name="secure_connection">BEVEILIGDE VERBINDING</string> <string name="secured">Beveiligd</string> <string name="select_location">Locatie selecteren</string> <string name="select_location_description">Wanneer u verbonden bent, wordt uw daadwerkelijke locatie gemaskeerd met een privé en veilige locatie in de geselecteerde regio.</string> + <string name="select_location_empty_text_first_row">Geen resultaten voor <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Probeer een andere zoekopdracht.</string> <string name="send">Verzenden</string> <string name="send_anyway">Toch verzenden</string> <string name="sending">Verzenden...</string> diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 4ff503ec6d..90d87491ab 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Usuń</string> <string name="report_a_problem">Zgłoś problem</string> <string name="reset_to_default_button">Przywróć domyślne</string> + <string name="search_placeholder">Wyszukaj...</string> <string name="secure_connection">BEZPIECZNE POŁĄCZENIE</string> <string name="secured">Zabezpieczone</string> <string name="select_location">Wybierz lokalizację</string> <string name="select_location_description">Podczas połączenia Twoja prawdziwa lokalizacja jest maskowana prywatną, bezpieczną lokalizacją w wybranym regionie.</string> + <string name="select_location_empty_text_first_row">Brak wyników dla <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Wypróbuj inne wyszukiwanie.</string> <string name="send">Wyślij</string> <string name="send_anyway">Mimo to wyślij</string> <string name="sending">Wysyłanie...</string> diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml index 3037dd4eed..1cf2bbb289 100644 --- a/android/app/src/main/res/values-pt/strings.xml +++ b/android/app/src/main/res/values-pt/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Remover</string> <string name="report_a_problem">Reportar um problema</string> <string name="reset_to_default_button">Repor para as predefinições</string> + <string name="search_placeholder">Pesquisar por...</string> <string name="secure_connection">LIGAÇÃO SEGURA</string> <string name="secured">Seguro</string> <string name="select_location">Selecionar local</string> <string name="select_location_description">Enquanto estiver ligado, a sua localização real será mascarada com uma localização privada e segura na região selecionada.</string> + <string name="select_location_empty_text_first_row">Sem resultados para <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Experimente uma pesquisa diferente.</string> <string name="send">Enviar</string> <string name="send_anyway">Enviar mesmo assim</string> <string name="sending">A enviar...</string> diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 33ec82869c..d394aeb07d 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Удалить</string> <string name="report_a_problem">Сообщение о проблеме</string> <string name="reset_to_default_button">Восстановить значение по умолчанию</string> + <string name="search_placeholder">Поиск...</string> <string name="secure_connection">ЗАЩИЩЕННОЕ ПОДКЛЮЧЕНИЕ</string> <string name="secured">Подключение защищено</string> <string name="select_location">Выбор местоположения</string> <string name="select_location_description">При подключении реальное местоположение маскируется защищенным конфиденциальным местоположением в выбранном регионе.</string> + <string name="select_location_empty_text_first_row">По запросу <b>%1$s</b> ничего не найдено.</string> + <string name="select_location_empty_text_second_row">Измените условие поиска.</string> <string name="send">Отправить</string> <string name="send_anyway">Все равно отправить</string> <string name="sending">Идет отправка...</string> diff --git a/android/app/src/main/res/values-sv/strings.xml b/android/app/src/main/res/values-sv/strings.xml index dcd91bb97b..1da6d39668 100644 --- a/android/app/src/main/res/values-sv/strings.xml +++ b/android/app/src/main/res/values-sv/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Ta bort</string> <string name="report_a_problem">Rapportera ett problem</string> <string name="reset_to_default_button">Återställ till standard</string> + <string name="search_placeholder">Sök efter …</string> <string name="secure_connection">SÄKER ANSLUTNING</string> <string name="secured">Skyddad</string> <string name="select_location">Välj plats</string> <string name="select_location_description">Medan du är ansluten maskeras din riktiga plats med en privat och säker plats i den valda regionen.</string> + <string name="select_location_empty_text_first_row">Inga resultat för <b>%1$s</b>.</string> + <string name="select_location_empty_text_second_row">Testa en annan sökning.</string> <string name="send">Skicka</string> <string name="send_anyway">Skicka ändå</string> <string name="sending">Skicka...</string> diff --git a/android/app/src/main/res/values-th/strings.xml b/android/app/src/main/res/values-th/strings.xml index 4ef7f78671..a85c2b8620 100644 --- a/android/app/src/main/res/values-th/strings.xml +++ b/android/app/src/main/res/values-th/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">ลบ</string> <string name="report_a_problem">รายงานปัญหา</string> <string name="reset_to_default_button">รีเซ็ตเป็นค่าเริ่มต้น</string> + <string name="search_placeholder">ค้นหา…</string> <string name="secure_connection">การเชื่อมต่อที่ปลอดภัย</string> <string name="secured">ปลอดภัย</string> <string name="select_location">เลือกตำแหน่งที่ตั้ง</string> <string name="select_location_description">ในขณะที่เชื่อมต่อ ตำแหน่งที่ตั้งจริงของคุณจะถูกปิดบัง ด้วยตำแหน่งที่ตั้งที่เป็นส่วนตัวและปลอดภัย ในภูมิภาคที่เลือก</string> + <string name="select_location_empty_text_first_row">ไม่มีผลลัพธ์สำหรับ <b>%1$s</b></string> + <string name="select_location_empty_text_second_row">ลองใช้การค้นหาอื่น</string> <string name="send">ส่ง</string> <string name="send_anyway">ส่งต่อไป</string> <string name="sending">กำลังส่ง...</string> diff --git a/android/app/src/main/res/values-tr/strings.xml b/android/app/src/main/res/values-tr/strings.xml index 252b74da9b..71672834ee 100644 --- a/android/app/src/main/res/values-tr/strings.xml +++ b/android/app/src/main/res/values-tr/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">Kaldır</string> <string name="report_a_problem">Bir sorun bildir</string> <string name="reset_to_default_button">Varsayılana sıfırla</string> + <string name="search_placeholder">Ara...</string> <string name="secure_connection">GÜVENLİ BAĞLANTI</string> <string name="secured">Güvenli</string> <string name="select_location">Konum seçin</string> <string name="select_location_description">Bağlıyken gerçek konumunuz, seçilen bölgedeki özel ve gizli bir konumla maskelenir.</string> + <string name="select_location_empty_text_first_row"><b>%1$s</b> için sonuç bulunamadı.</string> + <string name="select_location_empty_text_second_row">Farklı bir arama deneyin.</string> <string name="send">Gönder</string> <string name="send_anyway">Yine de gönder</string> <string name="sending">Gönderiliyor...</string> diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 77930489f9..48099250e7 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">移除</string> <string name="report_a_problem">报告问题</string> <string name="reset_to_default_button">重置为默认值</string> + <string name="search_placeholder">搜索…</string> <string name="secure_connection">安全连接</string> <string name="secured">已受保护</string> <string name="select_location">选择位置</string> <string name="select_location_description">连接时,将使用所选区域中一个私密且安全的位置隐藏您的真实位置。</string> + <string name="select_location_empty_text_first_row">没有关于<b>%1$s</b>的结果。</string> + <string name="select_location_empty_text_second_row">尝试其他搜索词。</string> <string name="send">发送</string> <string name="send_anyway">仍然发送</string> <string name="sending">正在发送…</string> diff --git a/android/app/src/main/res/values-zh-rTW/strings.xml b/android/app/src/main/res/values-zh-rTW/strings.xml index 3d297f6e9f..8d7fcd491d 100644 --- a/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/android/app/src/main/res/values-zh-rTW/strings.xml @@ -137,10 +137,13 @@ <string name="remove_button">移除</string> <string name="report_a_problem">回報問題</string> <string name="reset_to_default_button">重設為預設值</string> + <string name="search_placeholder">搜尋…</string> <string name="secure_connection">安全連線</string> <string name="secured">安全</string> <string name="select_location">選擇位置</string> <string name="select_location_description">連線時,會使用所選區域的一個私密安全位置,將您的真實位置遮住。</string> + <string name="select_location_empty_text_first_row"><b>%1$s</b> 沒有任何結果。</string> + <string name="select_location_empty_text_second_row">請嘗試使用其他關鍵字。</string> <string name="send">傳送</string> <string name="send_anyway">仍要傳送</string> <string name="sending">傳送中...</string> 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..dfed235066 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" } } |
