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