diff options
Diffstat (limited to 'android/app/src')
7 files changed, 356 insertions, 17 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt index 0a332ba385..d116fddcd6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt @@ -49,6 +49,7 @@ private val otherStates = if (index == 0) InAppNotification.NewDevice("Test Device") else null, deviceName = "Cool Beans", daysLeftUntilExpiry = 42, + selectedGeoLocationId = emptyList(), isPlayBuild = true, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 8f7775c613..acfb93dde9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -5,7 +5,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationEndReason +import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -25,6 +30,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -38,6 +45,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -46,7 +54,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -58,10 +69,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed +import co.touchlab.kermit.Logger import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.NavGraphs @@ -107,16 +120,23 @@ import net.mullvad.mullvadvpn.constant.SECURE_ZOOM_ANIMATION_MILLIS import net.mullvad.mullvadvpn.constant.UNSECURE_ZOOM import net.mullvad.mullvadvpn.constant.fallbackLatLong import net.mullvad.mullvadvpn.lib.common.util.openVpnSettings -import net.mullvad.mullvadvpn.lib.map.AnimatedMap +import net.mullvad.mullvadvpn.lib.map.Map +import net.mullvad.mullvadvpn.lib.map.data.CameraPosition import net.mullvad.mullvadvpn.lib.map.data.GlobeColors import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors import net.mullvad.mullvadvpn.lib.map.data.Marker +import net.mullvad.mullvadvpn.lib.map.internal.FAR_ANIMATION_MAX_ZOOM_MULTIPLIER +import net.mullvad.mullvadvpn.lib.map.internal.MAX_MULTIPLIER_PEAK_TIMING +import net.mullvad.mullvadvpn.lib.map.internal.SHORT_ANIMATION_CUTOFF_MILLIS +import net.mullvad.mullvadvpn.lib.map.toAnimationDurationMillis import net.mullvad.mullvadvpn.lib.model.FeatureIndicator import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.LatLong import net.mullvad.mullvadvpn.lib.model.Latitude import net.mullvad.mullvadvpn.lib.model.Longitude import net.mullvad.mullvadvpn.lib.model.PrepareError +import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -126,6 +146,7 @@ import net.mullvad.mullvadvpn.lib.theme.color.Alpha80 import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible +import net.mullvad.mullvadvpn.lib.theme.color.warning import net.mullvad.mullvadvpn.lib.tv.NavigationDrawerTv import net.mullvad.mullvadvpn.lib.ui.component.ExpandChevron import net.mullvad.mullvadvpn.lib.ui.tag.CONNECT_BUTTON_TEST_TAG @@ -164,6 +185,7 @@ private fun PreviewAccountScreen( onDismissNewDeviceClick = {}, onNavigateToFeature = {}, onClickShowWireguardPortSettings = {}, + onSelectRelay = {}, ) } } @@ -306,6 +328,7 @@ fun Connect( }, onClickShowWireguardPortSettings = dropUnlessResumed { navigator.navigate(VpnSettingsDestination()) }, + onSelectRelay = connectViewModel::onSelectRelay, ) } } @@ -330,6 +353,7 @@ fun ConnectScreen( onDismissNewDeviceClick: () -> Unit, onNavigateToFeature: (FeatureIndicator) -> Unit, onClickShowWireguardPortSettings: () -> Unit, + onSelectRelay: (RelayItemId) -> Unit, ) { val contentFocusRequester = remember { FocusRequester() } @@ -351,6 +375,7 @@ fun ConnectScreen( onDismissNewDeviceClick, onNavigateToFeature, onClickShowWireguardPortSettings, + onSelectRelay, ) } @@ -406,6 +431,7 @@ private fun Content( onDismissNewDeviceClick: () -> Unit, onNavigateToFeature: (FeatureIndicator) -> Unit, onClickShowWireguardPortSettings: () -> Unit, + onSelectRelay: (RelayItemId) -> Unit, ) { val screenHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() } @@ -422,7 +448,7 @@ private fun Content( ) .fillMaxSize() ) { - MullvadMap(state, indicatorPercentOffset) + MullvadMap(state, indicatorPercentOffset, onSelectRelay = onSelectRelay) MullvadCircularProgressIndicatorLarge( color = MaterialTheme.colorScheme.onSurface, @@ -474,7 +500,11 @@ private fun Content( } @Composable -private fun MullvadMap(state: ConnectUiState, progressIndicatorBias: Float) { +private fun MullvadMap( + state: ConnectUiState, + progressIndicatorBias: Float, + onSelectRelay: (RelayItemId) -> Unit, +) { // Distance to marker when secure/unsecure val baseZoom = @@ -485,19 +515,207 @@ private fun MullvadMap(state: ConnectUiState, progressIndicatorBias: Float) { label = "baseZoom", ) + val locationLatLng = (state.location?.toLatLong() ?: fallbackLatLong) + + val longitudeAnimation = remember { Animatable(locationLatLng.longitude.value) } + val latitudeAnimation = remember { Animatable(locationLatLng.latitude.value) } + latitudeAnimation.updateBounds(-40f, upperBound = 80f) + val userZoom = remember { Animatable(1f) } + + LaunchedEffect(locationLatLng) { + val currentPosition = + LatLong( + Latitude.fromFloat(latitudeAnimation.value), + Longitude.fromFloat(longitudeAnimation.value), + ) + val distance = locationLatLng.seppDistanceTo(currentPosition) + val duration = distance.toAnimationDurationMillis() + launch { + longitudeAnimation.animateTo( + state.location?.longitude?.toFloat() ?: fallbackLatLong.longitude.value, + animationSpec = tween(duration), + ) + } + launch { + latitudeAnimation.animateTo( + state.location?.latitude?.toFloat() ?: fallbackLatLong.latitude.value, + animationSpec = tween(duration), + ) + } + launch { + userZoom.animateTo( + targetValue = 1f, + animationSpec = + keyframes { + if (duration < SHORT_ANIMATION_CUTOFF_MILLIS) { + durationMillis = duration + 1f at duration using EaseInOut + } else { + durationMillis = duration + FAR_ANIMATION_MAX_ZOOM_MULTIPLIER at + (duration * MAX_MULTIPLIER_PEAK_TIMING).toInt() using + EaseInOut + 1f at duration using EaseInOut + } + }, + ) + } + } + + val locationMarkers = + state.relayLocations.map { location -> + val isSelected = + state.selectedGeoLocationId.any { + when (it) { + is GeoLocationId.City -> it == location.id + is GeoLocationId.Country -> it == location.id.country + is GeoLocationId.Hostname -> it.city == location.id + } + } + val colors = + if (isSelected) { + LocationMarkerColors( + perimeterColors = null, + centerColor = MaterialTheme.colorScheme.warning, + ringBorderColor = MaterialTheme.colorScheme.onPrimary, + ) + } else { + LocationMarkerColors( + perimeterColors = null, + centerColor = MaterialTheme.colorScheme.primary, + ringBorderColor = MaterialTheme.colorScheme.onPrimary, + ) + } + Marker(location.latLong, colors = colors, id = location.id) + } + + val tracker = remember { VelocityTracker() } + val scope = rememberCoroutineScope() + val markers = state.tunnelState.toMarker(state.location)?.let { listOf(it) } ?: emptyList() - AnimatedMap( - modifier = Modifier, - cameraLocation = state.location?.toLatLong() ?: fallbackLatLong, - cameraBaseZoom = baseZoom.value, - cameraVerticalBias = progressIndicatorBias, - markers = markers, + val density = LocalDensity.current + val dropdownPosition = remember { mutableStateOf(DpOffset(0.dp, 0.dp)) } + val showDropdown = remember { mutableStateOf(false) } + val locationName = remember { mutableStateOf<GeoLocationId?>(null) } + + if (showDropdown.value) { + DropdownMenu( + expanded = true, + onDismissRequest = { showDropdown.value = false }, + offset = dropdownPosition.value, + ) { + val text = + when (val geolocation = locationName.value) { + is GeoLocationId.City -> "${geolocation.country.code}-${geolocation.code}" + is GeoLocationId.Country -> geolocation.toString() + is GeoLocationId.Hostname -> geolocation.toString() + else -> "" + } + + Text( + style = MaterialTheme.typography.bodyLarge, + color = Color.White.copy(Alpha80), + text = text, + modifier = + Modifier.padding( + horizontal = Dimens.mediumPadding, + vertical = Dimens.smallPadding, + ), + ) + DropdownMenuItem( + text = { Text(text = "Set as Entry") }, + onClick = { + locationName.value?.let { onSelectRelay(it) } + showDropdown.value = false + }, + ) + DropdownMenuItem( + text = { Text(text = "Set as Exit") }, + onClick = { + locationName.value?.let { onSelectRelay(it) } + showDropdown.value = false + }, + ) + } + } + + + Map( + modifier = + Modifier.pointerInput(Unit) { + detectTransformGesturesWithEnd( + true, + onGestureStart = { + Logger.d { "Animation onGestureStart" } + tracker.resetTracking() + scope.launch { longitudeAnimation.stop() } + scope.launch { latitudeAnimation.stop() } + }, + onGesture = { centroid: Offset, pan: Offset, newZoom: Float, rotation: Float -> + Logger.d { "Animation onGesture" } + + val longitude = longitudeAnimation.value - pan.x * userZoom.value / 50f + val tempLatitude = Latitude.fromFloat(latitudeAnimation.value) + val latitude = (tempLatitude.value + pan.y * userZoom.value / 40f) + val newZoom = (userZoom.value + (1 - newZoom) * 0.5f).coerceIn(1f, 2f) + + scope.launch { + userZoom.snapTo(newZoom) + longitudeAnimation.snapTo(longitude) + latitudeAnimation.snapTo(latitude) + + tracker.addPosition( + System.currentTimeMillis(), + Offset(longitude, latitude), + ) + } + }, + onGestureEnd = { + Logger.d { "Animation onGestureEnd" } + // Longitude + val velocity = tracker.calculateVelocity() + scope.launch { + longitudeAnimation.animateDecay(velocity.x, exponentialDecay(0.4f)) + } + scope.launch { + var velY = velocity.y + do { + val res = + latitudeAnimation.animateDecay(velY, exponentialDecay(0.4f)) + + velY = -res.endState.velocityVector.value + } while (res.endReason != AnimationEndReason.Finished) + } + }, + ) + }, + cameraLocation = + CameraPosition( + latLong = + LatLong( + Latitude.fromFloat(latitudeAnimation.value), + Longitude.fromFloat(longitudeAnimation.value), + ), + verticalBias = progressIndicatorBias, + zoom = (baseZoom.value * userZoom.value).also { Logger.d("Zoom: $it") }, + ), + markers = markers + locationMarkers, globeColors = GlobeColors( landColor = MaterialTheme.colorScheme.primary, oceanColor = MaterialTheme.colorScheme.surface, ), + onClickRelayItemId = onSelectRelay, + onLongClickRelayItemId = { offset, geolocationId -> + with(density) { + DpOffset(x = offset.x.toDp(), y = offset.y.toDp()).also { + dropdownPosition.value = it + locationName.value = geolocationId + showDropdown.value = true + } + } + }, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MapThingy.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MapThingy.kt new file mode 100644 index 0000000000..b5c37e4ea4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MapThingy.kt @@ -0,0 +1,77 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculateCentroidSize +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateRotation +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.positionChanged +import kotlin.math.abs + +suspend fun PointerInputScope.detectTransformGesturesWithEnd( + panZoomLock: Boolean = false, + onGestureStart: () -> Unit, + onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit, + onGestureEnd: () -> Unit, +) { + awaitEachGesture { + var rotation = 0f + var zoom = 1f + var pan = Offset.Zero + var pastTouchSlop = false + val touchSlop = viewConfiguration.touchSlop + var lockedToPanZoom = false + + awaitFirstDown(requireUnconsumed = false) + onGestureStart() + do { + val event = awaitPointerEvent() + val canceled = event.changes.any { it.isConsumed } + if (!canceled) { + val zoomChange = event.calculateZoom() + val rotationChange = event.calculateRotation() + val panChange = event.calculatePan() + + if (!pastTouchSlop) { + zoom *= zoomChange + rotation += rotationChange + pan += panChange + + val centroidSize = event.calculateCentroidSize(useCurrent = false) + val zoomMotion = abs(1 - zoom) * centroidSize + val rotationMotion = + abs(rotation * kotlin.math.PI.toFloat() * centroidSize / 180f) + val panMotion = pan.getDistance() + + if ( + zoomMotion > touchSlop || + rotationMotion > touchSlop || + panMotion > touchSlop + ) { + pastTouchSlop = true + lockedToPanZoom = panZoomLock && rotationMotion < touchSlop + } + } + + if (pastTouchSlop) { + val centroid = event.calculateCentroid(useCurrent = false) + val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange + if (effectiveRotation != 0f || zoomChange != 1f || panChange != Offset.Zero) { + onGesture(centroid, panChange, zoomChange, effectiveRotation) + } + event.changes.forEach { + if (it.positionChanged()) { + it.consume() + } + } + } + } + } while (!canceled && event.changes.any { it.pressed }) + + onGestureEnd() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt index 956b1506a7..65d3ac09e9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt @@ -2,11 +2,15 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.lib.model.GeoIpLocation import net.mullvad.mullvadvpn.lib.model.InAppNotification +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.model.TunnelState data class ConnectUiState( val location: GeoIpLocation?, + val relayLocations: List<RelayItem.Location.City> = emptyList(), val selectedRelayItemTitle: String?, + val selectedGeoLocationId: List<GeoLocationId>, val tunnelState: TunnelState, val inAppNotification: InAppNotification?, val deviceName: String?, @@ -22,6 +26,7 @@ data class ConnectUiState( ConnectUiState( location = null, selectedRelayItemTitle = null, + selectedGeoLocationId = emptyList(), tunnelState = TunnelState.Disconnected(), inAppNotification = null, deviceName = null, 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 e24ad8e9ef..87b921f8ca 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 @@ -224,6 +224,9 @@ val uiModule = module { isPlayBuild = IS_PLAY_BUILD, isFdroidBuild = IS_FDROID_BUILD, packageName = get(named(SELF_PACKAGE_NAME)), + customListRepository = get(), + relayListRepository = get(), + filteredRelayListUseCase = get(), ) } viewModel { DeviceListViewModel(get(), get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt index de79d159e1..4655638198 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -20,17 +20,18 @@ inline fun <T> Flow<T>.onFirst(crossinline action: suspend (T) -> Unit): Flow<T> } } -inline fun <T1, T2, T3, T4, T5, T6, R> combine( +inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine( flow: Flow<T1>, flow2: Flow<T2>, flow3: Flow<T3>, flow4: Flow<T4>, flow5: Flow<T5>, flow6: Flow<T6>, - crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R, + flow7: Flow<T7>, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, ): Flow<R> { - return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> - -> + return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { + args: Array<*> -> @Suppress("UNCHECKED_CAST") transform( args[0] as T1, @@ -39,11 +40,12 @@ inline fun <T1, T2, T3, T4, T5, T6, R> combine( args[3] as T4, args[4] as T5, args[5] as T6, + args[6] as T7, ) } } -inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine( +inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine( flow: Flow<T1>, flow2: Flow<T2>, flow3: Flow<T3>, @@ -51,9 +53,10 @@ inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine( flow5: Flow<T5>, flow6: Flow<T6>, flow7: Flow<T7>, - crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, + flow8: Flow<T8>, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R, ): Flow<R> { - return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { + return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { args: Array<*> -> @Suppress("UNCHECKED_CAST") transform( @@ -64,6 +67,7 @@ inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine( args[4] as T5, args[5] as T6, args[6] as T7, + args[7] as T8, ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index e836acb844..a8f123d030 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -17,20 +17,28 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.screen.toLatLong import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.lib.common.util.daysFromNow +import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect import net.mullvad.mullvadvpn.lib.model.ConnectError +import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.PrepareError +import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository +import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.repository.NewDeviceRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase @@ -50,13 +58,16 @@ class ConnectViewModel( selectedLocationTitleUseCase: SelectedLocationTitleUseCase, private val outOfTimeUseCase: OutOfTimeUseCase, private val paymentUseCase: PaymentUseCase, + private val customListRepository: CustomListsRepository, private val connectionProxy: ConnectionProxy, lastKnownLocationUseCase: LastKnownLocationUseCase, private val systemVpnSettingsUseCase: SystemVpnSettingsAvailableUseCase, private val resources: Resources, + private val filteredRelayListUseCase: FilteredRelayListUseCase, private val isPlayBuild: Boolean, private val isFdroidBuild: Boolean, private val packageName: String, + private val relayListRepository: RelayListRepository, ) : ViewModel() { private val _uiSideEffect = Channel<UiSideEffect>() @@ -67,18 +78,24 @@ class ConnectViewModel( val uiState: StateFlow<ConnectUiState> = combine( selectedLocationTitleUseCase(), + relayListRepository.selectedLocation, inAppNotificationController.notifications, connectionProxy.tunnelState.withPrev(), lastKnownLocationUseCase.lastKnownDisconnectedLocation, accountRepository.accountData, deviceRepository.deviceState.map { it?.displayName() }, + filteredRelayListUseCase(RelayListType.Single).map { countries -> + countries.flatMap { it.cities } + }, ) { selectedRelayItemTitle, + selectedLocation, notifications, (tunnelState, prevTunnelState), lastKnownDisconnectedLocation, accountData, - deviceName -> + deviceName, + relayCities -> ConnectUiState( location = when (tunnelState) { @@ -103,10 +120,20 @@ class ConnectViewModel( } else { null }, + selectedGeoLocationId = + when (val id = selectedLocation.getOrNull()) { + is CustomListId -> + customListRepository.getCustomListById(id).getOrNull()?.locations + ?: emptyList() + is GeoLocationId -> listOf(id) + null -> emptyList() + }, tunnelState = tunnelState, inAppNotification = notifications.firstOrNull(), deviceName = deviceName, daysLeftUntilExpiry = accountData?.expiryDate?.daysFromNow(), + relayLocations = + relayCities.filter { it.latLong != tunnelState.location()?.toLatLong() }, isPlayBuild = isPlayBuild, ) } @@ -180,6 +207,10 @@ class ConnectViewModel( } } + fun onSelectRelay(id: RelayItemId) { + viewModelScope.launch { relayListRepository.updateSelectedRelayLocation(id) } + } + fun openAppListing() = viewModelScope.launch { val sideEffect = |
