diff options
| author | Albin <albin@mullvad.net> | 2023-06-13 18:00:39 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2023-06-13 18:00:39 +0200 |
| commit | 767d135f52c1dfbe97de5a7ae1cf506d345be721 (patch) | |
| tree | 6754512c8df7f3d4c40ccf72b94e05ca021649c9 | |
| parent | 64ae1f81ad328e7e179eea9e49b3dbf96e910dce (diff) | |
| parent | 2547e9cf8b180d5d2af3b1d73b9c2f88041d05b9 (diff) | |
| download | mullvadvpn-767d135f52c1dfbe97de5a7ae1cf506d345be721.tar.xz mullvadvpn-767d135f52c1dfbe97de5a7ae1cf506d345be721.zip | |
Merge branch 'migrate-location-selection-view-to-droid-61'
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" + } +} |
