summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAlbin <albin@mullvad.net>2023-06-13 18:00:39 +0200
committerAlbin <albin@mullvad.net>2023-06-13 18:00:39 +0200
commit767d135f52c1dfbe97de5a7ae1cf506d345be721 (patch)
tree6754512c8df7f3d4c40ccf72b94e05ca021649c9
parent64ae1f81ad328e7e179eea9e49b3dbf96e910dce (diff)
parent2547e9cf8b180d5d2af3b1d73b9c2f88041d05b9 (diff)
downloadmullvadvpn-767d135f52c1dfbe97de5a7ae1cf506d345be721.tar.xz
mullvadvpn-767d135f52c1dfbe97de5a7ae1cf506d345be721.zip
Merge branch 'migrate-location-selection-view-to-droid-61'
-rw-r--r--CHANGELOG.md1
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt141
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt259
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Chevron.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt123
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt20
-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/ui/fragment/SelectLocationFragment.kt224
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt60
-rw-r--r--android/app/src/main/res/anim/fade_out.xml6
-rw-r--r--android/app/src/main/res/layout/select_location.xml35
-rw-r--r--android/app/src/main/res/layout/select_location_header.xml32
-rw-r--r--android/app/src/main/res/values/dimensions.xml1
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt145
21 files changed, 797 insertions, 305 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c56751780..18242c7052 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,7 @@ Line wrap the file at 100 chars. Th
- Rename "Advanced settings" to "VPN Settings".
- Move the "Split tunneling" menu item up a level from "VPN settings" to "Settings".
- Migrate split tunneling view to compose.
+- Migrate select Location view to compose.
### Fixed
- Update relay list after logging in. Previously, if the user wasn't logged in when the daemon
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
new file mode 100644
index 0000000000..1dc87cddff
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt
@@ -0,0 +1,141 @@
+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 io.mockk.MockKAnnotations
+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.RelayEndpointData
+import net.mullvad.mullvadvpn.model.RelayListCity
+import net.mullvad.mullvadvpn.model.RelayListCountry
+import net.mullvad.mullvadvpn.model.WireguardRelayEndpointData
+import net.mullvad.mullvadvpn.relaylist.RelayList
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class SelectLocationScreenTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun testDefaultState() {
+ // Arrange
+ composeTestRule.setContent {
+ SelectLocationScreen(
+ uiState = SelectLocationUiState.Loading,
+ uiCloseAction = MutableSharedFlow()
+ )
+ }
+
+ // 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()
+ }
+ }
+
+ @Test
+ fun testShowRelayListState() {
+ // Arrange
+ composeTestRule.setContent {
+ SelectLocationScreen(
+ uiState =
+ SelectLocationUiState.ShowData(
+ countries = DUMMY_RELAY_LIST.countries,
+ selectedRelay = null
+ ),
+ uiCloseAction = MutableSharedFlow()
+ )
+ }
+
+ // 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()
+ onNodeWithText("Relay Country 2").assertExists()
+ onNodeWithText("Relay City 2").assertDoesNotExist()
+ onNodeWithText("Relay host 2").assertDoesNotExist()
+ }
+ }
+
+ @Test
+ fun testShowRelayListStateSelected() {
+ // Arrange
+ composeTestRule.setContent {
+ AppTheme {
+ SelectLocationScreen(
+ uiState =
+ SelectLocationUiState.ShowData(
+ countries =
+ DUMMY_RELAY_LIST.countries.apply {
+ this[0].expanded = true
+ this[0].cities[0].expanded = true
+ },
+ selectedRelay = DUMMY_RELAY_LIST.countries[0].cities[0].relays[0]
+ ),
+ uiCloseAction = MutableSharedFlow()
+ )
+ }
+ }
+
+ // 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()
+ onNodeWithText("Relay Country 2").assertExists()
+ onNodeWithText("Relay City 2").assertDoesNotExist()
+ onNodeWithText("Relay host 2").assertDoesNotExist()
+ }
+ }
+
+ companion object {
+ private val DUMMY_RELAY_1 =
+ net.mullvad.mullvadvpn.model.Relay(
+ "Relay host 1",
+ true,
+ RelayEndpointData.Wireguard(WireguardRelayEndpointData)
+ )
+ private val DUMMY_RELAY_2 =
+ net.mullvad.mullvadvpn.model.Relay(
+ "Relay host 2",
+ true,
+ RelayEndpointData.Wireguard(WireguardRelayEndpointData)
+ )
+ private val DUMMY_RELAY_CITY_1 =
+ RelayListCity("Relay City 1", "RCi1", arrayListOf(DUMMY_RELAY_1))
+ private val DUMMY_RELAY_CITY_2 =
+ RelayListCity("Relay City 2", "RCi2", arrayListOf(DUMMY_RELAY_2))
+ private val DUMMY_RELAY_COUNTRY_1 =
+ RelayListCountry("Relay Country 1", "RCo1", arrayListOf(DUMMY_RELAY_CITY_1))
+ private val DUMMY_RELAY_COUNTRY_2 =
+ RelayListCountry("Relay Country 2", "RCo2", arrayListOf(DUMMY_RELAY_CITY_2))
+
+ private val DUMMY_RELAY_LIST =
+ RelayList(
+ net.mullvad.mullvadvpn.model.RelayList(
+ arrayListOf(DUMMY_RELAY_COUNTRY_1, DUMMY_RELAY_COUNTRY_2)
+ )
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
index 4b170a39d7..c845db3ff6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
@@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.compose.cell
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.Icon
@@ -97,7 +98,10 @@ private fun ExpandableComposeCellBody(
)
}
- ChevronView(isExpanded)
+ ChevronView(
+ isExpanded = isExpanded,
+ modifier = Modifier.size(Dimens.expandableCellChevronSize)
+ )
}
}
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
new file mode 100644
index 0000000000..196ae9c89d
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt
@@ -0,0 +1,259 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+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.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+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
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.Text
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.ChevronView
+import net.mullvad.mullvadvpn.compose.theme.AlphaInactive
+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.relaylist.Relay
+import net.mullvad.mullvadvpn.relaylist.RelayCity
+import net.mullvad.mullvadvpn.relaylist.RelayCountry
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.relaylist.RelayItemType
+
+@Composable
+@Preview
+private fun PreviewRelayLocationCell() {
+ AppTheme {
+ Column(Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ val relayCountry =
+ RelayCountry(
+ name = "Relay only country",
+ code = "ROC",
+ 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",
+ expanded = false,
+ emptyList()
+ )
+ )
+ )
+ val fullRelayList =
+ RelayCountry(
+ name = "Relay Country",
+ code = "RC",
+ expanded = true,
+ cities =
+ listOf(
+ RelayCity(
+ country = relayCountry,
+ "Relay City",
+ code = "RCI",
+ expanded = true,
+ relays =
+ listOf(
+ Relay(city = relayCity, name = "Relay Item", active = false)
+ )
+ )
+ )
+ )
+ // 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)
+ }
+ }
+}
+
+@Composable
+fun RelayLocationCell(
+ relay: RelayItem,
+ modifier: Modifier = Modifier,
+ activeColor: Color = MaterialTheme.colorScheme.surface,
+ inactiveColor: Color = MaterialTheme.colorScheme.error,
+ selectedItem: RelayItem? = null,
+ onSelectRelay: (item: RelayItem) -> Unit = {}
+) {
+ val startPadding =
+ when (relay.type) {
+ RelayItemType.Country -> Dimens.countryRowPadding
+ RelayItemType.City -> Dimens.cityRowPadding
+ RelayItemType.Relay -> Dimens.relayRowPadding
+ }
+ val selected = selectedItem == relay
+ val expanded = rememberSaveable { mutableStateOf(relay.expanded) }
+ val backgroundColor =
+ when {
+ selected -> MaterialTheme.colorScheme.inversePrimary
+ relay.type == RelayItemType.Country -> MaterialTheme.colorScheme.primary
+ relay.type == RelayItemType.City -> MaterialTheme.colorScheme.primaryContainer
+ relay.type == RelayItemType.Relay -> MaterialTheme.colorScheme.secondaryContainer
+ else -> MaterialTheme.colorScheme.primary
+ }
+ Column(
+ modifier =
+ modifier.then(
+ Modifier.fillMaxWidth()
+ .padding(top = Dimens.listItemDivider)
+ .wrapContentHeight()
+ .fillMaxWidth()
+ )
+ ) {
+ Row(
+ modifier =
+ Modifier.align(Alignment.Start)
+ .wrapContentHeight()
+ .height(IntrinsicSize.Min)
+ .fillMaxWidth()
+ .background(backgroundColor)
+ ) {
+ Row(
+ modifier =
+ Modifier.weight(1f)
+ .then(
+ if (relay.active) {
+ Modifier.clickable { onSelectRelay(relay) }
+ } else {
+ Modifier
+ }
+ )
+ ) {
+ Box(
+ modifier =
+ Modifier.align(Alignment.CenterVertically).padding(start = startPadding)
+ ) {
+ Box(
+ modifier =
+ Modifier.align(Alignment.CenterStart)
+ .size(Dimens.relayCircleSize)
+ .background(
+ color =
+ when {
+ selected -> Color.Transparent
+ relay.active -> activeColor
+ else -> inactiveColor
+ },
+ shape = CircleShape
+ )
+ )
+ Image(
+ painter = painterResource(id = R.drawable.icon_tick),
+ modifier =
+ Modifier.align(Alignment.CenterStart)
+ .alpha(
+ if (selected) {
+ AlphaVisible
+ } else {
+ AlphaInvisible
+ }
+ ),
+ contentDescription = null
+ )
+ }
+ Text(
+ text = relay.name,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier =
+ Modifier.weight(1f)
+ .align(Alignment.CenterVertically)
+ .alpha(
+ if (relay.active) {
+ AlphaVisible
+ } else {
+ AlphaInactive
+ }
+ )
+ .padding(
+ horizontal = Dimens.smallPadding,
+ vertical = Dimens.mediumPadding
+ )
+ )
+ }
+ if (relay.hasChildren) {
+ ChevronView(
+ isExpanded = expanded.value,
+ modifier =
+ Modifier.fillMaxHeight()
+ .clickable { expanded.value = !expanded.value }
+ .padding(horizontal = Dimens.mediumPadding)
+ .align(Alignment.CenterVertically)
+ )
+ }
+ }
+ if (expanded.value) {
+ when (relay) {
+ is RelayCountry -> {
+ relay.cities.forEach { relayCity ->
+ RelayLocationCell(
+ relay = relayCity,
+ selectedItem = selectedItem,
+ onSelectRelay = onSelectRelay,
+ modifier = Modifier.animateContentSize()
+ )
+ }
+ }
+ is RelayCity -> {
+ relay.relays.forEach { relay ->
+ RelayLocationCell(
+ relay = relay,
+ selectedItem = selectedItem,
+ onSelectRelay = onSelectRelay,
+ modifier = Modifier.animateContentSize()
+ )
+ }
+ }
+ }
+ }
+ }
+}
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 8d458c7077..49546aa8f4 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
@@ -4,7 +4,6 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -13,11 +12,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.unit.dp
import net.mullvad.mullvadvpn.R
@Composable
-fun ChevronView(isExpanded: Boolean) {
+fun ChevronView(modifier: Modifier = Modifier, isExpanded: Boolean) {
val resourceId = R.drawable.icon_chevron
val rotation = remember { Animatable(90f) }
@@ -31,6 +29,6 @@ fun ChevronView(isExpanded: Boolean) {
Image(
painterResource(id = resourceId),
contentDescription = null,
- modifier = Modifier.size(30.dp).rotate(rotation.value),
+ modifier = modifier.rotate(rotation.value),
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt
index 570bdf4eb4..9a9b852013 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt
@@ -49,6 +49,7 @@ fun CollapsingTopBar(
title: String,
progress: Float,
backTitle: String,
+ backIcon: Int? = null,
modifier: Modifier
) {
val expandedToolbarHeight = dimensionResource(id = R.dimen.expanded_toolbar_height)
@@ -76,7 +77,7 @@ fun CollapsingTopBar(
)
) {
Image(
- painter = painterResource(id = R.drawable.icon_back),
+ painter = painterResource(id = backIcon ?: R.drawable.icon_back),
contentDescription = stringResource(id = R.string.back),
modifier = Modifier.width(iconSize).height(iconSize)
)
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
new file mode 100644
index 0000000000..6dd3435e64
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt
@@ -0,0 +1,123 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+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.CollapsableAwareToolbarScaffold
+import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar
+import net.mullvad.mullvadvpn.compose.constant.ContentType
+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.compose.theme.Dimens
+import net.mullvad.mullvadvpn.relaylist.RelayCountry
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+
+@Preview
+@Composable
+fun PreviewSelectLocationScreen() {
+ val state =
+ SelectLocationUiState.ShowData(
+ countries = listOf(RelayCountry("Country 1", "Code 1", false, emptyList())),
+ selectedRelay = null
+ )
+ AppTheme { SelectLocationScreen(uiState = state, uiCloseAction = MutableSharedFlow()) }
+}
+
+@Composable
+fun SelectLocationScreen(
+ uiState: SelectLocationUiState,
+ uiCloseAction: SharedFlow<Unit>,
+ onSelectRelay: (item: RelayItem) -> Unit = {},
+ onBackClick: () -> Unit = {}
+) {
+ val state = rememberCollapsingToolbarScaffoldState()
+ val progress = state.toolbarState.progress
+ LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } }
+ CollapsableAwareToolbarScaffold(
+ 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
+ )
+ }
+ ) {
+ 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))
+ }
+ when (uiState) {
+ SelectLocationUiState.Loading -> {
+ item(contentType = ContentType.PROGRESS) {
+ CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier =
+ Modifier.size(
+ width = Dimens.progressIndicatorSize,
+ height = Dimens.progressIndicatorSize
+ )
+ .testTag(CIRCULAR_PROGRESS_INDICATOR)
+ )
+ }
+ }
+ is SelectLocationUiState.ShowData -> {
+ items(
+ count = uiState.countries.size,
+ key = { index -> uiState.countries[index].code },
+ contentType = { ContentType.ITEM }
+ ) { index ->
+ val country = uiState.countries[index]
+ RelayLocationCell(
+ relay = country,
+ selectedItem = uiState.selectedRelay,
+ onSelectRelay = onSelectRelay,
+ modifier = Modifier.animateContentSize()
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt
new file mode 100644
index 0000000000..3c90cd784a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt
@@ -0,0 +1,10 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.relaylist.RelayCountry
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+
+sealed interface SelectLocationUiState {
+ object Loading : SelectLocationUiState
+ data class ShowData(val countries: List<RelayCountry>, val selectedRelay: RelayItem?) :
+ SelectLocationUiState
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
index 7f79cdbe03..326908667c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
@@ -1,6 +1,10 @@
package net.mullvad.mullvadvpn.compose.test
+// VpnSettingsScreen
const val LAZY_LIST_TEST_TAG = "lazy_list_test_tag"
const val LAZY_LIST_LAST_ITEM_TEST_TAG = "lazy_list_last_item_test_tag"
const val LAZY_LIST_QUANTUM_ITEM_OFF_TEST_TAG = "lazy_list_quantum_item_off_test_tag"
const val LAZY_LIST_QUANTUM_ITEM_ON_TEST_TAG = "lazy_list_quantum_item_on_test_tag"
+
+// SelectLocationScreen
+const val CIRCULAR_PROGRESS_INDICATOR = "circular_progress_indicator"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt
index 9b42dbdb3c..527f9430f9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt
@@ -59,7 +59,9 @@ private val MullvadColorPalette =
onBackground = MullvadWhite,
onSurfaceVariant = MullvadWhite,
onPrimary = MullvadWhite,
- onSecondary = MullvadWhite60
+ onSecondary = MullvadWhite60,
+ inversePrimary = MullvadGreen,
+ error = MullvadRed
)
val Shapes =
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 e84350b987..a09697d0b7 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
@@ -9,6 +9,9 @@ data class Dimensions(
val cellHeight: Dp = 52.dp,
val cellLabelVerticalPadding: Dp = 14.dp,
val cellStartPadding: Dp = 22.dp,
+ val cityRowPadding: Dp = 34.dp,
+ val countryRowPadding: Dp = 18.dp,
+ val expandableCellChevronSize: Dp = 30.dp,
val indentedCellStartPadding: Dp = 38.dp,
val infoButtonVerticalPadding: Dp = 13.dp,
val listIconSize: Dp = 24.dp,
@@ -20,8 +23,12 @@ data class Dimensions(
val loadingSpinnerStrokeWidth: Dp = 3.dp,
val mediumPadding: Dp = 16.dp,
val progressIndicatorSize: Dp = 60.dp,
+ val relayCircleSize: Dp = 16.dp,
+ val relayRowPadding: Dp = 50.dp,
val selectableCellTextMargin: Dp = 12.dp,
- val smallPadding: Dp = 8.dp
+ val sideMargin: Dp = 22.dp,
+ val smallPadding: Dp = 8.dp,
+ val verticalSpace: Dp = 20.dp
)
val defaultDimensions = Dimensions()
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
index f0fcc4fa65..01ce12085f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
@@ -27,6 +27,7 @@ import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel
import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel
+import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel
import org.apache.commons.validator.routines.InetAddressValidator
@@ -91,6 +92,7 @@ val uiModule = module {
get(),
)
}
+ viewModel { SelectLocationViewModel(get()) }
}
const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME"
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 2c8493de8a..9500c43795 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
@@ -16,14 +16,14 @@ class RelayCity(
get() = relays.any { relay -> relay.active }
override val hasChildren
- get() = !relays.isEmpty()
+ get() = relays.isNotEmpty()
override val visibleChildCount: Int
get() {
- if (expanded) {
- return relays.size
+ return if (expanded) {
+ relays.size
} else {
- return 0
+ 0
}
}
@@ -39,18 +39,18 @@ class RelayCity(
val offset = position - 1
val relayCount = relays.size
- if (offset >= relayCount) {
- return GetItemResult.Count(1 + relayCount)
+ return if (offset >= relayCount) {
+ GetItemResult.Count(1 + relayCount)
} else {
- return GetItemResult.Item(relays[offset])
+ GetItemResult.Item(relays[offset])
}
}
fun getItemCount(): Int {
- if (expanded) {
- return 1 + relays.size
+ return if (expanded) {
+ 1 + relays.size
} else {
- return 1
+ 1
}
}
}
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 197387d1c2..447cc25ff2 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
@@ -15,14 +15,14 @@ class RelayCountry(
get() = cities.any { city -> city.active }
override val hasChildren
- get() = !cities.isEmpty()
+ get() = cities.isNotEmpty()
override val visibleChildCount: Int
get() {
- if (expanded) {
- return cities.map { city -> city.visibleItemCount }.sum()
+ return if (expanded) {
+ cities.sumOf { city -> city.visibleItemCount }
} else {
- return 0
+ 0
}
}
@@ -36,9 +36,8 @@ class RelayCountry(
if (expanded) {
for (city in cities) {
- val itemOrCount = city.getItem(remaining)
- when (itemOrCount) {
+ when (val itemOrCount = city.getItem(remaining)) {
is GetItemResult.Item -> return itemOrCount
is GetItemResult.Count -> {
remaining -= itemOrCount.count
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 81dc589486..03aaef1c84 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
@@ -1,230 +1,40 @@
package net.mullvad.mullvadvpn.ui.fragment
-import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.view.animation.Animation
-import android.view.animation.Animation.AnimationListener
-import android.view.animation.AnimationUtils
-import android.widget.ImageButton
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import androidx.recyclerview.widget.LinearLayoutManager
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.launch
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.ComposeView
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.relaylist.RelayItem
-import net.mullvad.mullvadvpn.relaylist.RelayList
-import net.mullvad.mullvadvpn.relaylist.RelayListAdapter
-import net.mullvad.mullvadvpn.ui.CollapsibleTitleController
-import net.mullvad.mullvadvpn.ui.ListItemDividerDecoration
+import net.mullvad.mullvadvpn.compose.screen.SelectLocationScreen
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
import net.mullvad.mullvadvpn.ui.NavigationBarPainter
import net.mullvad.mullvadvpn.ui.StatusBarPainter
-import net.mullvad.mullvadvpn.ui.extension.requireMainActivity
-import net.mullvad.mullvadvpn.ui.paintNavigationBar
-import net.mullvad.mullvadvpn.ui.paintStatusBar
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
-import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy
-import net.mullvad.mullvadvpn.ui.serviceconnection.relayListListener
-import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView
-import net.mullvad.mullvadvpn.util.AdapterWithHeader
-import net.mullvad.mullvadvpn.util.JobTracker
-import org.koin.android.ext.android.inject
+import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
class SelectLocationFragment : BaseFragment(), StatusBarPainter, NavigationBarPainter {
- // Injected dependencies
- private val serviceConnectionManager: ServiceConnectionManager by inject()
-
- private enum class RelayListState {
- Initializing,
- Loading,
- Visible,
- }
-
- private lateinit var relayListAdapter: RelayListAdapter
- private lateinit var titleController: CollapsibleTitleController
-
- private var loadingSpinner = CompletableDeferred<View>()
- private var relayListState = RelayListState.Initializing
-
- @Deprecated("Refactor code to instead rely on Lifecycle.") private val jobTracker = JobTracker()
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
-
- relayListAdapter =
- RelayListAdapter(context.resources).apply {
- onSelect = { relayItem ->
- serviceConnectionManager.relayListListener()?.selectedRelayLocation =
- relayItem?.location
- serviceConnectionManager.connectionProxy()?.connect()
- close()
- }
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- lifecycleScope.launchUiSubscriptionsOnResume()
- }
+ private val vm by viewModel<SelectLocationViewModel>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
- val view = inflater.inflate(R.layout.select_location, container, false)
-
- view.findViewById<ImageButton>(R.id.close).setOnClickListener { close() }
-
- titleController = CollapsibleTitleController(view, R.id.relay_list)
-
- view.findViewById<CustomRecyclerView>(R.id.relay_list).apply {
- layoutManager = LinearLayoutManager(requireMainActivity())
-
- adapter =
- AdapterWithHeader(relayListAdapter, R.layout.select_location_header).apply {
- onHeaderAvailable = { headerView ->
- initializeLoadingSpinner(headerView)
- titleController.expandedTitleView =
- headerView.findViewById(R.id.expanded_title)
- }
- }
-
- addItemDecoration(
- ListItemDividerDecoration(
- bottomOffset = resources.getDimensionPixelSize(R.dimen.list_item_divider)
- )
- )
- }
-
- return view
- }
-
- override fun onResume() {
- super.onResume()
- paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
- }
-
- override fun onDestroyView() {
- titleController.onDestroy()
- super.onDestroyView()
- }
-
- fun close() {
- activity?.onBackPressed()
- }
-
- private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch {
- repeatOnLifecycle(Lifecycle.State.RESUMED) {
- launchPaintStatusBarAfterTransition()
- launchRelayListSubscription()
- }
- }
-
- private fun CoroutineScope.launchPaintStatusBarAfterTransition() = launch {
- transitionFinishedFlow.collect {
- paintStatusBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
- }
- }
-
- private fun CoroutineScope.launchRelayListSubscription() = launch {
- serviceConnectionManager.connectionState
- .flatMapLatest { state ->
- if (state is ServiceConnectionState.ConnectedReady) {
- callbackFlow {
- state.container.relayListListener.onRelayListChange = { list, item ->
- this.trySend(Pair(list, item))
- }
-
- awaitClose { state.container.relayListListener.onRelayListChange = null }
- }
- } else {
- emptyFlow()
- }
- }
- .collect { (relayList, selectedItem) ->
- when (relayListState) {
- RelayListState.Initializing -> {
- jobTracker.newUiJob("updateRelayList") {
- updateRelayList(relayList, selectedItem)
- }
- relayListState = RelayListState.Visible
- }
- RelayListState.Loading -> {
- jobTracker.newUiJob("updateRelayList") {
- animateRelayListInitialization(relayList, selectedItem)
- }
- }
- RelayListState.Visible -> {
- jobTracker.newUiJob("updateRelayList") {
- updateRelayList(relayList, selectedItem)
- }
- }
- }
-
- if (relayListState == RelayListState.Initializing) {
- relayListState = RelayListState.Loading
- }
- }
- }
-
- private fun updateRelayList(relayList: RelayList, selectedItem: RelayItem?) {
- relayListAdapter.onRelayListChange(relayList, selectedItem)
- }
-
- private fun initializeLoadingSpinner(parentView: View) {
- val spinner = parentView.findViewById<View>(R.id.loading_spinner)
-
- if (relayListState == RelayListState.Visible) {
- // Because this method is executed inside a layout pass, hiding the spinner needs to be
- // done in a new job so that it is executed after the layout pass finishes and can
- // therefore schedule a new layout
- jobTracker.newUiJob("hideLoadingSpinner") { spinner.visibility = View.GONE }
- }
-
- loadingSpinner.complete(spinner)
- }
-
- // Smoothly fade out the spinner before showing the relay list items.
- private suspend fun animateRelayListInitialization(
- relayList: RelayList,
- selectedItem: RelayItem?
- ) {
- val animationFinished = CompletableDeferred<Unit>()
- val animationListener =
- object : AnimationListener {
- override fun onAnimationEnd(animation: Animation) {
- animationFinished.complete(Unit)
+ return inflater.inflate(R.layout.fragment_compose, container, false).apply {
+ findViewById<ComposeView>(R.id.compose_view).setContent {
+ AppTheme {
+ val state = vm.uiState.collectAsState().value
+ SelectLocationScreen(
+ uiState = state,
+ uiCloseAction = vm.uiCloseAction,
+ onSelectRelay = vm::selectRelay,
+ onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }
+ )
}
-
- override fun onAnimationStart(animation: Animation) {}
- override fun onAnimationRepeat(animation: Animation) {}
- }
-
- val fadeOut =
- AnimationUtils.loadAnimation(requireMainActivity(), R.anim.fade_out).apply {
- setAnimationListener(animationListener)
}
-
- loadingSpinner.await().let { spinner ->
- spinner.startAnimation(fadeOut)
-
- animationFinished.await()
-
- spinner.visibility = View.GONE
- updateRelayList(relayList, selectedItem)
}
}
}
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
new file mode 100644
index 0000000000..9994f02546
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt
@@ -0,0 +1,60 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flatMapLatest
+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.ui.serviceconnection.RelayListListener
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy
+import net.mullvad.mullvadvpn.ui.serviceconnection.relayListListener
+
+class SelectLocationViewModel(private val serviceConnectionManager: ServiceConnectionManager) :
+ ViewModel() {
+ private val _closeAction = MutableSharedFlow<Unit>()
+
+ val uiState =
+ serviceConnectionManager.connectionState
+ .flatMapLatest { state ->
+ if (state is ServiceConnectionState.ConnectedReady) {
+ state.container.relayListListener.relayListCallbackFlow()
+ } else {
+ emptyFlow()
+ }
+ }
+ .map { (relayList, relayItem) ->
+ SelectLocationUiState.ShowData(
+ countries = relayList.countries,
+ selectedRelay = relayItem
+ )
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ SelectLocationUiState.Loading
+ )
+
+ val uiCloseAction = _closeAction.asSharedFlow()
+
+ fun selectRelay(relayItem: RelayItem?) {
+ serviceConnectionManager.relayListListener()?.selectedRelayLocation = relayItem?.location
+ serviceConnectionManager.connectionProxy()?.connect()
+ viewModelScope.launch { _closeAction.emit(Unit) }
+ }
+
+ private fun RelayListListener.relayListCallbackFlow() = callbackFlow {
+ onRelayListChange = { list, item -> this.trySend(list to item) }
+ awaitClose { onRelayListChange = null }
+ }
+}
diff --git a/android/app/src/main/res/anim/fade_out.xml b/android/app/src/main/res/anim/fade_out.xml
deleted file mode 100644
index 7c164cb338..0000000000
--- a/android/app/src/main/res/anim/fade_out.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<set xmlns:android="http://schemas.android.com/apk/res/android">
- <alpha android:fromAlpha="1.0"
- android:toAlpha="0.0"
- android:duration="@integer/transition_animation_duration" />
-</set>
diff --git a/android/app/src/main/res/layout/select_location.xml b/android/app/src/main/res/layout/select_location.xml
deleted file mode 100644
index 25eebf7648..0000000000
--- a/android/app/src/main/res/layout/select_location.xml
+++ /dev/null
@@ -1,35 +0,0 @@
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="@color/darkBlue"
- android:gravity="left">
- <TextView android:id="@+id/title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/select_location"
- style="@style/SettingsCollapsedHeader" />
- <LinearLayout android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical">
- <FrameLayout android:layout_width="match_parent"
- android:layout_height="wrap_content">
- <ImageButton android:id="@+id/close"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:padding="12dp"
- android:background="?android:attr/selectableItemBackground"
- android:src="@drawable/icon_close" />
- <TextView android:id="@+id/collapsed_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="4dp"
- android:layout_gravity="center"
- android:text="@string/select_location"
- style="@style/SettingsCollapsedHeader" />
- </FrameLayout>
- <net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView android:id="@+id/relay_list"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:scrollbars="vertical" />
- </LinearLayout>
-</FrameLayout>
diff --git a/android/app/src/main/res/layout/select_location_header.xml b/android/app/src/main/res/layout/select_location_header.xml
deleted file mode 100644
index bd7ede2f3c..0000000000
--- a/android/app/src/main/res/layout/select_location_header.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical"
- android:gravity="left">
- <TextView android:id="@+id/expanded_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_weight="0"
- android:layout_marginVertical="4dp"
- android:layout_marginHorizontal="@dimen/side_margin"
- android:lines="1"
- android:text="@string/select_location"
- style="@style/SettingsExpandedHeader" />
- <TextView android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_weight="0"
- android:layout_marginHorizontal="@dimen/side_margin"
- android:layout_marginBottom="@dimen/vertical_space"
- android:textColor="@color/white80"
- android:textSize="@dimen/text_small"
- android:text="@string/select_location_description" />
- <ProgressBar android:id="@+id/loading_spinner"
- android:layout_width="60dp"
- android:layout_height="60dp"
- android:layout_gravity="center"
- android:indeterminate="true"
- android:indeterminateOnly="true"
- android:indeterminateDuration="600"
- android:indeterminateDrawable="@drawable/icon_spinner"
- android:visibility="visible" />
-</LinearLayout>
diff --git a/android/app/src/main/res/values/dimensions.xml b/android/app/src/main/res/values/dimensions.xml
index d6bab1dade..7142006718 100644
--- a/android/app/src/main/res/values/dimensions.xml
+++ b/android/app/src/main/res/values/dimensions.xml
@@ -2,7 +2,6 @@
<dimen name="country_row_padding">18dp</dimen>
<dimen name="city_row_padding">34dp</dimen>
<dimen name="relay_row_padding">50dp</dimen>
- <dimen name="list_item_divider">1dp</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/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt
new file mode 100644
index 0000000000..fbb5008eb7
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt
@@ -0,0 +1,145 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.viewModelScope
+import app.cash.turbine.test
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.TestCoroutineRule
+import net.mullvad.mullvadvpn.assertLists
+import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
+import net.mullvad.mullvadvpn.model.LocationConstraint
+import net.mullvad.mullvadvpn.relaylist.RelayCountry
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.relaylist.RelayList
+import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy
+import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy
+import net.mullvad.mullvadvpn.ui.serviceconnection.relayListListener
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class SelectLocationViewModelTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+ private lateinit var viewModel: SelectLocationViewModel
+
+ // Service connections
+ private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
+ private val mockRelayListListener: RelayListListener = mockk(relaxUnitFun = true)
+
+ // Captures
+ private val relaySlot = slot<(RelayList, RelayItem?) -> Unit>()
+
+ private val serviceConnectionState =
+ MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
+
+ @Before
+ fun setup() {
+ every { mockServiceConnectionManager.connectionState } returns serviceConnectionState
+ every { mockServiceConnectionContainer.relayListListener } returns mockRelayListListener
+
+ every { mockRelayListListener.onRelayListChange = capture(relaySlot) } answers {}
+ every { mockRelayListListener.onRelayListChange = null } answers {}
+
+ mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS)
+
+ viewModel = SelectLocationViewModel(mockServiceConnectionManager)
+ }
+
+ @After
+ fun teardown() {
+ viewModel.viewModelScope.coroutineContext.cancel()
+ unmockkAll()
+ }
+
+ @Test
+ fun testInitialState() = runTest {
+ viewModel.uiState.test { assertEquals(SelectLocationUiState.Loading, awaitItem()) }
+ }
+
+ @Test
+ fun testUpdateLocations() = runTest {
+ // Arrange
+ val mockCountries = listOf<RelayCountry>(mockk(), mockk())
+ val selectedRelay: RelayItem = mockk()
+ val mockRelayList: RelayList = mockk()
+ every { mockRelayList.countries } returns mockCountries
+
+ // Act, Assert
+ viewModel.uiState.test {
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ relaySlot.captured.invoke(mockRelayList, selectedRelay)
+
+ assertEquals(SelectLocationUiState.Loading, awaitItem())
+ val actualState = awaitItem()
+ assertIs<SelectLocationUiState.ShowData>(actualState)
+ assertLists(mockCountries, actualState.countries)
+ assertEquals(selectedRelay, actualState.selectedRelay)
+ }
+ }
+
+ @Test
+ fun testUpdateLocationsNoSelectedRelay() = runTest {
+ // Arrange
+ val mockCountries = listOf<RelayCountry>(mockk(), mockk())
+ val selectedRelay: RelayItem? = null
+ val mockRelayList: RelayList = mockk()
+ every { mockRelayList.countries } returns mockCountries
+
+ // Act, Assert
+ viewModel.uiState.test {
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ relaySlot.captured.invoke(mockRelayList, selectedRelay)
+
+ assertEquals(SelectLocationUiState.Loading, awaitItem())
+ val actualState = awaitItem()
+ assertIs<SelectLocationUiState.ShowData>(actualState)
+ assertLists(mockCountries, actualState.countries)
+ assertEquals(selectedRelay, actualState.selectedRelay)
+ }
+ }
+
+ @Test
+ fun testSelectRelayAndClose() = runTest {
+ // Arrange
+ val mockRelayItem: RelayItem = mockk()
+ val mockLocation: LocationConstraint.Country = mockk(relaxed = true)
+ val connectionProxyMock: ConnectionProxy = mockk(relaxUnitFun = true)
+ every { mockRelayItem.location } returns mockLocation
+ every { mockServiceConnectionManager.relayListListener() } returns mockRelayListListener
+ every { mockServiceConnectionManager.connectionProxy() } returns connectionProxyMock
+
+ // Act, Assert
+ viewModel.uiCloseAction.test {
+ viewModel.selectRelay(mockRelayItem)
+ // Await an empty item
+ assertEquals(Unit, awaitItem())
+ verify {
+ connectionProxyMock.connect()
+ mockRelayListListener.selectedRelayLocation = mockLocation
+ }
+ }
+ }
+
+ companion object {
+ private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS =
+ "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt"
+ }
+}