summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-08-05 11:08:06 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2025-08-05 11:08:06 +0200
commit0a576a1e71ecc29b444031344b0bfb434632af4e (patch)
treecc63e8aba2de520dccc920807d595925981d3a7e
parentf240b49d638f3f9932de70dbb00d108e33b7670f (diff)
parent74e16685956129553f934f3d6c6a44f6d8889df8 (diff)
downloadmullvadvpn-0a576a1e71ecc29b444031344b0bfb434632af4e.tar.xz
mullvadvpn-0a576a1e71ecc29b444031344b0bfb434632af4e.zip
Merge branch 'update-composefoundation-to-resolve-bug-in-horizontalpager-droid-1639'
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt130
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt7
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt4
6 files changed, 117 insertions, 60 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt
index 2b7b6ab977..35faa62190 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreenTest.kt
@@ -57,7 +57,7 @@ class SelectLocationScreenTest {
private fun ComposeContext.initScreen(
state: Lc<Unit, SelectLocationUiState> = Lc.Loading(Unit),
- onSelectHop: (hop: Hop) -> Unit = {},
+ onSelectHop: (hop: Hop, relayListType: RelayListType) -> Unit = { _, _ -> },
onSearchClick: (RelayListType) -> Unit = {},
onBackClick: () -> Unit = {},
onFilterClick: () -> Unit = {},
@@ -191,7 +191,7 @@ class SelectLocationScreenTest {
)
)
)
- val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true)
+ val mockedOnSelectHop: (Hop, RelayListType) -> Unit = mockk(relaxed = true)
initScreen(
state =
Lc.Content(
@@ -211,7 +211,7 @@ class SelectLocationScreenTest {
onNodeWithText(customList.relay.name).performClick()
// Assert
- verify { mockedOnSelectHop(customList) }
+ verify { mockedOnSelectHop(customList, RelayListType.EXIT) }
}
@Test
@@ -228,7 +228,7 @@ class SelectLocationScreenTest {
)
)
)
- val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true)
+ val mockedOnSelectHop: (Hop, RelayListType) -> Unit = mockk(relaxed = true)
initScreen(
state =
Lc.Content(
@@ -248,7 +248,7 @@ class SelectLocationScreenTest {
onNodeWithText(recent.relay.name).performClick()
// Assert
- verify { mockedOnSelectHop(recent) }
+ verify { mockedOnSelectHop(recent, RelayListType.EXIT) }
}
@Test
@@ -265,7 +265,7 @@ class SelectLocationScreenTest {
)
)
)
- val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true)
+ val mockedOnSelectHop: (Hop, RelayListType) -> Unit = mockk(relaxed = true)
initScreen(
state =
Lc.Content(
@@ -308,7 +308,7 @@ class SelectLocationScreenTest {
)
)
)
- val mockedOnSelectHop: (Hop) -> Unit = mockk(relaxed = true)
+ val mockedOnSelectHop: (Hop, RelayListType) -> Unit = mockk(relaxed = true)
initScreen(
state =
Lc.Content(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt
index 18ad113a26..e42164b3e5 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt
@@ -11,6 +11,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -37,6 +38,7 @@ private fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedButton(
text: String,
onClick: () -> Unit,
shape: Shape,
+ selectedProgress: Float = 1f,
) {
SegmentedButton(
onClick = onClick,
@@ -44,8 +46,14 @@ private fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedButton(
colors =
SegmentedButtonDefaults.colors()
.copy(
- activeContainerColor = MaterialTheme.colorScheme.selected,
- activeContentColor = MaterialTheme.colorScheme.onSelected,
+ activeContainerColor =
+ MaterialTheme.colorScheme.selected
+ .copy(alpha = selectedProgress)
+ .compositeOver(MaterialTheme.colorScheme.primary),
+ activeContentColor =
+ MaterialTheme.colorScheme.onSelected
+ .copy(alpha = selectedProgress)
+ .compositeOver(MaterialTheme.colorScheme.onPrimary),
inactiveContainerColor = MaterialTheme.colorScheme.primary,
inactiveContentColor = MaterialTheme.colorScheme.onPrimary,
),
@@ -67,11 +75,13 @@ private fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedButton(
@Composable
fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedStartButton(
selected: Boolean,
+ selectedProgress: Float = 1f,
text: String,
onClick: () -> Unit,
) {
MullvadSegmentedButton(
selected = selected,
+ selectedProgress = selectedProgress,
text = text,
onClick = onClick,
shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp),
@@ -81,11 +91,13 @@ fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedStartButton(
@Composable
fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedMiddleButton(
selected: Boolean,
+ selectedProgress: Float = 1f,
text: String,
onClick: () -> Unit,
) {
MullvadSegmentedButton(
selected = selected,
+ selectedProgress = selectedProgress,
text = text,
onClick = onClick,
shape = RoundedCornerShape(0.dp), // Square
@@ -95,11 +107,13 @@ fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedMiddleButton(
@Composable
fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedEndButton(
selected: Boolean,
+ selectedProgress: Float = 1f,
text: String,
onClick: () -> Unit,
) {
MullvadSegmentedButton(
selected = selected,
+ selectedProgress = selectedProgress,
text = text,
onClick = onClick,
shape = RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp),
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt
index 1e918219cd..eba2498ca1 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt
@@ -77,7 +77,7 @@ private typealias Content = Lce.Content<SelectLocationListUiState>
@Composable
fun SelectLocationList(
relayListType: RelayListType,
- onSelectHop: (Hop) -> Unit,
+ onSelectHop: (Hop, RelayListType) -> Unit,
openDaitaSettings: () -> Unit,
onAddCustomList: () -> Unit,
onEditCustomLists: (() -> Unit)?,
@@ -103,7 +103,7 @@ fun SelectLocationList(
state = state,
lazyListState = lazyListState,
openDaitaSettings = openDaitaSettings,
- onSelectHop = onSelectHop,
+ onSelectHop = { onSelectHop(it, relayListType) },
onUpdateBottomSheetState = onUpdateBottomSheetState,
onAddCustomList = onAddCustomList,
onEditCustomLists = onEditCustomLists,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt
index f1d9a51f64..dab28c681e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationScreen.kt
@@ -1,7 +1,6 @@
package net.mullvad.mullvadvpn.compose.screen.location
import android.annotation.SuppressLint
-import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -13,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
@@ -39,8 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@@ -61,6 +62,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.ResultBackNavigator
import com.ramcosta.composedestinations.result.ResultRecipient
import com.ramcosta.composedestinations.result.onResult
+import kotlin.math.abs
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.MullvadSegmentedEndButton
@@ -100,7 +102,7 @@ private fun PreviewSelectLocationScreen(
SelectLocationScreen(
state = state,
snackbarHostState = SnackbarHostState(),
- onSelectHop = {},
+ onSelectHop = { _, _ -> },
onSearchClick = {},
onBackClick = {},
onFilterClick = {},
@@ -254,7 +256,7 @@ fun SelectLocation(
fun SelectLocationScreen(
state: Lc<Unit, SelectLocationUiState>,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
- onSelectHop: (item: Hop) -> Unit,
+ onSelectHop: (item: Hop, relayListType: RelayListType) -> Unit,
onSearchClick: (RelayListType) -> Unit,
onBackClick: () -> Unit,
onFilterClick: () -> Unit,
@@ -345,8 +347,20 @@ fun SelectLocationScreen(
Loading()
}
is Lc.Content -> {
+ val pagerState =
+ rememberPagerState(
+ initialPage = state.value.relayListType.ordinal,
+ pageCount = {
+ if (state.value.multihopEnabled) {
+ RelayListType.entries.size
+ } else {
+ 1
+ }
+ },
+ )
+
if (state.value.multihopEnabled) {
- MultihopBar(state.value.relayListType, onSelectRelayList)
+ MultihopBar(pagerState, state.value.relayListType, onSelectRelayList)
}
AnimatedContent(
@@ -368,6 +382,7 @@ fun SelectLocationScreen(
}
RelayLists(
+ pagerState,
state = state.value,
onSelectHop = onSelectHop,
openDaitaSettings = openDaitaSettings,
@@ -376,6 +391,7 @@ fun SelectLocationScreen(
onUpdateBottomSheetState = { newState ->
locationBottomSheetState = newState
},
+ onSelectRelayList,
)
}
}
@@ -446,7 +462,11 @@ private fun SelectLocationDropdownMenu(
}
@Composable
-private fun MultihopBar(relayListType: RelayListType, onSelectHopList: (RelayListType) -> Unit) {
+private fun MultihopBar(
+ pagerState: PagerState,
+ relayListType: RelayListType,
+ onSelectHopList: (RelayListType) -> Unit,
+) {
SingleChoiceSegmentedButtonRow(
modifier =
Modifier.fillMaxWidth()
@@ -458,11 +478,19 @@ private fun MultihopBar(relayListType: RelayListType, onSelectHopList: (RelayLis
) {
MullvadSegmentedStartButton(
selected = relayListType == RelayListType.ENTRY,
+ selectedProgress =
+ 1f -
+ abs(pagerState.getOffsetDistanceInPages(RelayListType.ENTRY.ordinal))
+ .coerceIn(0f..1f),
onClick = { onSelectHopList(RelayListType.ENTRY) },
text = stringResource(id = R.string.entry),
)
MullvadSegmentedEndButton(
selected = relayListType == RelayListType.EXIT,
+ selectedProgress =
+ 1f -
+ abs(pagerState.getOffsetDistanceInPages(RelayListType.EXIT.ordinal))
+ .coerceIn(0f..1f),
onClick = { onSelectHopList(RelayListType.EXIT) },
text = stringResource(id = R.string.exit),
)
@@ -470,57 +498,73 @@ private fun MultihopBar(relayListType: RelayListType, onSelectHopList: (RelayLis
}
@Composable
+@Suppress("ComplexCondition")
private fun RelayLists(
+ pagerState: PagerState,
state: SelectLocationUiState,
- onSelectHop: (Hop) -> Unit,
+ onSelectHop: (Hop, RelayListType) -> Unit,
openDaitaSettings: () -> Unit,
onAddCustomList: () -> Unit,
onEditCustomLists: (() -> Unit)?,
onUpdateBottomSheetState: (LocationBottomSheetState) -> Unit,
+ onSelectRelayList: (RelayListType) -> Unit,
) {
- // This is a workaround for the HorizontalPager being broken on Android TV when it contains
- // focusable views and you navigate with the D-pad. Remove this code once DROID-1639 is fixed.
- val configuration = LocalConfiguration.current
+ // This is so that when the pager is scrolled by the user the relay list type is updated
+ // correctly.
+ // If multihop is not enabled, the pager will only have one page, so this will not be called.
+ if (state.multihopEnabled) {
+ LaunchedEffect(pagerState.currentPage) {
+ onSelectRelayList(RelayListType.entries[pagerState.currentPage])
+ }
+ }
+
+ LaunchedEffect(state.relayListType) {
+ val index = state.relayListType.ordinal
+ pagerState.animateScrollToPage(index)
+ }
+
+ val focusManager = LocalFocusManager.current
+ val onSelectHopInner: (Hop, RelayListType) -> Unit = { hop, relayListType ->
+ onSelectHop(hop, relayListType)
+ // If multihop is enabled and the user selects a location or custom list in the entry list
+ // the app will switch to the exit list. Normally in this case the focus will stay in the
+ // entry list, but in this case we want move the focus to the exit list.
+ if (
+ state.multihopEnabled &&
+ relayListType == RelayListType.ENTRY &&
+ hop is Hop.Single<*> &&
+ hop.isActive
+ ) {
+ focusManager.moveFocus(FocusDirection.Right)
+ if (hop.relay.hasChildren) {
+ focusManager.moveFocus(FocusDirection.Right)
+ }
+ }
+ }
- if (configuration.navigation == Configuration.NAVIGATION_DPAD) {
+ HorizontalPager(
+ state = pagerState,
+ userScrollEnabled = true,
+ beyondViewportPageCount =
+ if (state.multihopEnabled) {
+ 1
+ } else {
+ 0
+ },
+ ) { pageIndex ->
SelectLocationList(
- relayListType = state.relayListType,
- onSelectHop = onSelectHop,
+ relayListType =
+ if (state.multihopEnabled) {
+ RelayListType.entries[pageIndex]
+ } else {
+ RelayListType.EXIT
+ },
+ onSelectHop = onSelectHopInner,
openDaitaSettings = openDaitaSettings,
onAddCustomList = onAddCustomList,
onEditCustomLists = onEditCustomLists,
onUpdateBottomSheetState = onUpdateBottomSheetState,
)
- } else {
- val pagerState =
- rememberPagerState(
- initialPage = state.relayListType.ordinal,
- pageCount = { RelayListType.entries.size },
- )
- LaunchedEffect(state.relayListType) {
- val index = state.relayListType.ordinal
- pagerState.animateScrollToPage(index)
- }
-
- HorizontalPager(
- state = pagerState,
- userScrollEnabled = false,
- beyondViewportPageCount =
- if (state.multihopEnabled) {
- 1
- } else {
- 0
- },
- ) { pageIndex ->
- SelectLocationList(
- relayListType = RelayListType.entries[pageIndex],
- onSelectHop = onSelectHop,
- openDaitaSettings = openDaitaSettings,
- onAddCustomList = onAddCustomList,
- onEditCustomLists = onEditCustomLists,
- onUpdateBottomSheetState = onUpdateBottomSheetState,
- )
- }
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt
index 4964f24e8e..26f5dd42fa 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModel.kt
@@ -76,13 +76,12 @@ class SelectLocationViewModel(
viewModelScope.launch { _relayListType.emit(relayListType) }
}
- fun selectHop(hop: Hop) {
+ fun selectHop(hop: Hop, relayListType: RelayListType) {
viewModelScope.launch {
if (hop.isActive) {
-
selectRelayHop(
hop = hop,
- relayListType = _relayListType.value,
+ relayListType = relayListType,
selectEntryLocation = wireguardConstraintsRepository::setEntryLocation,
selectExitLocation = relayListRepository::updateSelectedRelayLocation,
selectMultihopLocation =
@@ -91,7 +90,7 @@ class SelectLocationViewModel(
.fold(
{ _uiSideEffect.send(SelectLocationSideEffect.GenericError) },
{
- when (_relayListType.value) {
+ when (relayListType) {
RelayListType.ENTRY ->
if (hop is Hop.Multi)
_uiSideEffect.send(SelectLocationSideEffect.CloseScreen)
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt
index 3f80572e06..e5804ffe61 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/location/SelectLocationViewModelTest.kt
@@ -115,7 +115,7 @@ class SelectLocationViewModelTest {
// Act, Assert
viewModel.uiSideEffect.test {
- viewModel.selectHop(Hop.Single(mockRelayItem))
+ viewModel.selectHop(Hop.Single(mockRelayItem), RelayListType.EXIT)
// Await an empty item
assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem())
coVerify { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) }
@@ -142,7 +142,7 @@ class SelectLocationViewModelTest {
assertIs<Lc.Content<SelectLocationUiState>>(firstState)
assertEquals(RelayListType.ENTRY, firstState.value.relayListType)
// Select entry
- viewModel.selectHop(Hop.Single(mockRelayItem))
+ viewModel.selectHop(Hop.Single(mockRelayItem), RelayListType.ENTRY)
// Assert relay list type is exit
val secondState = awaitItem()
assertIs<Lc.Content<SelectLocationUiState>>(secondState)