diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-08-05 11:08:06 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-08-05 11:08:06 +0200 |
| commit | 0a576a1e71ecc29b444031344b0bfb434632af4e (patch) | |
| tree | cc63e8aba2de520dccc920807d595925981d3a7e | |
| parent | f240b49d638f3f9932de70dbb00d108e33b7670f (diff) | |
| parent | 74e16685956129553f934f3d6c6a44f6d8889df8 (diff) | |
| download | mullvadvpn-0a576a1e71ecc29b444031344b0bfb434632af4e.tar.xz mullvadvpn-0a576a1e71ecc29b444031344b0bfb434632af4e.zip | |
Merge branch 'update-composefoundation-to-resolve-bug-in-horizontalpager-droid-1639'
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) |
