diff options
| author | David Göransson <david.goransson@mullvad.net> | 2024-09-18 14:56:30 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2024-09-18 14:56:30 +0200 |
| commit | b50a8296449a267b4cad1c497070c48545294055 (patch) | |
| tree | 49eab8b7f75a4727acaf778266f7a16ee18b239d /android/app/src | |
| parent | 63c1abe65257b3357bbca696ad832968e6ecab44 (diff) | |
| parent | 4906946ee7fd0bc279a7e023a07804c17c32971c (diff) | |
| download | mullvadvpn-b50a8296449a267b4cad1c497070c48545294055.tar.xz mullvadvpn-b50a8296449a267b4cad1c497070c48545294055.zip | |
Merge branch 'add-feature-indicators-to-the-main-screen-droid-1108'
Diffstat (limited to 'android/app/src')
22 files changed, 903 insertions, 724 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index c9cd30e439..1ae19c534a 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -14,10 +14,9 @@ import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.CONNECT_CARD_HEADER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.TOP_BAR_ACCOUNT_BUTTON import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect @@ -59,9 +58,8 @@ class ConnectScreenTest { setContentWithTheme { ConnectScreen(state = ConnectUiState.INITIAL) } // Assert - onNodeWithTag(SCROLLABLE_COLUMN_TEST_TAG).assertExists() - onNodeWithText("UNSECURED CONNECTION").assertExists() - onNodeWithText("Secure my connection").assertExists() + onNodeWithText("DISCONNECTED").assertExists() + onNodeWithText("Connect").assertExists() } } @@ -76,8 +74,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = null, tunnelState = TunnelState.Connecting(null, null, emptyList()), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -89,45 +85,7 @@ class ConnectScreenTest { // Assert onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() - onNodeWithText("CREATING SECURE CONNECTION").assertExists() - onNodeWithText("Switch location").assertExists() - onNodeWithText("Cancel").assertExists() - onNodeWithText("BLOCKING INTERNET").assertExists() - } - } - - @Test - fun testConnectingStateQuantumSecured() { - composeExtension.use { - // Arrange - val mockTunnelEndpoint: TunnelEndpoint = mockk(relaxed = true) - every { mockTunnelEndpoint.quantumResistant } returns true - setContentWithTheme { - ConnectScreen( - state = - ConnectUiState( - location = null, - selectedRelayItemTitle = null, - tunnelState = - TunnelState.Connecting( - endpoint = mockTunnelEndpoint, - null, - emptyList(), - ), - inAddress = null, - outAddress = "", - showLocation = false, - deviceName = "", - daysLeftUntilExpiry = null, - inAppNotification = InAppNotification.TunnelStateBlocked, - isPlayBuild = false, - ) - ) - } - - // Assert - onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() - onNodeWithText("CREATING QUANTUM SECURE CONNECTION").assertExists() + onNodeWithText("CONNECTING...").assertExists() onNodeWithText("Switch location").assertExists() onNodeWithText("Cancel").assertExists() onNodeWithText("BLOCKING INTERNET").assertExists() @@ -147,40 +105,6 @@ class ConnectScreenTest { selectedRelayItemTitle = null, tunnelState = TunnelState.Connected(mockTunnelEndpoint, null, emptyList()), - inAddress = null, - outAddress = "", - showLocation = false, - deviceName = "", - daysLeftUntilExpiry = null, - inAppNotification = null, - isPlayBuild = false, - ) - ) - } - - // Assert - onNodeWithText("SECURE CONNECTION").assertExists() - onNodeWithText("Switch location").assertExists() - onNodeWithText("Disconnect").assertExists() - } - } - - @Test - fun testConnectedStateQuantumSecured() { - composeExtension.use { - // Arrange - val mockTunnelEndpoint: TunnelEndpoint = mockk(relaxed = true) - every { mockTunnelEndpoint.quantumResistant } returns true - setContentWithTheme { - ConnectScreen( - state = - ConnectUiState( - location = null, - selectedRelayItemTitle = null, - tunnelState = - TunnelState.Connected(mockTunnelEndpoint, null, emptyList()), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -191,7 +115,7 @@ class ConnectScreenTest { } // Assert - onNodeWithText("QUANTUM SECURE CONNECTION").assertExists() + onNodeWithText("CONNECTED").assertExists() onNodeWithText("Switch location").assertExists() onNodeWithText("Disconnect").assertExists() } @@ -209,8 +133,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = mockLocationName, tunnelState = TunnelState.Disconnecting(ActionAfterDisconnect.Nothing), - inAddress = null, - outAddress = "", showLocation = true, deviceName = "", daysLeftUntilExpiry = null, @@ -221,7 +143,7 @@ class ConnectScreenTest { } // Assert - onNodeWithText("UNSECURED CONNECTION").assertExists() + onNodeWithText("DISCONNECTED").assertExists() onNodeWithText(mockLocationName).assertExists() onNodeWithText("Disconnect").assertExists() } @@ -239,8 +161,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = mockLocationName, tunnelState = TunnelState.Disconnected(), - inAddress = null, - outAddress = "", showLocation = true, deviceName = "", daysLeftUntilExpiry = null, @@ -251,9 +171,9 @@ class ConnectScreenTest { } // Assert - onNodeWithText("UNSECURED CONNECTION").assertExists() + onNodeWithText("DISCONNECTED").assertExists() onNodeWithText(mockLocationName).assertExists() - onNodeWithText("Secure my connection").assertExists() + onNodeWithText("Connect").assertExists() } } @@ -272,8 +192,6 @@ class ConnectScreenTest { TunnelState.Error( ErrorState(ErrorStateCause.StartTunnelError, true) ), - inAddress = null, - outAddress = "", showLocation = true, deviceName = "", daysLeftUntilExpiry = null, @@ -309,8 +227,6 @@ class ConnectScreenTest { TunnelState.Error( ErrorState(ErrorStateCause.StartTunnelError, false) ), - inAddress = null, - outAddress = "", showLocation = true, deviceName = "", daysLeftUntilExpiry = null, @@ -344,8 +260,6 @@ class ConnectScreenTest { selectedRelayItemTitle = null, tunnelState = TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -357,7 +271,7 @@ class ConnectScreenTest { // Assert onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() - onNodeWithText("CREATING SECURE CONNECTION").assertExists() + onNodeWithText("CONNECTING...").assertExists() onNodeWithText("Switch location").assertExists() onNodeWithText("Disconnect").assertExists() onNodeWithText("BLOCKING INTERNET").assertExists() @@ -376,8 +290,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = mockLocationName, tunnelState = TunnelState.Disconnecting(ActionAfterDisconnect.Block), - inAddress = null, - outAddress = "", showLocation = true, deviceName = "", daysLeftUntilExpiry = null, @@ -388,7 +300,7 @@ class ConnectScreenTest { } // Assert - onNodeWithText("SECURE CONNECTION").assertExists() + onNodeWithText("CONNECTED").assertExists() onNodeWithText(mockLocationName).assertExists() onNodeWithText("Disconnect").assertExists() onNodeWithText("BLOCKING INTERNET").assertExists() @@ -408,8 +320,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = mockLocationName, tunnelState = TunnelState.Disconnected(), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -442,8 +352,6 @@ class ConnectScreenTest { selectedRelayItemTitle = null, tunnelState = TunnelState.Connected(mockTunnelEndpoint, null, emptyList()), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -476,8 +384,6 @@ class ConnectScreenTest { selectedRelayItemTitle = null, tunnelState = TunnelState.Connected(mockTunnelEndpoint, null, emptyList()), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -508,8 +414,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = null, tunnelState = TunnelState.Disconnected(), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -540,8 +444,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = null, tunnelState = TunnelState.Connecting(null, null, emptyList()), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -561,18 +463,31 @@ class ConnectScreenTest { } @Test - fun showLocationInfo() { + fun showConnectionDetails() { composeExtension.use { // Arrange val mockLocation: GeoIpLocation = mockk(relaxed = true) val mockTunnelEndpoint: TunnelEndpoint = mockk(relaxed = true) val mockHostName = "Host-Name" - val mockPort = 99 - val mockHost = "Host" - val mockProtocol = TransportProtocol.Udp - val mockInAddress = Triple(mockHost, mockPort, mockProtocol) - val mockOutAddress = "HostAddressV4 / HostAddressV4" + val inHost = "Host" + val inPort = 99 + val inProtocol = TransportProtocol.Udp every { mockLocation.hostname } returns mockHostName + + // In + every { mockTunnelEndpoint.obfuscation } returns null + every { mockTunnelEndpoint.endpoint.address.address.hostAddress } returns inHost + every { mockTunnelEndpoint.endpoint.address.port } returns inPort + every { mockTunnelEndpoint.endpoint.protocol } returns inProtocol + + // Out Ipv4 + val outIpv4 = "ipv4address" + every { mockLocation.ipv4?.hostAddress } returns outIpv4 + + // Out Ipv6 + val outIpv6 = "ipv6address" + every { mockLocation.ipv6?.hostAddress } returns outIpv6 + setContentWithTheme { ConnectScreen( state = @@ -580,9 +495,11 @@ class ConnectScreenTest { location = mockLocation, selectedRelayItemTitle = null, tunnelState = - TunnelState.Connected(mockTunnelEndpoint, null, emptyList()), - inAddress = mockInAddress, - outAddress = mockOutAddress, + TunnelState.Connected( + mockTunnelEndpoint, + mockLocation, + emptyList(), + ), showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -593,13 +510,18 @@ class ConnectScreenTest { } // Act - onNodeWithTag(LOCATION_INFO_TEST_TAG).performClick() + onNodeWithTag(CONNECT_CARD_HEADER_TEST_TAG).performClick() // Assert onNodeWithText(mockHostName).assertExists() - onNodeWithText("WireGuard").assertExists() - onNodeWithText("In $mockHost:$mockPort UDP").assertExists() - onNodeWithText("Out $mockOutAddress").assertExists() + onNodeWithText("In").assertExists() + onNodeWithText("$inHost:$inPort UDP").assertExists() + + onNodeWithText("Out Ipv4").assertExists() + onNodeWithText(outIpv4).assertExists() + + onNodeWithText("Out Ipv6").assertExists() + onNodeWithText(outIpv6).assertExists() } } @@ -615,8 +537,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = null, tunnelState = TunnelState.Connecting(null, null, emptyList()), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -647,8 +567,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = null, tunnelState = TunnelState.Connecting(null, null, emptyList()), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -678,8 +596,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = null, tunnelState = TunnelState.Connecting(null, null, emptyList()), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, @@ -711,8 +627,6 @@ class ConnectScreenTest { location = null, selectedRelayItemTitle = null, tunnelState = TunnelState.Connecting(null, null, emptyList()), - inAddress = null, - outAddress = "", showLocation = false, deviceName = "", daysLeftUntilExpiry = null, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt index 1bb9957114..47690247dc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt @@ -1,43 +1,16 @@ package net.mullvad.mullvadvpn.compose.button -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -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.dp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.preview.TunnelStatePreviewParameterProvider import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisconnectButton @Composable @Preview @@ -48,7 +21,6 @@ private fun PreviewConnectionButton( ConnectionButton( state = tunnelState, disconnectClick = {}, - reconnectClick = {}, cancelClick = {}, connectClick = {}, ) @@ -58,18 +30,17 @@ private fun PreviewConnectionButton( @Composable fun ConnectionButton( modifier: Modifier = Modifier, - reconnectButtonTestTag: String = "", state: TunnelState, - disconnectClick: () -> Unit, - reconnectClick: () -> Unit, - cancelClick: () -> Unit, - connectClick: () -> Unit, + disconnectClick: () -> Unit = {}, + cancelClick: () -> Unit = {}, + connectClick: () -> Unit = {}, ) { + val containerColor = if (state is TunnelState.Disconnected) { MaterialTheme.colorScheme.tertiary } else { - MaterialTheme.colorScheme.error.copy(alpha = AlphaDisconnectButton) + MaterialTheme.colorScheme.error } val contentColor = @@ -97,7 +68,7 @@ fun ConnectionButton( } ) - val onMainClick = + val onClick = when (state) { is TunnelState.Disconnected -> connectClick is TunnelState.Connecting -> cancelClick @@ -108,126 +79,18 @@ fun ConnectionButton( cancelClick } } + else -> disconnectClick } - ConnectionButton( + PrimaryButton( + onClick = onClick, + colors = + ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor, + ), modifier = modifier, text = buttonText, - containerColor = containerColor, - contentColor = contentColor, - mainClick = onMainClick, - reconnectClick = reconnectClick, - reconnectButtonTestTag = reconnectButtonTestTag, - isReconnectButtonEnabled = (state is TunnelState.Disconnected).not(), ) } - -@Composable -@Suppress("LongMethod") -private fun ConnectionButton( - text: String, - mainClick: () -> Unit, - reconnectClick: () -> Unit, - isReconnectButtonEnabled: Boolean, - containerColor: Color, - contentColor: Color, - modifier: Modifier = Modifier, - reconnectButtonTestTag: String = "", -) { - ConstraintLayout( - modifier = modifier.padding(vertical = Dimens.connectButtonExtraPadding).fillMaxWidth() - ) { - // initial height set at 0.dp - var componentHeight by remember { mutableStateOf(0.dp) } - - // get local density from composable - val density = LocalDensity.current - - val (connectionButton, reconnectButton) = createRefs() - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides - Dimens.reconnectButtonMinInteractiveComponentSize - ) { - val dividerSize = Dimens.listItemDivider - - Button( - onClick = mainClick, - shape = - if (isReconnectButtonEnabled) { - MaterialTheme.shapes.small.copy( - topEnd = CornerSize(percent = 0), - bottomEnd = CornerSize(percent = 0), - ) - } else { - MaterialTheme.shapes.small - }, - colors = - ButtonDefaults.buttonColors( - containerColor = containerColor, - contentColor = contentColor, - ), - modifier = - Modifier.constrainAs(connectionButton) { - start.linkTo(parent.start) - if (isReconnectButtonEnabled) { - end.linkTo(reconnectButton.start) - } else { - end.linkTo(parent.end) - } - width = Dimension.fillToConstraints - height = Dimension.wrapContent - } - .onGloballyPositioned { - componentHeight = with(density) { it.size.height.toDp() } - }, - ) { - // Offset to compensate for the reconnect button. - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - if (isReconnectButtonEnabled) { - Modifier.padding(start = componentHeight + Dimens.listItemDivider) - } else { - Modifier - }, - ) - } - - if (isReconnectButtonEnabled) { - FilledIconButton( - shape = - MaterialTheme.shapes.small.copy( - topStart = CornerSize(percent = 0), - bottomStart = CornerSize(percent = 0), - ), - colors = - IconButtonDefaults.filledIconButtonColors( - containerColor = containerColor, - contentColor = contentColor, - ), - onClick = reconnectClick, - modifier = - Modifier.testTag(reconnectButtonTestTag) - .constrainAs(reconnectButton) { - start.linkTo(connectionButton.end, margin = dividerSize) - top.linkTo(connectionButton.top) - bottom.linkTo(connectionButton.bottom) - end.linkTo(parent.end) - height = Dimension.fillToConstraints - } - .aspectRatio(1f, true), - ) { - Icon( - painter = painterResource(id = R.drawable.icon_reload), - contentDescription = null, - ) - } - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt index 6aa17cefed..af8e1a30d3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.Alpha20 @@ -157,7 +156,7 @@ private fun BaseButton( enabled = isEnabled, contentPadding = if (hasIcon) { - PaddingValues(horizontal = 0.dp, vertical = Dimens.buttonVerticalPadding) + PaddingValues(vertical = Dimens.buttonVerticalPadding) } else { ButtonDefaults.ContentPadding }, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt index 531cf685c4..69bff821c9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt @@ -1,52 +1,163 @@ package net.mullvad.mullvadvpn.compose.button +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.SpacedColumn import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.color.Alpha20 +import net.mullvad.mullvadvpn.lib.theme.Dimens @Preview @Composable -private fun PreviewSwitchLocationButton() { +private fun PreviewConnectionButton() { AppTheme { SpacedColumn { - SwitchLocationButton(onClick = {}, text = "Switch Location", showChevron = false) - SwitchLocationButton(onClick = {}, text = "Switch Location", showChevron = true) + SwitchLocationButton( + text = "Switch Location", + onSwitchLocation = {}, + reconnectClick = {}, + isReconnectButtonEnabled = true, + ) + SwitchLocationButton( + text = "Switch Location", + onSwitchLocation = {}, + reconnectClick = {}, + isReconnectButtonEnabled = false, + ) } } } @Composable +@Suppress("LongMethod") fun SwitchLocationButton( - modifier: Modifier = Modifier, text: String, - showChevron: Boolean, - onClick: () -> Unit, + onSwitchLocation: () -> Unit, + reconnectClick: () -> Unit, + isReconnectButtonEnabled: Boolean, + modifier: Modifier = Modifier, + reconnectButtonTestTag: String = "", ) { - PrimaryButton( - onClick = onClick, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = Alpha20), - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - modifier = modifier, - text = text, - trailingIcon = - if (showChevron) { - { + ConstraintLayout( + modifier = modifier.padding(vertical = Dimens.connectButtonExtraPadding).fillMaxWidth() + ) { + // initial height set at 0.dp + var componentHeight by remember { mutableStateOf(0.dp) } + + // get local density from composable + val density = LocalDensity.current + + val (connectionButton, reconnectButton) = createRefs() + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides + Dimens.reconnectButtonMinInteractiveComponentSize + ) { + val dividerSize = Dimens.listItemDivider + + Button( + onClick = onSwitchLocation, + shape = + if (isReconnectButtonEnabled) { + MaterialTheme.shapes.small.copy( + topEnd = CornerSize(percent = 0), + bottomEnd = CornerSize(percent = 0), + ) + } else { + MaterialTheme.shapes.small + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = + Modifier.constrainAs(connectionButton) { + start.linkTo(parent.start) + if (isReconnectButtonEnabled) { + end.linkTo(reconnectButton.start) + } else { + end.linkTo(parent.end) + } + width = Dimension.fillToConstraints + height = Dimension.wrapContent + } + .onGloballyPositioned { + componentHeight = with(density) { it.size.height.toDp() } + }, + ) { + // Offset to compensate for the reconnect button. + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + if (isReconnectButtonEnabled) { + Modifier.padding(start = componentHeight + Dimens.listItemDivider) + } else { + Modifier + }, + ) + } + + if (isReconnectButtonEnabled) { + FilledIconButton( + shape = + MaterialTheme.shapes.small.copy( + topStart = CornerSize(percent = 0), + bottomStart = CornerSize(percent = 0), + ), + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = reconnectClick, + modifier = + Modifier.testTag(reconnectButtonTestTag) + .constrainAs(reconnectButton) { + start.linkTo(connectionButton.end, margin = dividerSize) + top.linkTo(connectionButton.top) + bottom.linkTo(connectionButton.bottom) + end.linkTo(parent.end) + height = Dimension.fillToConstraints + } + .defaultMinSize(minWidth = Dimens.switchLocationRetryMinWidth), + ) { Icon( - painter = painterResource(id = R.drawable.icon_chevron), + painter = painterResource(id = R.drawable.icon_reload), contentDescription = null, ) } - } else null, - ) + } + } + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt index a4233cc3b1..a5d2ee37f9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow @@ -114,6 +115,7 @@ internal fun NavigationTitleView( @Composable internal fun DefaultNavigationView(chevronContentDescription: String, tint: Color) { Icon( + modifier = Modifier.rotate(-90f), painter = painterResource(id = R.drawable.icon_chevron), contentDescription = chevronContentDescription, tint = tint, 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 15758be10e..ff20dd0a67 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 @@ -3,20 +3,32 @@ package net.mullvad.mullvadvpn.compose.component import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.TweenSpec import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Column import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R @Composable +@Preview +private fun PreviewChevron() { + Column { + Chevron(color = MaterialTheme.colorScheme.onPrimary, isExpanded = false) + Chevron(color = MaterialTheme.colorScheme.onPrimary, isExpanded = true) + } +} + +@Composable fun Chevron(modifier: Modifier = Modifier, color: Color, isExpanded: Boolean) { - val degree = remember(isExpanded) { if (isExpanded) 270f else 90f } + val degree = remember(isExpanded) { if (isExpanded) 180f else 0f } val animatedRotation = animateFloatAsState( targetValue = degree, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt index c736b6709e..535dcf2bb8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt @@ -29,89 +29,48 @@ private fun PreviewConnectionStatusText( } @Composable -fun ConnectionStatusText(state: TunnelState, modifier: Modifier = Modifier) { - when (state) { - is TunnelState.Disconnecting -> { - when (state.actionAfterDisconnect) { - ActionAfterDisconnect.Nothing -> DisconnectedText(modifier = modifier) - ActionAfterDisconnect.Block -> - ConnectedText(isQuantumResistant = false, modifier = modifier) - ActionAfterDisconnect.Reconnect -> - ConnectingText(isQuantumResistant = false, modifier = modifier) - } - } - is TunnelState.Disconnected -> DisconnectedText(modifier = modifier) - is TunnelState.Connecting -> - ConnectingText( - isQuantumResistant = state.endpoint?.quantumResistant == true, - modifier = modifier, - ) - is TunnelState.Connected -> - ConnectedText(isQuantumResistant = state.endpoint.quantumResistant, modifier = modifier) - is TunnelState.Error -> - ErrorText(isBlocking = state.errorState.isBlocking, modifier = modifier) - } -} - -@Composable -private fun DisconnectedText(modifier: Modifier) { +fun ConnectionStatusText(state: TunnelState) { Text( - text = textResource(id = R.string.unsecured_connection), - color = MaterialTheme.colorScheme.error, + text = state.text(), + color = state.textColor(), style = MaterialTheme.typography.connectionStatus, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = modifier, ) } @Composable -private fun ConnectingText(isQuantumResistant: Boolean, modifier: Modifier) { - Text( - text = - textResource( - id = - if (isQuantumResistant) R.string.quantum_creating_secure_connection - else R.string.creating_secure_connection - ), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.connectionStatus, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - ) -} - -@Composable -private fun ConnectedText(isQuantumResistant: Boolean, modifier: Modifier) { - Text( - text = +private fun TunnelState.text() = + when (this) { + is TunnelState.Connected -> textResource(id = R.string.connected) + is TunnelState.Connecting -> textResource(id = R.string.connecting) + is TunnelState.Disconnected -> textResource(id = R.string.disconnected) + is TunnelState.Disconnecting -> + when (actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> textResource(id = R.string.disconnected) + ActionAfterDisconnect.Block -> textResource(id = R.string.connected) + ActionAfterDisconnect.Reconnect -> textResource(id = R.string.connecting) + } + is TunnelState.Error -> textResource( id = - if (isQuantumResistant) R.string.quantum_secure_connection - else R.string.secure_connection - ), - color = MaterialTheme.colorScheme.tertiary, - style = MaterialTheme.typography.connectionStatus, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - ) -} + if (errorState.isBlocking) R.string.blocked_connection else R.string.error_state + ) + }.uppercase() @Composable -private fun ErrorText(isBlocking: Boolean, modifier: Modifier) { - Text( - text = - textResource( - id = if (isBlocking) R.string.blocked_connection else R.string.error_state - ), - color = - if (isBlocking) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.connectionStatus, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - ) -} +private fun TunnelState.textColor() = + when (this) { + is TunnelState.Connected -> MaterialTheme.colorScheme.tertiary + is TunnelState.Connecting -> MaterialTheme.colorScheme.onSurface + is TunnelState.Disconnected -> MaterialTheme.colorScheme.error + is TunnelState.Disconnecting -> + when (actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> MaterialTheme.colorScheme.error + ActionAfterDisconnect.Block -> MaterialTheme.colorScheme.tertiary + ActionAfterDisconnect.Reconnect -> MaterialTheme.colorScheme.onSurface + } + is TunnelState.Error -> + if (errorState.isBlocking) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.error + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FeatureChip.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FeatureChip.kt new file mode 100644 index 0000000000..65a63dd089 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/FeatureChip.kt @@ -0,0 +1,93 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.shape.chipShape + +@Preview +@Composable +private fun PreviewMullvadFeatureChip() { + AppTheme { Row { MullvadFeatureChip(text = "DAITA") } } +} + +@Composable +fun MullvadFeatureChip( + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLowest, + borderColor: Color = MaterialTheme.colorScheme.primary, + labelColor: Color = MaterialTheme.colorScheme.onPrimary, + iconColor: Color = MaterialTheme.colorScheme.onPrimary, + text: String, +) { + FilterChip( + shape = MaterialTheme.shapes.chipShape, + colors = + FilterChipDefaults.filterChipColors( + disabledContainerColor = containerColor, + disabledLabelColor = labelColor, + labelColor = labelColor, + iconColor = iconColor, + ), + border = + FilterChipDefaults.filterChipBorder( + borderColor = borderColor, + enabled = true, + selected = false, + ), + selected = false, + onClick = {}, + enabled = false, + label = { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) +} + +@Composable +fun MullvadMoreChip( + onClick: () -> Unit, + containerColor: Color = MaterialTheme.colorScheme.background, + borderColor: Color = Color.Transparent, + labelColor: Color = MaterialTheme.colorScheme.onPrimary, + iconColor: Color = MaterialTheme.colorScheme.onPrimary, + text: String, +) { + FilterChip( + onClick = onClick, + shape = MaterialTheme.shapes.chipShape, + colors = + FilterChipDefaults.filterChipColors( + containerColor = containerColor, + labelColor = labelColor, + iconColor = iconColor, + ), + border = + FilterChipDefaults.filterChipBorder( + borderColor = borderColor, + enabled = true, + selected = false, + ), + selected = false, + label = { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt deleted file mode 100644 index 3fbd40c537..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt +++ /dev/null @@ -1,153 +0,0 @@ -package net.mullvad.mullvadvpn.compose.component - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_CONNECTION_OUT_TEST_TAG -import net.mullvad.mullvadvpn.lib.model.GeoIpLocation -import net.mullvad.mullvadvpn.lib.model.TransportProtocol -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible -import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible - -@Preview -@Composable -private fun PreviewLocationInfo() { - AppTheme { - LocationInfo( - onToggleTunnelInfo = {}, - isVisible = true, - isExpanded = true, - location = null, - isUsingDaita = false, - inAddress = null, - outAddress = "", - ) - } -} - -@Composable -fun LocationInfo( - modifier: Modifier = Modifier, - colorExpanded: Color = MaterialTheme.colorScheme.onSurface, - colorCollapsed: Color = MaterialTheme.colorScheme.onSurfaceVariant, - onToggleTunnelInfo: () -> Unit, - isVisible: Boolean, - isExpanded: Boolean, - location: GeoIpLocation?, - isUsingDaita: Boolean, - inAddress: Triple<String, Int, TransportProtocol>?, - outAddress: String, -) { - Column( - modifier = - if (isVisible) { - Modifier.clickable { onToggleTunnelInfo() }.alpha(AlphaVisible) - } else { - Modifier.alpha(AlphaInvisible) - } - .then(modifier) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - RelayHostname( - hostname = location?.hostname, - isUsingDaita = isUsingDaita, - isExpanded = isExpanded, - colorExpanded = colorExpanded, - colorCollapsed = colorCollapsed, - ) - Chevron( - isExpanded = isExpanded, - color = - if (isExpanded) { - colorExpanded - } else { - colorCollapsed - }, - modifier = Modifier.padding(horizontal = Dimens.chevronMargin), - ) - } - Text( - text = - if (isExpanded) { - stringResource(id = R.string.wireguard) - } else { - "" - }, - color = colorExpanded, - style = MaterialTheme.typography.labelMedium, - ) - val textInAddress = - inAddress?.let { - val protocol = - when (inAddress.third) { - TransportProtocol.Tcp -> stringResource(id = R.string.tcp) - TransportProtocol.Udp -> stringResource(id = R.string.udp) - } - "${inAddress.first}:${inAddress.second} $protocol" - } ?: "" - Text( - text = "${stringResource(id = R.string.in_address)} $textInAddress", - color = colorExpanded, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.alpha(if (isExpanded) AlphaVisible else AlphaInvisible), - ) - Text( - text = "${stringResource(id = R.string.out_address)} $outAddress", - color = colorExpanded, - style = MaterialTheme.typography.labelMedium, - modifier = - Modifier.testTag(LOCATION_INFO_CONNECTION_OUT_TEST_TAG) - .alpha( - if (isExpanded && outAddress.isNotEmpty()) AlphaVisible else AlphaInvisible - ), - ) - } -} - -@Composable -private fun RelayHostname( - hostname: String?, - isUsingDaita: Boolean, - isExpanded: Boolean, - colorExpanded: Color, - colorCollapsed: Color, -) { - val hostnameTitle = - when { - hostname != null && isUsingDaita -> { - stringResource( - id = R.string.connected_using_daita, - hostname, - stringResource(id = R.string.daita), - ) - } - hostname != null -> hostname - else -> "" - } - - Text( - text = hostnameTitle, - color = - if (isExpanded) { - colorExpanded - } else { - colorCollapsed - }, - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), - ) -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionDetailPanel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionDetailPanel.kt new file mode 100644 index 0000000000..b6ab3f2166 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionDetailPanel.kt @@ -0,0 +1,198 @@ +package net.mullvad.mullvadvpn.compose.component.connectioninfo + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_CONNECTION_OUT_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.TransportProtocol +import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Composable +fun ConnectionDetailPanel(tunnelState: TunnelState.Connected) { + + ConnectionInfoHeader( + stringResource(R.string.connect_panel_connection_details), + Modifier.fillMaxWidth().padding(bottom = Dimens.smallPadding), + ) + + ConnectionDetails( + tunnelState.endpoint.toInAddress(), + tunnelState.location()?.ipv4?.hostAddress, + tunnelState.location()?.ipv6?.hostAddress, + modifier = Modifier.padding(bottom = Dimens.smallPadding), + ) +} + +@Suppress("LongMethod") +@Composable +fun ConnectionDetails( + inIPV4: String, + outIPV4: String?, + outIPV6: String?, + modifier: Modifier = Modifier, +) { + ConstraintLayout(modifier = modifier.fillMaxWidth()) { + val (inAddrHeader, inAddr, outAddrV4Header, outAddrV4, outAddrV6Header, outAddrV6) = + createRefs() + val headerBarrier = createEndBarrier(inAddrHeader, outAddrV4Header, outAddrV6Header) + + val inAddrBarrier = createBottomBarrier(inAddrHeader, inAddr) + val outAddrV4Barrier = createBottomBarrier(inAddrHeader, inAddr, outAddrV4Header, outAddrV4) + + val outAddrV6Barrier = + createBottomBarrier( + inAddrHeader, + inAddr, + outAddrV4Header, + outAddrV4, + outAddrV6Header, + outAddrV6, + ) + + Text( + text = stringResource(R.string.connection_details_in), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.padding(end = Dimens.smallPadding).constrainAs(inAddrHeader) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(inAddrBarrier) + height = Dimension.wrapContent + width = Dimension.wrapContent + }, + ) + Text( + text = inIPV4, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.constrainAs(inAddr) { + start.linkTo(headerBarrier) + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(inAddrBarrier) + height = Dimension.wrapContent + width = Dimension.fillToConstraints + }, + ) + + if (outIPV4 != null) { + Text( + text = stringResource(R.string.connection_details_out_ipv4), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.padding(end = Dimens.smallPadding).constrainAs(outAddrV4Header) { + start.linkTo(parent.start) + top.linkTo(inAddrBarrier) + bottom.linkTo(outAddrV4Barrier) + height = Dimension.wrapContent + width = Dimension.wrapContent + }, + ) + Box( + modifier = + Modifier.constrainAs(outAddrV4) { + start.linkTo(headerBarrier) + end.linkTo(parent.end) + top.linkTo(inAddrBarrier) + bottom.linkTo(outAddrV4Barrier) + height = Dimension.wrapContent + width = Dimension.fillToConstraints + } + ) { + SelectionContainer { + Text( + modifier = Modifier.testTag(LOCATION_INFO_CONNECTION_OUT_TEST_TAG), + text = outIPV4, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + if (outIPV6 != null) { + Text( + text = stringResource(R.string.connection_details_out_ipv6), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.padding(end = Dimens.smallPadding).constrainAs(outAddrV6Header) { + start.linkTo(parent.start) + top.linkTo(outAddrV4Barrier) + bottom.linkTo(outAddrV6Barrier) + height = Dimension.wrapContent + width = Dimension.wrapContent + }, + ) + Box( + modifier = + Modifier.constrainAs(outAddrV6) { + start.linkTo(headerBarrier) + end.linkTo(parent.end) + top.linkTo(outAddrV4Barrier) + bottom.linkTo(outAddrV6Barrier) + height = Dimension.wrapContent + width = Dimension.fillToConstraints + } + ) { + SelectionContainer { + Text( + text = outIPV6, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +fun TunnelEndpoint.toInAddress(): String { + val relayEndpoint = this.obfuscation?.endpoint ?: this.endpoint + + val host = relayEndpoint.address.address.hostAddress ?: "" + val port = relayEndpoint.address.port + val protocol = relayEndpoint.protocol + + return buildString { + append(host) + append(":") + append(port) + append(" ") + append( + when (protocol) { + TransportProtocol.Tcp -> stringResource(id = R.string.tcp) + TransportProtocol.Udp -> stringResource(id = R.string.udp) + } + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionInfoHeader.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionInfoHeader.kt new file mode 100644 index 0000000000..fe6e5de012 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/ConnectionInfoHeader.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn.compose.component.connectioninfo + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Composable +fun ConnectionInfoHeader(text: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier.padding(top = Dimens.smallPadding), + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt new file mode 100644 index 0000000000..8815149f27 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/connectioninfo/FeatureIndicatorsPanel.kt @@ -0,0 +1,95 @@ +package net.mullvad.mullvadvpn.compose.component.connectioninfo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ContextualFlowRow +import androidx.compose.foundation.layout.ContextualFlowRowOverflow +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.MullvadFeatureChip +import net.mullvad.mullvadvpn.compose.component.MullvadMoreChip +import net.mullvad.mullvadvpn.compose.component.textResource +import net.mullvad.mullvadvpn.lib.model.FeatureIndicator +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Composable +fun FeatureIndicatorsPanel( + featureIndicators: List<FeatureIndicator>, + expanded: Boolean, + onToggleExpand: () -> Unit, +) { + if (featureIndicators.isNotEmpty()) { + if (expanded) { + ConnectionInfoHeader( + stringResource(R.string.connect_panel_active_features), + Modifier.fillMaxWidth(), + ) + } + FeatureIndicators(featureIndicators, expanded, onToggleExpand) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FeatureIndicators( + features: List<FeatureIndicator>, + expanded: Boolean, + onToggleExpand: () -> Unit, +) { + ContextualFlowRow( + modifier = Modifier.fillMaxWidth(), + itemCount = features.size, + // FlowRow may crash if maxLines is set to 1 + // https://issuetracker.google.com/issues/367440149 & + // https://issuetracker.google.com/issues/355003185 + maxLines = if (expanded) Int.MAX_VALUE else 2, + horizontalArrangement = Arrangement.spacedBy(Dimens.smallPadding), + overflow = + ContextualFlowRowOverflow.expandOrCollapseIndicator( + expandIndicator = { + val hiddenFeatureCount = totalItemCount - shownItemCount + MullvadMoreChip( + onClick = onToggleExpand, + text = + stringResource( + R.string.feature_indicators_show_more, + hiddenFeatureCount, + ), + containerColor = Color.Transparent, + ) + }, + collapseIndicator = {}, + ), + ) { index -> + MullvadFeatureChip(text = features[index].text()) + } + + // Spacing are added to compensate for when the + if (features.isEmpty() && !expanded) { + Spacer(Modifier.height(Dimens.smallSpacer)) + } +} + +@Composable +private fun FeatureIndicator.text(): String { + val resource = + when (this) { + FeatureIndicator.QUANTUM_RESISTANCE -> R.string.feature_quantum_resistant + FeatureIndicator.SPLIT_TUNNELING -> R.string.feature_split_tunneling + FeatureIndicator.SHADOWSOCKS, + FeatureIndicator.UDP_2_TCP -> R.string.feature_udp_2_tcp + FeatureIndicator.LAN_SHARING -> R.string.feature_lan_sharing + FeatureIndicator.DNS_CONTENT_BLOCKERS -> R.string.feature_dns_content_blockers + FeatureIndicator.CUSTOM_DNS -> R.string.feature_custom_dns + FeatureIndicator.SERVER_IP_OVERRIDE -> R.string.feature_server_ip_override + FeatureIndicator.CUSTOM_MTU -> R.string.feature_custom_mtu + FeatureIndicator.DAITA -> R.string.feature_daita + } + return textResource(resource) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt index bdce4abef6..ac325c8f9c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt @@ -74,7 +74,7 @@ private fun PreviewNotificationBanner() { @Composable fun NotificationBanner( - modifier: Modifier, + modifier: Modifier = Modifier, notification: InAppNotification?, isPlayBuild: Boolean, onClickUpdateVersion: () -> Unit, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt index b6c98b5ed8..f95c4681f9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt @@ -115,7 +115,7 @@ fun AutoConnectAndLockdownModeScreen(onBackClick: () -> Unit = {}) { } }, isEnabled = { pagerState.currentPage != 0 }, - rotation = 180f, + rotation = 90f, ) // Go to next page @@ -132,7 +132,7 @@ fun AutoConnectAndLockdownModeScreen(onBackClick: () -> Unit = {}) { } }, isEnabled = { pagerState.currentPage != pagerState.pageCount - 1 }, - rotation = 0f, + rotation = -90f, ) PageIndicator( 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 c9ee962eb1..b4811f95f0 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 @@ -4,28 +4,31 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.compose.animation.animateContentSize +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,14 +38,15 @@ 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.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed @@ -60,19 +64,20 @@ import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.ConnectionButton import net.mullvad.mullvadvpn.compose.button.SwitchLocationButton +import net.mullvad.mullvadvpn.compose.component.Chevron import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText -import net.mullvad.mullvadvpn.compose.component.LocationInfo import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName +import net.mullvad.mullvadvpn.compose.component.connectioninfo.ConnectionDetailPanel +import net.mullvad.mullvadvpn.compose.component.connectioninfo.FeatureIndicatorsPanel import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.component.notificationbanner.NotificationBanner import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.CONNECT_CARD_HEADER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle @@ -94,15 +99,23 @@ import net.mullvad.mullvadvpn.lib.model.Longitude import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.Shapes +import net.mullvad.mullvadvpn.lib.theme.color.Alpha20 +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.typeface.connectionStatus +import net.mullvad.mullvadvpn.lib.theme.typeface.hostname import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild import net.mullvad.mullvadvpn.util.removeHtmlTags import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import org.koin.androidx.compose.koinViewModel private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000 +private val SCREEN_HEIGHT_THRESHOLD = 700.dp +private const val SHORT_SCREEN_INDICATOR_BIAS = 0.2f +private const val TALL_SCREEN_INDICATOR_BIAS = 0.3f @Preview @Composable @@ -139,16 +152,19 @@ fun Connect( is ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> { openAccountPage(sideEffect.token) } + is ConnectViewModel.UiSideEffect.OutOfTime -> navigator.navigate(OutOfTimeDestination) { launchSingleTop = true popUpTo(NavGraphs.root) { inclusive = true } } + ConnectViewModel.UiSideEffect.RevokedDevice -> navigator.navigate(DeviceRevokedDestination) { launchSingleTop = true popUpTo(NavGraphs.root) { inclusive = true } } + is ConnectViewModel.UiSideEffect.NoVpnPermission -> launchVpnPermission.launch(Unit) is ConnectViewModel.UiSideEffect.ConnectError -> launch { @@ -209,8 +225,6 @@ fun ConnectScreen( onDismissNewDeviceClick: () -> Unit = {}, ) { - val scrollState = rememberScrollState() - ScaffoldWithTopBarAndDeviceName( topBarColor = state.tunnelState.topBarColor(), iconTintColor = state.tunnelState.iconTintColor(), @@ -220,41 +234,44 @@ fun ConnectScreen( timeLeft = state.daysLeftUntilExpiry, snackbarHostState = snackbarHostState, ) { - var progressIndicatorBias by remember { mutableFloatStateOf(0f) } + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val indicatorPercentOffset = + if (screenHeight < SCREEN_HEIGHT_THRESHOLD) SHORT_SCREEN_INDICATOR_BIAS + else TALL_SCREEN_INDICATOR_BIAS + + Box(Modifier.padding(it).fillMaxSize()) { + MullvadMap(state, indicatorPercentOffset) - MapColumn(state, it, progressIndicatorBias, scrollState) { - Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f)) MullvadCircularProgressIndicatorLarge( color = MaterialTheme.colorScheme.onSurface, modifier = - Modifier.animateContentSize() - .padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - top = Dimens.mediumPadding, - ) - .alpha(if (state.showLoading) AlphaVisible else AlphaInvisible) - .align(Alignment.CenterHorizontally) - .testTag(CIRCULAR_PROGRESS_INDICATOR) - .onGloballyPositioned { - val offsetY = it.positionInParent().y + it.size.height / 2 - it.parentLayoutCoordinates?.let { - val parentHeight = it.size.height - val verticalBias = offsetY / parentHeight - if (verticalBias.isFinite()) { - progressIndicatorBias = verticalBias - } + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative( + x = (constraints.maxWidth * 0.5f - placeable.width / 2).toInt(), + y = + (constraints.maxHeight * indicatorPercentOffset - + placeable.height / 2) + .toInt(), + ) } - }, + } + .alpha(if (state.showLoading) AlphaVisible else AlphaInvisible) + .testTag(CIRCULAR_PROGRESS_INDICATOR), ) - Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f)) - - ConnectionInfo(state = state) - Spacer(modifier = Modifier.height(Dimens.buttonSpacing)) - - ButtonPanel( - state, + NotificationBanner( + notification = state.inAppNotification, + isPlayBuild = state.isPlayBuild, + onClickUpdateVersion = onUpdateVersionClick, + onClickShowAccount = onManageAccountClick, + onClickDismissNewDevice = onDismissNewDeviceClick, + ) + ConnectionCard( + state = state, + modifier = Modifier.align(Alignment.BottomCenter), onSwitchLocationClick, onDisconnectClick, onReconnectClick, @@ -262,26 +279,11 @@ fun ConnectScreen( onConnectClick, ) } - - NotificationBanner( - modifier = Modifier.padding(top = it.calculateTopPadding()), - notification = state.inAppNotification, - isPlayBuild = state.isPlayBuild, - onClickUpdateVersion = onUpdateVersionClick, - onClickShowAccount = onManageAccountClick, - onClickDismissNewDevice = onDismissNewDeviceClick, - ) } } @Composable -private fun MapColumn( - state: ConnectUiState, - it: PaddingValues, - progressIndicatorBias: Float, - scrollState: ScrollState, - content: @Composable ColumnScope.() -> Unit, -) { +private fun MullvadMap(state: ConnectUiState, progressIndicatorBias: Float) { // Distance to marker when secure/unsecure val baseZoom = @@ -295,7 +297,7 @@ private fun MapColumn( val markers = state.tunnelState.toMarker(state.location)?.let { listOf(it) } ?: emptyList() AnimatedMap( - modifier = Modifier.padding(top = it.calculateTopPadding()), + modifier = Modifier, cameraLocation = state.location?.toLatLong() ?: fallbackLatLong, cameraBaseZoom = baseZoom.value, cameraVerticalBias = progressIndicatorBias, @@ -306,64 +308,164 @@ private fun MapColumn( oceanColor = MaterialTheme.colorScheme.surface, ), ) +} - Column( - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.Start, +@Composable +private fun ConnectionCard( + state: ConnectUiState, + modifier: Modifier = Modifier, + onSwitchLocationClick: () -> Unit, + onDisconnectClick: () -> Unit, + onReconnectClick: () -> Unit, + onCancelClick: () -> Unit, + onConnectClick: () -> Unit, +) { + var expanded by rememberSaveable(state.tunnelState::class) { mutableStateOf(false) } + val containerColor = + animateColorAsState( + if (expanded) MaterialTheme.colorScheme.surfaceContainer + else MaterialTheme.colorScheme.surfaceContainer.copy(alpha = Alpha80), + label = "connection_card_color", + ) + + Card( modifier = - Modifier.animateContentSize() - .padding(top = it.calculateTopPadding()) - .fillMaxHeight() - .drawVerticalScrollbar( - scrollState, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaScrollbar), - ) - .verticalScroll(scrollState) - .testTag(SCROLLABLE_COLUMN_TEST_TAG), + modifier.widthIn(max = Dimens.connectionCardMaxWidth).padding(Dimens.mediumPadding), + Shapes.large, + colors = CardDefaults.cardColors(containerColor = containerColor.value), ) { - content() - // We need to manually add this padding so we align size with the map - // component and marker with the progress indicator. - Spacer(modifier = Modifier.height(it.calculateBottomPadding())) + Column( + modifier = + Modifier.padding( + top = Dimens.mediumPadding, + start = Dimens.mediumPadding, + end = Dimens.mediumPadding, + bottom = Dimens.smallPadding, + ) + ) { + ConnectionCardHeader(state, state.location, expanded) { expanded = !expanded } + + AnimatedContent( + state.tunnelState as? TunnelState.Connected to expanded, + modifier = Modifier.weight(1f, fill = false), + label = "connection_card_connection_details", + ) { (connectedState, isExpanded) -> + if (connectedState != null) { + ConnectionInfo( + connectedState, + isExpanded, + onToggleExpand = { expanded = !expanded }, + ) + } else { + Spacer(Modifier.height(Dimens.smallSpacer)) + } + } + + Spacer(Modifier.height(Dimens.mediumPadding)) + + ButtonPanel( + state, + onSwitchLocationClick, + onDisconnectClick, + onReconnectClick, + onCancelClick, + onConnectClick, + ) + } } } @Composable -private fun ConnectionInfo(state: ConnectUiState) { - ConnectionStatusText( - state = state.tunnelState, - modifier = Modifier.padding(horizontal = Dimens.sideMargin), - ) - Text( - text = state.location?.country ?: "", - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = Dimens.sideMargin), - ) - Text( - text = state.location?.city ?: "", - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = Dimens.sideMargin), - ) - var expanded by rememberSaveable { mutableStateOf(false) } - LocationInfo( - onToggleTunnelInfo = { expanded = !expanded }, - isVisible = state.showLocationInfo, - isExpanded = expanded, - location = state.location, - isUsingDaita = state.tunnelState.isUsingDaita(), - inAddress = state.inAddress, - outAddress = state.outAddress, +private fun ConnectionCardHeader( + state: ConnectUiState, + location: GeoIpLocation?, + expanded: Boolean, + onToggleExpand: () -> Unit, +) { + Column( modifier = Modifier.fillMaxWidth() - .padding(horizontal = Dimens.sideMargin) - .testTag(LOCATION_INFO_TEST_TAG), - ) + .clickable( + enabled = state.tunnelState is TunnelState.Connected, + onClick = onToggleExpand, + ) + .testTag(CONNECT_CARD_HEADER_TEST_TAG) + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + ConnectionStatusText(state = state.tunnelState) + if (state.tunnelState is TunnelState.Connected) { + Chevron(isExpanded = !expanded, color = MaterialTheme.colorScheme.onSurface) + } + } + + Text( + modifier = Modifier.fillMaxWidth().padding(top = Dimens.tinyPadding), + text = location.asString(), + style = MaterialTheme.typography.connectionStatus, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + val hostname = location?.hostname + AnimatedContent(hostname, label = "hostname") { + if (it != null) { + Text( + modifier = Modifier.fillMaxWidth(), + text = it, + style = MaterialTheme.typography.hostname, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +private fun GeoIpLocation?.asString(): String { + return if (this == null) "" + else { + buildString { + append(country) + city?.let { + append(", ") + append(it) + } + } + } +} + +@Composable +private fun ConnectionInfo( + tunnelState: TunnelState.Connected, + expanded: Boolean, + onToggleExpand: () -> Unit, +) { + val scrollState = rememberScrollState() + Column { + if (expanded) { + HorizontalDivider( + Modifier.padding(vertical = Dimens.smallPadding), + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(Alpha20), + ) + } + Column( + modifier = + Modifier.fillMaxWidth() + .drawVerticalScrollbar( + scrollState, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaScrollbar), + ) + .verticalScroll(scrollState) + ) { + FeatureIndicatorsPanel(tunnelState.featureIndicators, expanded, onToggleExpand) + + if (expanded) { + ConnectionDetailPanel(tunnelState) + } + } + } } @Composable @@ -384,34 +486,32 @@ private fun ButtonPanel( action.invoke() } } + Column(modifier = Modifier.padding(vertical = Dimens.tinyPadding)) { + SwitchLocationButton( + text = + if (state.showLocation && state.selectedRelayItemTitle != null) { + state.selectedRelayItemTitle + } else { + stringResource(id = R.string.switch_location) + }, + onSwitchLocation = onSwitchLocationClick, + reconnectClick = { handleThrottledAction(onReconnectClick) }, + isReconnectButtonEnabled = + state.tunnelState is TunnelState.Connected || + state.tunnelState is TunnelState.Connecting, + modifier = Modifier.testTag(SELECT_LOCATION_BUTTON_TEST_TAG), + reconnectButtonTestTag = RECONNECT_BUTTON_TEST_TAG, + ) + Spacer(Modifier.height(Dimens.buttonVerticalPadding)) - SwitchLocationButton( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = Dimens.sideMargin) - .testTag(SELECT_LOCATION_BUTTON_TEST_TAG), - onClick = onSwitchLocationClick, - showChevron = state.showLocation, - text = - if (state.showLocation && state.selectedRelayItemTitle != null) { - state.selectedRelayItemTitle - } else { - stringResource(id = R.string.switch_location) - }, - ) - Spacer(modifier = Modifier.height(Dimens.buttonSpacing)) - ConnectionButton( - state = state.tunnelState, - modifier = - Modifier.padding(horizontal = Dimens.sideMargin) - .padding(bottom = Dimens.screenVerticalMargin) - .testTag(CONNECT_BUTTON_TEST_TAG), - disconnectClick = onDisconnectClick, - reconnectClick = { handleThrottledAction(onReconnectClick) }, - cancelClick = onCancelClick, - connectClick = { handleThrottledAction(onConnectClick) }, - reconnectButtonTestTag = RECONNECT_BUTTON_TEST_TAG, - ) + ConnectionButton( + modifier = Modifier.fillMaxWidth().testTag(CONNECT_BUTTON_TEST_TAG), + state = state.tunnelState, + disconnectClick = onDisconnectClick, + cancelClick = onCancelClick, + connectClick = { handleThrottledAction(onConnectClick) }, + ) + } } @Composable @@ -423,12 +523,14 @@ fun TunnelState.toMarker(location: GeoIpLocation?): Marker? { location.toLatLong(), colors = LocationMarkerColors(centerColor = MaterialTheme.colorScheme.tertiary), ) + is TunnelState.Connecting -> null is TunnelState.Disconnected -> Marker( location.toLatLong(), colors = LocationMarkerColors(centerColor = MaterialTheme.colorScheme.error), ) + is TunnelState.Disconnecting -> null is TunnelState.Error -> null } @@ -453,11 +555,13 @@ private fun ConnectViewModel.UiSideEffect.ConnectError.toMessage(context: Contex when (this) { ConnectViewModel.UiSideEffect.ConnectError.NoVpnPermission -> context.getString(R.string.vpn_permission_denied_error) + is ConnectViewModel.UiSideEffect.ConnectError.AlwaysOnVpn -> // Snackbar currently do not support annotated string context .getString(R.string.always_on_vpn_error_notification_content, appName) .removeHtmlTags() + ConnectViewModel.UiSideEffect.ConnectError.Generic -> context.getString(R.string.error_occurred) } 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 910bdaa17f..98b5219785 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 @@ -1,7 +1,6 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.lib.model.GeoIpLocation -import net.mullvad.mullvadvpn.lib.model.TransportProtocol import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.repository.InAppNotification @@ -9,8 +8,6 @@ data class ConnectUiState( val location: GeoIpLocation?, val selectedRelayItemTitle: String?, val tunnelState: TunnelState, - val inAddress: Triple<String, Int, TransportProtocol>?, - val outAddress: String, val showLocation: Boolean, val inAppNotification: InAppNotification?, val deviceName: String?, @@ -18,8 +15,6 @@ data class ConnectUiState( val isPlayBuild: Boolean, ) { - val showLocationInfo: Boolean = - tunnelState !is TunnelState.Disconnected && location?.hostname != null val showLoading = tunnelState is TunnelState.Connecting || tunnelState is TunnelState.Disconnecting @@ -29,8 +24,6 @@ data class ConnectUiState( location = null, selectedRelayItemTitle = null, tunnelState = TunnelState.Disconnected(), - inAddress = null, - outAddress = "", showLocation = false, inAppNotification = null, deviceName = null, 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 299c99190d..4163010c1d 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 @@ -26,7 +26,7 @@ const val SCROLLABLE_COLUMN_TEST_TAG = "scrollable_column_test_tag" const val SELECT_LOCATION_BUTTON_TEST_TAG = "select_location_button_test_tag" const val CONNECT_BUTTON_TEST_TAG = "connect_button_test_tag" const val RECONNECT_BUTTON_TEST_TAG = "reconnect_button_test_tag" -const val LOCATION_INFO_TEST_TAG = "location_info_test_tag" +const val CONNECT_CARD_HEADER_TEST_TAG = "connect_card_header_test_tag" const val LOCATION_INFO_CONNECTION_OUT_TEST_TAG = "location_info_connection_out_test_tag" // ConnectScreen - Notification banner diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GeoIpLocationExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GeoIpLocationExtensions.kt deleted file mode 100644 index d908f44158..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GeoIpLocationExtensions.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.mullvad.mullvadvpn.util - -import net.mullvad.mullvadvpn.lib.model.GeoIpLocation - -fun GeoIpLocation.toOutAddress(): String = - when { - ipv6 != null && ipv4 != null -> "${ipv4!!.hostAddress} / ${ipv6!!.hostAddress}" - ipv6 != null -> ipv6!!.hostAddress ?: "" - ipv4 != null -> ipv4!!.hostAddress ?: "" - else -> "" - } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt index d8c310b029..20d8f4fc8e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt @@ -1,12 +1 @@ package net.mullvad.mullvadvpn.util - -import net.mullvad.mullvadvpn.lib.model.TransportProtocol -import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint - -fun TunnelEndpoint.toInAddress(): Triple<String, Int, TransportProtocol> { - val relayEndpoint = this.obfuscation?.endpoint ?: this.endpoint - val host = relayEndpoint.address.address.hostAddress ?: "" - val port = relayEndpoint.address.port - val protocol = relayEndpoint.protocol - return Triple(host, port, protocol) -} 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 7da838e58f..33d81f3ba1 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 @@ -33,8 +33,6 @@ import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase import net.mullvad.mullvadvpn.util.combine import net.mullvad.mullvadvpn.util.daysFromNow import net.mullvad.mullvadvpn.util.isSuccess -import net.mullvad.mullvadvpn.util.toInAddress -import net.mullvad.mullvadvpn.util.toOutAddress @Suppress("LongParameterList") class ConnectViewModel( @@ -83,13 +81,6 @@ class ConnectViewModel( }, selectedRelayItemTitle = selectedRelayItemTitle, tunnelState = tunnelState, - inAddress = - when (tunnelState) { - is TunnelState.Connected -> tunnelState.endpoint.toInAddress() - is TunnelState.Connecting -> tunnelState.endpoint?.toInAddress() - else -> null - }, - outAddress = tunnelState.location()?.toOutAddress() ?: "", showLocation = when (tunnelState) { is TunnelState.Disconnected -> true diff --git a/android/app/src/main/res/drawable/icon_reload.xml b/android/app/src/main/res/drawable/icon_reload.xml new file mode 100644 index 0000000000..1ab457eff5 --- /dev/null +++ b/android/app/src/main/res/drawable/icon_reload.xml @@ -0,0 +1,14 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <group> + <clip-path + android:pathData="M0,0h24v24h-24z"/> + <path + android:pathData="M6,12C6,8.686 8.686,6 12,6C13.776,6 15.373,6.771 16.472,8H15C14.448,8 14,8.448 14,9C14,9.552 14.448,10 15,10H19C19.552,10 20,9.552 20,9V5C20,4.448 19.552,4 19,4C18.448,4 18,4.448 18,5V6.709C16.535,5.049 14.39,4 12,4C7.582,4 4,7.582 4,12C4,16.418 7.582,20 12,20C14.13,20 16.067,19.166 17.5,17.809C17.901,17.43 17.918,16.797 17.539,16.396C17.159,15.995 16.526,15.977 16.125,16.357C15.049,17.376 13.598,18 12,18C8.686,18 6,15.314 6,12Z" + android:fillColor="#ffffff" + android:fillType="evenOdd"/> + </group> +</vector> diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index 79f97ff466..267813d62a 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt @@ -9,7 +9,6 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.assertEquals import kotlin.test.assertIs @@ -41,8 +40,6 @@ import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase -import net.mullvad.mullvadvpn.util.toInAddress -import net.mullvad.mullvadvpn.util.toOutAddress import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -95,9 +92,6 @@ class ConnectViewModelTest { @BeforeEach fun setup() { - mockkStatic(TUNNEL_ENDPOINT_EXTENSIONS) - mockkStatic(GEO_IP_LOCATIONS_EXTENSIONS) - every { mockServiceConnectionManager.connectionState } returns serviceConnectionState every { mockAccountRepository.accountData } returns accountExpiryState @@ -164,8 +158,7 @@ class ConnectViewModelTest { val tunnelEndpoint: TunnelEndpoint = mockk() val location: GeoIpLocation = mockk() val tunnelStateTestItem = TunnelState.Connected(tunnelEndpoint, location, emptyList()) - every { tunnelEndpoint.toInAddress() } returns mockk(relaxed = true) - every { location.toOutAddress() } returns "1.1.1.1" + every { location.ipv4?.hostAddress } returns "1.1.1.1" every { location.hostname } returns "hostname" // Act, Assert @@ -330,11 +323,4 @@ class ConnectViewModelTest { // Assert assertIs<ConnectViewModel.UiSideEffect.OutOfTime>(deferred.await()) } - - companion object { - private const val TUNNEL_ENDPOINT_EXTENSIONS = - "net.mullvad.mullvadvpn.util.TunnelEndpointExtensionsKt" - private const val GEO_IP_LOCATIONS_EXTENSIONS = - "net.mullvad.mullvadvpn.util.GeoIpLocationExtensionsKt" - } } |
