diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-07-14 11:37:24 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-07-14 11:37:24 +0200 |
| commit | 0836eb0babb61baa95b2cb5bd3bdb12647fc5df7 (patch) | |
| tree | 4d3b43e32de8277d3f2eb3ae43e2cf8a4be4a041 | |
| parent | 50a01d641fe081fafb648132e56476e9a320053e (diff) | |
| parent | b82f65d4b19f4e534cdd69e8ead7f79aa176c6b2 (diff) | |
| download | mullvadvpn-0836eb0babb61baa95b2cb5bd3bdb12647fc5df7.tar.xz mullvadvpn-0836eb0babb61baa95b2cb5bd3bdb12647fc5df7.zip | |
Merge branch 'migrate-connect-fragment-main-screen-to-compose-droid-188'
39 files changed, 1393 insertions, 767 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 new file mode 100644 index 0000000000..48671136e0 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -0,0 +1,581 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +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.RECONNECT_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.model.GeoIpLocation +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.talpid.net.TransportProtocol +import net.mullvad.talpid.net.TunnelEndpoint +import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import net.mullvad.talpid.tunnel.ErrorState +import net.mullvad.talpid.tunnel.ErrorStateCause +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ConnectScreenTest { + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun setup() { + MockKAnnotations.init(this) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun testDefaultState() { + // Arrange + composeTestRule.setContent { ConnectScreen(uiState = ConnectUiState.INITIAL) } + + // Assert + composeTestRule.apply { + onNodeWithText("UNSECURED CONNECTION").assertExists() + onNodeWithText("Secure my connection").assertExists() + } + } + + @Test + fun testConnectingState() { + // Arrange + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Connecting(null, null), + tunnelRealState = TunnelState.Connecting(null, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() + onNodeWithText("CREATING SECURE CONNECTION").assertExists() + onNodeWithText("Switch location").assertExists() + onNodeWithText("Cancel").assertExists() + } + } + + @Test + fun testConnectingStateQuantumSecured() { + // Arrange + val mockTunnelEndpoint: TunnelEndpoint = mockk(relaxed = true) + every { mockTunnelEndpoint.quantumResistant } returns true + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Connecting(endpoint = mockTunnelEndpoint, null), + tunnelRealState = + TunnelState.Connecting(endpoint = mockTunnelEndpoint, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() + onNodeWithText("CREATING QUANTUM SECURE CONNECTION").assertExists() + onNodeWithText("Switch location").assertExists() + onNodeWithText("Cancel").assertExists() + } + } + + @Test + fun testConnectedState() { + // Arrange + val mockTunnelEndpoint: TunnelEndpoint = mockk(relaxed = true) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), + tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("SECURE CONNECTION").assertExists() + onNodeWithText("Switch location").assertExists() + onNodeWithText("Disconnect").assertExists() + } + } + + @Test + fun testConnectedStateQuantumSecured() { + // Arrange + val mockTunnelEndpoint: TunnelEndpoint = mockk(relaxed = true) + every { mockTunnelEndpoint.quantumResistant } returns true + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), + tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("QUANTUM SECURE CONNECTION").assertExists() + onNodeWithText("Switch location").assertExists() + onNodeWithText("Disconnect").assertExists() + } + } + + @Test + fun testDisconnectingState() { + // Arrange + val mockRelayLocation: RelayItem = mockk(relaxed = true) + val mockLocationName = "Home" + every { mockRelayLocation.locationName } returns mockLocationName + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = mockRelayLocation, + versionInfo = null, + tunnelUiState = TunnelState.Disconnecting(ActionAfterDisconnect.Nothing), + tunnelRealState = TunnelState.Disconnecting(ActionAfterDisconnect.Nothing), + inAddress = null, + outAddress = "", + showLocation = true, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("UNSECURED CONNECTION").assertExists() + onNodeWithText(mockLocationName).assertExists() + onNodeWithText("Secure my connection").assertExists() + } + } + + @Test + fun testDisconnectedState() { + // Arrange + val mockRelayLocation: RelayItem = mockk(relaxed = true) + val mockLocationName = "Home" + every { mockRelayLocation.locationName } returns mockLocationName + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = mockRelayLocation, + versionInfo = null, + tunnelUiState = TunnelState.Disconnected, + tunnelRealState = TunnelState.Disconnected, + inAddress = null, + outAddress = "", + showLocation = true, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("UNSECURED CONNECTION").assertExists() + onNodeWithText(mockLocationName).assertExists() + onNodeWithText("Secure my connection").assertExists() + } + } + + @Test + fun testErrorStateBlocked() { + // Arrange + val mockRelayLocation: RelayItem = mockk(relaxed = true) + val mockLocationName = "Home" + every { mockRelayLocation.locationName } returns mockLocationName + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = mockRelayLocation, + versionInfo = null, + tunnelUiState = + TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, true)), + tunnelRealState = + TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, true)), + inAddress = null, + outAddress = "", + showLocation = true, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("BLOCKED CONNECTION").assertExists() + onNodeWithText(mockLocationName).assertExists() + onNodeWithText("Disconnect").assertExists() + } + } + + @Test + fun testErrorStateNotBlocked() { + // Arrange + val mockRelayLocation: RelayItem = mockk(relaxed = true) + val mockLocationName = "Home" + every { mockRelayLocation.locationName } returns mockLocationName + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = mockRelayLocation, + versionInfo = null, + tunnelUiState = + TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, false)), + tunnelRealState = + TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, false)), + inAddress = null, + outAddress = "", + showLocation = true, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("FAILED TO SECURE CONNECTION").assertExists() + onNodeWithText(mockLocationName).assertExists() + onNodeWithText("Dismiss").assertExists() + } + } + + @Test + fun testReconnectingState() { + // Arrange + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect), + tunnelRealState = + TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists() + onNodeWithText("CREATING SECURE CONNECTION").assertExists() + onNodeWithText("Switch location").assertExists() + onNodeWithText("Disconnect").assertExists() + } + } + + @Test + fun testDisconnectingBlockState() { + // Arrange + val mockRelayLocation: RelayItem = mockk(relaxed = true) + val mockLocationName = "Home" + every { mockRelayLocation.locationName } returns mockLocationName + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = mockRelayLocation, + versionInfo = null, + tunnelUiState = TunnelState.Disconnecting(ActionAfterDisconnect.Block), + tunnelRealState = TunnelState.Disconnecting(ActionAfterDisconnect.Block), + inAddress = null, + outAddress = "", + showLocation = true, + isTunnelInfoExpanded = false + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("SECURE CONNECTION").assertExists() + onNodeWithText(mockLocationName).assertExists() + onNodeWithText("Disconnect").assertExists() + } + } + + @Test + fun testClickSelectLocationButton() { + // Arrange + val mockRelayLocation: RelayItem = mockk(relaxed = true) + val mockLocationName = "Home" + every { mockRelayLocation.locationName } returns mockLocationName + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = mockRelayLocation, + versionInfo = null, + tunnelUiState = TunnelState.Disconnected, + tunnelRealState = TunnelState.Disconnected, + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ), + onSwitchLocationClick = mockedClickHandler + ) + } + + // Act + composeTestRule.onNodeWithTag(SELECT_LOCATION_BUTTON_TEST_TAG).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun testOnDisconnectClick() { + // Arrange + val mockTunnelEndpoint: TunnelEndpoint = mockk(relaxed = true) + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), + tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ), + onDisconnectClick = mockedClickHandler + ) + } + + // Act + composeTestRule.onNodeWithTag(CONNECT_BUTTON_TEST_TAG).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun testOnReconnectClick() { + // Arrange + val mockTunnelEndpoint: TunnelEndpoint = mockk(relaxed = true) + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), + tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ), + onReconnectClick = mockedClickHandler + ) + } + + // Act + composeTestRule.onNodeWithTag(RECONNECT_BUTTON_TEST_TAG).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun testOnConnectClick() { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Disconnected, + tunnelRealState = TunnelState.Disconnected, + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ), + onConnectClick = mockedClickHandler + ) + } + + // Act + composeTestRule.onNodeWithTag(CONNECT_BUTTON_TEST_TAG).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun testOnCancelClick() { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Connecting(null, null), + tunnelRealState = TunnelState.Connecting(null, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ), + onCancelClick = mockedClickHandler + ) + } + + // Act + composeTestRule.onNodeWithTag(CONNECT_BUTTON_TEST_TAG).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun testToggleTunnelInfo() { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Connecting(null, null), + tunnelRealState = TunnelState.Connecting(null, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false + ), + onToggleTunnelInfo = mockedClickHandler + ) + } + + // Act + composeTestRule.onNodeWithTag(LOCATION_INFO_TEST_TAG).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun showLocationInfo() { + // 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" + every { mockLocation.hostname } returns mockHostName + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = mockLocation, + relayLocation = null, + versionInfo = null, + tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), + tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + inAddress = mockInAddress, + outAddress = mockOutAddress, + showLocation = false, + isTunnelInfoExpanded = true + ) + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText(mockHostName).assertExists() + onNodeWithText("WireGuard").assertExists() + onNodeWithText("In $mockHost:$mockPort UDP").assertExists() + onNodeWithText("Out $mockOutAddress").assertExists() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Button.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ActionButton.kt index ef5e283c29..8b8a6cd319 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Button.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ActionButton.kt @@ -1,6 +1,7 @@ -package net.mullvad.mullvadvpn.compose.component +package net.mullvad.mullvadvpn.compose.button import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -18,11 +19,19 @@ import net.mullvad.mullvadvpn.compose.theme.Dimens @Composable fun ActionButton( - text: String, onClick: () -> Unit, colors: ButtonColors, modifier: Modifier = Modifier, - isEnabled: Boolean = true + text: String = "", + isEnabled: Boolean = true, + content: @Composable RowScope.() -> Unit = { + Text( + text = text, + textAlign = TextAlign.Center, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + } ) { Button( onClick = onClick, @@ -37,11 +46,6 @@ fun ActionButton( colors = colors, shape = MaterialTheme.shapes.small ) { - Text( - text = text, - textAlign = TextAlign.Center, - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) + content() } } 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 new file mode 100644 index 0000000000..ca92eb290c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt @@ -0,0 +1,180 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +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.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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.textResource +import net.mullvad.mullvadvpn.compose.theme.AlphaInactive +import net.mullvad.mullvadvpn.compose.theme.AppTheme +import net.mullvad.mullvadvpn.compose.theme.Dimens +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +@Composable +fun ConnectionButton( + modifier: Modifier = Modifier, + reconnectButtonTestTag: String = "", + state: TunnelState, + disconnectClick: () -> Unit, + reconnectClick: () -> Unit, + cancelClick: () -> Unit, + connectClick: () -> Unit +) { + when (state) { + is TunnelState.Disconnected -> ConnectButton(modifier = modifier, onClick = connectClick) + is TunnelState.Disconnecting -> { + when (state.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> + ConnectButton(modifier = modifier, onClick = connectClick) + ActionAfterDisconnect.Block -> + DisconnectButton( + modifier = modifier, + text = stringResource(id = R.string.disconnect), + mainClick = connectClick, + reconnectClick = reconnectClick, + reconnectButtonTestTag = reconnectButtonTestTag + ) + ActionAfterDisconnect.Reconnect -> + DisconnectButton( + modifier = modifier, + text = stringResource(id = R.string.disconnect), + mainClick = connectClick, + reconnectClick = reconnectClick, + reconnectButtonTestTag = reconnectButtonTestTag + ) + } + } + is TunnelState.Connecting -> + DisconnectButton( + modifier = modifier, + text = stringResource(id = R.string.cancel), + mainClick = cancelClick, + reconnectClick = reconnectClick, + reconnectButtonTestTag = reconnectButtonTestTag + ) + is TunnelState.Connected -> + DisconnectButton( + modifier = modifier, + text = stringResource(id = R.string.disconnect), + mainClick = disconnectClick, + reconnectClick = reconnectClick, + reconnectButtonTestTag = reconnectButtonTestTag + ) + is TunnelState.Error -> { + if (state.errorState.isBlocking) { + DisconnectButton( + modifier = modifier, + text = stringResource(id = R.string.disconnect), + mainClick = disconnectClick, + reconnectClick = reconnectClick, + reconnectButtonTestTag = reconnectButtonTestTag + ) + } else { + DisconnectButton( + modifier = modifier, + text = stringResource(id = R.string.dismiss), + mainClick = cancelClick, + reconnectClick = reconnectClick, + reconnectButtonTestTag = reconnectButtonTestTag + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewConnectButton() { + AppTheme { ConnectButton(onClick = {}) } +} + +@Composable +private fun ConnectButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + ActionButton( + text = textResource(id = R.string.connect), + modifier = modifier, + onClick = onClick, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) +} + +@Preview +@Composable +fun PreviewDisconnectButton() { + AppTheme { DisconnectButton(text = "Disconnect", mainClick = {}, reconnectClick = {}) } +} + +@Composable +private fun DisconnectButton( + text: String, + modifier: Modifier = Modifier, + height: Dp = Dimens.connectButtonHeight, + reconnectButtonTestTag: String = "", + mainClick: () -> Unit, + reconnectClick: () -> Unit +) { + Row(modifier = modifier.height(height)) { + Button( + onClick = mainClick, + shape = + MaterialTheme.shapes.small.copy( + topEnd = CornerSize(percent = 0), + bottomEnd = CornerSize(percent = 0) + ), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error.copy(alpha = AlphaInactive), + contentColor = MaterialTheme.colorScheme.onError + ), + modifier = Modifier.weight(1f).height(height) + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.width(Dimens.listItemDivider)) + + FilledIconButton( + shape = + MaterialTheme.shapes.small.copy( + topStart = CornerSize(percent = 0), + bottomStart = CornerSize(percent = 0) + ), + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.error.copy(alpha = AlphaInactive), + contentColor = MaterialTheme.colorScheme.onError + ), + onClick = reconnectClick, + modifier = Modifier.height(height).aspectRatio(1f, true).testTag(reconnectButtonTestTag) + ) { + Icon(painter = painterResource(id = R.drawable.icon_reload), contentDescription = null) + } + } +} 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 new file mode 100644 index 0000000000..2d6e149af8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt @@ -0,0 +1,66 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +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.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.SpacedColumn +import net.mullvad.mullvadvpn.compose.theme.Alpha20 +import net.mullvad.mullvadvpn.compose.theme.AppTheme +import net.mullvad.mullvadvpn.compose.theme.Dimens + +@Preview +@Composable +fun PreviewSwitchLocationButton() { + AppTheme { + SpacedColumn { + SwitchLocationButton(onClick = {}, text = "Switch Location", showChevron = false) + SwitchLocationButton(onClick = {}, text = "Switch Location", showChevron = true) + } + } +} + +@Composable +fun SwitchLocationButton( + modifier: Modifier = Modifier, + text: String, + showChevron: Boolean, + onClick: () -> Unit, +) { + ActionButton( + onClick = onClick, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.inverseSurface.copy(alpha = Alpha20), + contentColor = MaterialTheme.colorScheme.inverseSurface + ), + modifier = modifier + ) { + Box(modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(all = Dimens.smallPadding)) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Center) + ) + if (showChevron) { + Icon( + painter = painterResource(id = R.drawable.icon_chevron), + contentDescription = null, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } +} 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 1e34b13896..c1c01dc6c1 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 @@ -9,11 +9,16 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import net.mullvad.mullvadvpn.R @Composable -fun ChevronView(modifier: Modifier = Modifier, isExpanded: Boolean) { +fun ChevronView( + modifier: Modifier = Modifier, + colorFilter: ColorFilter? = null, + isExpanded: Boolean +) { val resourceId = R.drawable.icon_chevron val rotation = remember { Animatable(90f + if (isExpanded) 180f else 0f) } @@ -27,6 +32,7 @@ fun ChevronView(modifier: Modifier = Modifier, isExpanded: Boolean) { Image( painterResource(id = resourceId), contentDescription = null, + colorFilter = colorFilter, modifier = modifier.rotate(rotation.value), ) } 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 new file mode 100644 index 0000000000..4cc1d82fcd --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt @@ -0,0 +1,94 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.theme.AppTheme +import net.mullvad.mullvadvpn.compose.theme.typeface.connectionStatus +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import net.mullvad.talpid.tunnel.ErrorState +import net.mullvad.talpid.tunnel.ErrorStateCause + +@Preview +@Composable +fun PreviewConnectionStatusText() { + AppTheme { + SpacedColumn { + ConnectionStatusText(TunnelState.Disconnected) + ConnectionStatusText(TunnelState.Connecting(null, null)) + ConnectionStatusText( + state = TunnelState.Error(ErrorState(ErrorStateCause.Ipv6Unavailable, true)) + ) + } + } +} + +@Composable +fun ConnectionStatusText(state: TunnelState) { + when (state) { + is TunnelState.Disconnecting -> { + when (state.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> DisconnectedText() + ActionAfterDisconnect.Block -> ConnectedText(false) + ActionAfterDisconnect.Reconnect -> ConnectingText(false) + } + } + is TunnelState.Disconnected -> DisconnectedText() + is TunnelState.Connecting -> ConnectingText(state.endpoint?.quantumResistant == true) + is TunnelState.Connected -> ConnectedText(state.endpoint.quantumResistant) + is TunnelState.Error -> ErrorText(state.errorState.isBlocking) + } +} + +@Composable +private fun DisconnectedText() { + Text( + text = textResource(id = R.string.unsecured_connection), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.connectionStatus + ) +} + +@Composable +private fun ConnectingText(isQuantumResistant: Boolean) { + Text( + text = + textResource( + id = + if (isQuantumResistant) R.string.quantum_creating_secure_connection + else R.string.creating_secure_connection + ), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.connectionStatus + ) +} + +@Composable +private fun ConnectedText(isQuantumResistant: Boolean) { + Text( + text = + textResource( + id = + if (isQuantumResistant) R.string.quantum_secure_connection + else R.string.secure_connection + ), + color = MaterialTheme.colorScheme.surface, + style = MaterialTheme.typography.connectionStatus + ) +} + +@Composable +private fun ErrorText(isBlocking: Boolean) { + Text( + text = + textResource( + id = if (isBlocking) R.string.blocked_connection else R.string.error_state + ), + color = + if (isBlocking) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.connectionStatus + ) +} 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 new file mode 100644 index 0000000000..9980908353 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt @@ -0,0 +1,119 @@ +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.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +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.theme.AlphaInactive +import net.mullvad.mullvadvpn.compose.theme.AlphaInvisible +import net.mullvad.mullvadvpn.compose.theme.AlphaVisible +import net.mullvad.mullvadvpn.compose.theme.AppTheme +import net.mullvad.mullvadvpn.compose.theme.Dimens +import net.mullvad.mullvadvpn.model.GeoIpLocation +import net.mullvad.talpid.net.TransportProtocol + +@Preview +@Composable +fun PreviewLocationInfo() { + AppTheme { + LocationInfo( + onToggleTunnelInfo = {}, + isVisible = true, + isExpanded = true, + location = null, + inAddress = null, + outAddress = "" + ) + } +} + +@Composable +fun LocationInfo( + modifier: Modifier = Modifier, + colorExpanded: Color = MaterialTheme.colorScheme.onPrimary, + colorCollapsed: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaInactive), + onToggleTunnelInfo: () -> Unit, + isVisible: Boolean, + isExpanded: Boolean, + location: GeoIpLocation?, + inAddress: Triple<String, Int, TransportProtocol>?, + outAddress: String +) { + Column( + modifier = + if (isVisible) { + Modifier.clickable { onToggleTunnelInfo() }.alpha(AlphaVisible) + } else { + Modifier.alpha(AlphaInvisible) + } + .then(modifier) + ) { + Row { + Text( + text = location?.hostname ?: "", + color = + if (isExpanded) { + colorExpanded + } else { + colorCollapsed + }, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold) + ) + ChevronView( + isExpanded = isExpanded, + colorFilter = + ColorFilter.tint( + 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.alpha(if (isExpanded) AlphaVisible else AlphaInvisible) + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt index c53b91dfae..57d2073672 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.component.ActionButton +import net.mullvad.mullvadvpn.compose.button.ActionButton import net.mullvad.mullvadvpn.compose.textfield.CustomPortTextField import net.mullvad.mullvadvpn.compose.theme.AlphaDescription import net.mullvad.mullvadvpn.compose.theme.AlphaDisabled 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 new file mode 100644 index 0000000000..b6b31b7f3b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -0,0 +1,134 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.CircularProgressIndicator +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.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +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.ConnectionStatusText +import net.mullvad.mullvadvpn.compose.component.LocationInfo +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.RECONNECT_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.theme.AppTheme +import net.mullvad.mullvadvpn.compose.theme.Dimens +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +@Preview +@Composable +fun PreviewConnectScreen() { + val state = ConnectUiState.INITIAL + AppTheme { ConnectScreen(state) } +} + +@Composable +fun ConnectScreen( + uiState: ConnectUiState, + onDisconnectClick: () -> Unit = {}, + onReconnectClick: () -> Unit = {}, + onConnectClick: () -> Unit = {}, + onCancelClick: () -> Unit = {}, + onSwitchLocationClick: () -> Unit = {}, + onToggleTunnelInfo: () -> Unit = {} +) { + val scrollState = rememberScrollState() + Column( + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.Start, + modifier = + Modifier.background(color = MaterialTheme.colorScheme.primary) + .fillMaxHeight() + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenVerticalMargin) + .scrollable(scrollState, Orientation.Vertical) + ) { + if ( + uiState.tunnelRealState is TunnelState.Connecting || + (uiState.tunnelRealState is TunnelState.Disconnecting && + uiState.tunnelRealState.actionAfterDisconnect == + ActionAfterDisconnect.Reconnect) + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + modifier = + Modifier.size( + width = Dimens.progressIndicatorSize, + height = Dimens.progressIndicatorSize + ) + .align(Alignment.CenterHorizontally) + .testTag(CIRCULAR_PROGRESS_INDICATOR) + ) + } + Spacer(modifier = Modifier.height(Dimens.smallPadding)) + ConnectionStatusText(state = uiState.tunnelRealState) + Text( + text = uiState.location?.country ?: "", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = uiState.location?.city ?: "", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary + ) + LocationInfo( + onToggleTunnelInfo = onToggleTunnelInfo, + isVisible = uiState.tunnelRealState != TunnelState.Disconnected, + isExpanded = uiState.isTunnelInfoExpanded, + location = uiState.location, + inAddress = uiState.inAddress, + outAddress = uiState.outAddress, + modifier = Modifier.fillMaxWidth().testTag(LOCATION_INFO_TEST_TAG) + ) + Spacer(modifier = Modifier.height(Dimens.buttonSeparation)) + SwitchLocationButton( + modifier = + Modifier.fillMaxWidth() + .height(Dimens.selectLocationButtonHeight) + .testTag(SELECT_LOCATION_BUTTON_TEST_TAG), + onClick = onSwitchLocationClick, + showChevron = uiState.showLocation, + text = + if (uiState.showLocation) { + uiState.relayLocation?.locationName ?: "" + } else { + stringResource(id = R.string.switch_location) + } + ) + Spacer(modifier = Modifier.height(Dimens.buttonSeparation)) + ConnectionButton( + state = uiState.tunnelUiState, + modifier = + Modifier.fillMaxWidth() + .height(Dimens.connectButtonHeight) + .testTag(CONNECT_BUTTON_TEST_TAG), + disconnectClick = onDisconnectClick, + reconnectClick = onReconnectClick, + cancelClick = onCancelClick, + connectClick = onConnectClick, + reconnectButtonTestTag = RECONNECT_BUTTON_TEST_TAG + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index adf641e965..27824c5b28 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -26,7 +26,7 @@ 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.ActionButton +import net.mullvad.mullvadvpn.compose.button.ActionButton import net.mullvad.mullvadvpn.compose.component.ListItem import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.dialog.ShowDeviceRemovalDialog diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt index 07a2a759b2..4b4107a586 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.component.ActionButton +import net.mullvad.mullvadvpn.compose.button.ActionButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState import net.mullvad.mullvadvpn.compose.theme.AppTheme diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt index 29146fe634..832179f95c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.component.ActionButton +import net.mullvad.mullvadvpn.compose.button.ActionButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.theme.AppTheme 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 a69f96a891..417f589b6e 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 @@ -4,6 +4,7 @@ import net.mullvad.mullvadvpn.model.GeoIpLocation import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.talpid.net.TransportProtocol data class ConnectUiState( val location: GeoIpLocation?, @@ -11,6 +12,9 @@ data class ConnectUiState( val versionInfo: VersionInfo?, val tunnelUiState: TunnelState, val tunnelRealState: TunnelState, + val inAddress: Triple<String, Int, TransportProtocol>?, + val outAddress: String, + val showLocation: Boolean, val isTunnelInfoExpanded: Boolean ) { companion object { @@ -21,6 +25,9 @@ data class ConnectUiState( versionInfo = null, tunnelUiState = TunnelState.Disconnected, tunnelRealState = TunnelState.Disconnected, + inAddress = null, + outAddress = "", + showLocation = false, isTunnelInfoExpanded = false ) } 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 536a343594..f896183bcb 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 @@ -11,5 +11,11 @@ const val LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG = const val LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG = "lazy_list_wireguard_custom_port_number_test_tag" -// SelectLocationScreen +// SelectLocationScreen, ConnectScreen const val CIRCULAR_PROGRESS_INDICATOR = "circular_progress_indicator" + +// ConnectScreen +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" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt index d089a467ca..49c64f75d4 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt @@ -23,6 +23,7 @@ val MullvadWhite80 = Color(0xCCFFFFFF) const val AlphaVisible = 1f const val AlphaDisabled = 0.2f +const val Alpha20 = 0.2f const val AlphaInactive = 0.4f const val AlphaDescription = 0.6f const val AlphaInvisible = 0f diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt index c2cc862b8d..a558d817ae 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt @@ -21,6 +21,7 @@ import net.mullvad.mullvadvpn.compose.theme.typeface.TypeScale // Add our own definitions here private val MullvadTypography = Typography( + headlineLarge = TextStyle(fontSize = TypeScale.TextHuge, fontWeight = FontWeight.Bold), headlineSmall = TextStyle( color = MullvadWhite, @@ -61,9 +62,12 @@ private val MullvadColorPalette = onSurfaceVariant = MullvadWhite, onPrimary = MullvadWhite, onSecondary = MullvadWhite60, + onError = MullvadWhite, + onSurface = MullvadWhite, inversePrimary = MullvadGreen, error = MullvadRed, - outlineVariant = Color.Transparent // Used by divider + outlineVariant = Color.Transparent, // Used by divider + inverseSurface = MullvadWhite ) val Shapes = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt index 8c949531ff..9881bfe818 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.unit.dp data class Dimensions( val buttonHeight: Dp = 44.dp, + val buttonSeparation: Dp = 18.dp, val cellEndPadding: Dp = 16.dp, val cellFooterTopPadding: Dp = 6.dp, val cellHeight: Dp = 52.dp, @@ -12,7 +13,9 @@ data class Dimensions( val cellStartPadding: Dp = 22.dp, val cellTopPadding: Dp = 6.dp, val cellVerticalSpacing: Dp = 14.dp, + val chevronMargin: Dp = 4.dp, val cityRowPadding: Dp = 34.dp, + val connectButtonHeight: Dp = 50.dp, val countryRowPadding: Dp = 18.dp, val customPortBoxMinWidth: Dp = 80.dp, val expandableCellChevronSize: Dp = 30.dp, @@ -29,9 +32,11 @@ data class Dimensions( val progressIndicatorSize: Dp = 60.dp, val relayCircleSize: Dp = 16.dp, val relayRowPadding: Dp = 50.dp, + val screenVerticalMargin: Dp = 22.dp, val searchFieldHeight: Dp = 42.dp, val searchFieldHorizontalPadding: Dp = 22.dp, val searchIconSize: Dp = 24.dp, + val selectLocationButtonHeight: Dp = 50.dp, val selectLocationTitlePadding: Dp = 12.dp, val selectableCellTextMargin: Dp = 12.dp, val sideMargin: Dp = 22.dp, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/TypeScale.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/TypeScale.kt index 53116ad7e8..41396152c1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/TypeScale.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/TypeScale.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.sp * * Order entries within each type by descending size. */ internal object TypeScale { + val TextHuge = 30.sp val TextBig = 24.sp val TextMediumPlus = 18.sp val TextMedium = 16.sp diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/Typeface.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/Typeface.kt index 71c72bd1af..aadf2601ac 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/Typeface.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/Typeface.kt @@ -26,3 +26,13 @@ val Typography.listItemSubText: TextStyle fontSize = TypeScale.TextSmall ) } + +val Typography.connectionStatus: TextStyle + @Composable + get() { + return TextStyle( + fontWeight = FontWeight.Bold, + letterSpacing = TextUnit.Unspecified, + fontSize = TypeScale.TextMedium + ) + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt index c513c77d8e..c3d58d2ca7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt @@ -7,7 +7,7 @@ import net.mullvad.talpid.tunnel.ActionAfterDisconnect import net.mullvad.talpid.tunnel.ErrorState import net.mullvad.talpid.tunnel.ErrorStateCause -sealed class TunnelState() : Parcelable { +sealed class TunnelState : Parcelable { @Parcelize object Disconnected : TunnelState(), Parcelable @Parcelize @@ -45,33 +45,33 @@ sealed class TunnelState() : Parcelable { fun fromString(description: String, endpoint: TunnelEndpoint?): TunnelState { return when (description) { - DISCONNECTED -> TunnelState.Disconnected - CONNECTING -> TunnelState.Connecting(endpoint, null) - CONNECTED -> TunnelState.Connected(endpoint!!, null) - RECONNECTING -> TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect) - DISCONNECTING -> TunnelState.Disconnecting(ActionAfterDisconnect.Nothing) - BLOCKING -> TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, true)) + DISCONNECTED -> Disconnected + CONNECTING -> Connecting(endpoint, null) + CONNECTED -> Connected(endpoint!!, null) + RECONNECTING -> Disconnecting(ActionAfterDisconnect.Reconnect) + DISCONNECTING -> Disconnecting(ActionAfterDisconnect.Nothing) + BLOCKING -> Error(ErrorState(ErrorStateCause.StartTunnelError, true)) ERROR -> { - TunnelState.Error(ErrorState(ErrorStateCause.SetFirewallPolicyError, false)) + Error(ErrorState(ErrorStateCause.SetFirewallPolicyError, false)) } - else -> TunnelState.Error(ErrorState(ErrorStateCause.SetFirewallPolicyError, false)) + else -> Error(ErrorState(ErrorStateCause.SetFirewallPolicyError, false)) } } } override fun toString(): String = when (this) { - is TunnelState.Disconnected -> DISCONNECTED - is TunnelState.Connecting -> CONNECTING - is TunnelState.Connected -> CONNECTED - is TunnelState.Disconnecting -> { + is Disconnected -> DISCONNECTED + is Connecting -> CONNECTING + is Connected -> CONNECTED + is Disconnecting -> { if (actionAfterDisconnect == ActionAfterDisconnect.Reconnect) { RECONNECTING } else { DISCONNECTING } } - is TunnelState.Error -> { + is Error -> { if (errorState.isBlocking) { BLOCKING } else { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectActionButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectActionButton.kt deleted file mode 100644 index 664eb1bc49..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectActionButton.kt +++ /dev/null @@ -1,134 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -import android.view.View -import android.view.ViewGroup.MarginLayoutParams -import android.widget.Button -import android.widget.ImageButton -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.talpid.tunnel.ActionAfterDisconnect - -class ConnectActionButton(val parentView: View) { - private val mainButton: Button = parentView.findViewById(R.id.action_button) - private val reconnectButton: ImageButton = parentView.findViewById(R.id.reconnect_button) - - private val resources = parentView.context.resources - private val greenBackground = resources.getDrawable(R.drawable.green_button_background, null) - private val leftRedBackground = - resources.getDrawable(R.drawable.transparent_red_left_half_button_background, null) - - private var showReconnectButton = false - set(value) { - if (field != value) { - field = value - updateReconnectButton() - } - } - - private var reconnectButtonSpace = 0 - set(value) { - if (field != value) { - field = value - updateReconnectButton() - } - } - - var tunnelState: TunnelState = TunnelState.Disconnected - set(value) { - when (value) { - is TunnelState.Disconnected -> disconnected() - is TunnelState.Disconnecting -> { - when (value.actionAfterDisconnect) { - ActionAfterDisconnect.Nothing -> disconnected() - ActionAfterDisconnect.Block -> connected() - ActionAfterDisconnect.Reconnect -> connecting() - } - } - is TunnelState.Connecting -> connecting() - is TunnelState.Connected -> connected() - is TunnelState.Error -> { - if (value.errorState.isBlocking) { - connected() - } else { - blockError() - } - } - } - - field = value - } - - var onConnect: (() -> Unit)? = null - var onCancel: (() -> Unit)? = null - var onReconnect: (() -> Unit)? = null - var onDisconnect: (() -> Unit)? = null - - init { - mainButton.setOnClickListener { action() } - reconnectButton.setOnClickListener { onReconnect?.invoke() } - - reconnectButton.addOnLayoutChangeListener { _, left, _, right, _, _, _, _, _ -> - val width = right - left - val layoutParams = reconnectButton.layoutParams - val leftMargin = - when (layoutParams) { - is MarginLayoutParams -> layoutParams.leftMargin - else -> 0 - } - - reconnectButtonSpace = width + leftMargin - } - } - - private fun action() { - val state = tunnelState - - when (state) { - is TunnelState.Disconnected -> onConnect?.invoke() - is TunnelState.Disconnecting -> onConnect?.invoke() - is TunnelState.Connecting -> onCancel?.invoke() - is TunnelState.Connected -> onDisconnect?.invoke() - is TunnelState.Error -> { - if (state.errorState.isBlocking) { - onDisconnect?.invoke() - } else { - onCancel?.invoke() - } - } - } - } - - private fun disconnected() { - mainButton.background = greenBackground - mainButton.setText(R.string.connect) - showReconnectButton = false - } - - private fun connecting() { - redButton(R.string.cancel) - } - - private fun connected() { - redButton(R.string.disconnect) - } - - private fun blockError() { - redButton(R.string.dismiss) - } - - private fun redButton(text: Int) { - mainButton.background = leftRedBackground - mainButton.setText(text) - showReconnectButton = true - } - - private fun updateReconnectButton() { - if (showReconnectButton) { - reconnectButton.visibility = View.VISIBLE - mainButton.setPadding(reconnectButtonSpace, 0, 0, 0) - } else { - reconnectButton.visibility = View.GONE - mainButton.setPadding(0, 0, 0, 0) - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectionStatus.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectionStatus.kt deleted file mode 100644 index f0cf18f65c..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectionStatus.kt +++ /dev/null @@ -1,78 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -import android.content.Context -import android.view.View -import android.widget.TextView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.talpid.tunnel.ActionAfterDisconnect - -class ConnectionStatus(parentView: View, context: Context) { - private val spinner: View = parentView.findViewById(R.id.connecting_spinner) - private val text: TextView = parentView.findViewById(R.id.connection_status) - - private val unsecuredTextColor = context.getColor(R.color.red) - private val connectingTextColor = context.getColor(R.color.white) - private val securedTextColor = context.getColor(R.color.green) - - fun setState(state: TunnelState) { - when (state) { - is TunnelState.Disconnecting -> { - when (state.actionAfterDisconnect) { - ActionAfterDisconnect.Nothing -> disconnected() - ActionAfterDisconnect.Block -> connected(false) - ActionAfterDisconnect.Reconnect -> connecting(false) - } - } - is TunnelState.Disconnected -> disconnected() - is TunnelState.Connecting -> connecting(state.endpoint?.quantumResistant == true) - is TunnelState.Connected -> connected(state.endpoint.quantumResistant) - is TunnelState.Error -> errorState(state.errorState.isBlocking) - } - } - - private fun disconnected() { - spinner.visibility = View.GONE - - text.setTextColor(unsecuredTextColor) - text.setText(R.string.unsecured_connection) - } - - private fun connecting(isQuantumResistant: Boolean) { - spinner.visibility = View.VISIBLE - - text.setTextColor(connectingTextColor) - text.setText( - if (isQuantumResistant) { - R.string.quantum_creating_secure_connection - } else { - R.string.creating_secure_connection - } - ) - } - - private fun connected(isQuantumResistant: Boolean) { - spinner.visibility = View.GONE - - text.setTextColor(securedTextColor) - text.setText( - if (isQuantumResistant) { - R.string.quantum_secure_connection - } else { - R.string.secure_connection - } - ) - } - - private fun errorState(isBlocking: Boolean) { - spinner.visibility = View.GONE - - if (isBlocking) { - text.setTextColor(securedTextColor) - text.setText(R.string.blocked_connection) - } else { - text.setTextColor(unsecuredTextColor) - text.setText(R.string.error_state) - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LocationInfo.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LocationInfo.kt deleted file mode 100644 index be432375cc..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LocationInfo.kt +++ /dev/null @@ -1,146 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -import android.content.Context -import android.view.View -import android.widget.TextView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.model.GeoIpLocation -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.talpid.net.TransportProtocol -import net.mullvad.talpid.net.TunnelEndpoint - -class LocationInfo( - parentView: View, - private val context: Context, - private val onToggleTunnelInfo: () -> Unit -) { - private val hostnameColorCollapsed = context.getColor(R.color.white40) - private val hostnameColorExpanded = context.getColor(R.color.white) - - private val country: TextView = parentView.findViewById(R.id.country) - private val city: TextView = parentView.findViewById(R.id.city) - private val tunnelInfo: View = parentView.findViewById(R.id.tunnel_info) - private val hostname: TextView = parentView.findViewById(R.id.hostname) - private val chevron: View = parentView.findViewById(R.id.chevron) - private val protocol: TextView = parentView.findViewById(R.id.tunnel_protocol) - private val inAddress: TextView = parentView.findViewById(R.id.in_address) - private val outAddress: TextView = parentView.findViewById(R.id.out_address) - - private var tunnelEndpoint: TunnelEndpoint? = null - private var isTunnelInfoVisible = false - - var isTunnelInfoExpanded = false - set(value) { - field = value - updateTunnelInfo() - } - - var location: GeoIpLocation? = null - set(value) { - field = value - - country.text = value?.country ?: "" - city.text = value?.city ?: "" - hostname.text = value?.hostname ?: "" - - updateOutAddress(value) - } - - var state: TunnelState = TunnelState.Disconnected - set(value) { - field = value - - when (value) { - is TunnelState.Connecting -> { - tunnelEndpoint = value.endpoint - isTunnelInfoVisible = true - } - is TunnelState.Connected -> { - tunnelEndpoint = value.endpoint - isTunnelInfoVisible = true - } - else -> { - tunnelEndpoint = null - isTunnelInfoVisible = false - } - } - - updateTunnelInfo() - } - - init { - tunnelInfo.setOnClickListener { onToggleTunnelInfo() } - } - - private fun updateTunnelInfo() { - if (isTunnelInfoVisible) { - showTunnelInfo() - } else { - hideTunnelInfo() - } - } - - private fun hideTunnelInfo() { - chevron.visibility = View.INVISIBLE - - protocol.text = "" - inAddress.text = "" - outAddress.text = "" - } - - private fun showTunnelInfo() { - chevron.visibility = View.VISIBLE - - if (isTunnelInfoExpanded) { - hostname.setTextColor(hostnameColorExpanded) - chevron.rotation = 180.0F - protocol.setText(R.string.wireguard) - showInAddress(tunnelEndpoint) - updateOutAddress(location) - } else { - hostname.setTextColor(hostnameColorCollapsed) - chevron.rotation = 0.0F - protocol.text = "" - inAddress.text = "" - outAddress.text = "" - } - } - - private fun showInAddress(tunnelEndpoint: TunnelEndpoint?) { - if (tunnelEndpoint != null) { - val relayEndpoint = tunnelEndpoint.obfuscation?.endpoint ?: tunnelEndpoint.endpoint - val host = relayEndpoint.address.address.hostAddress - val port = relayEndpoint.address.port - val protocol = - when (relayEndpoint.protocol) { - TransportProtocol.Tcp -> context.getString(R.string.tcp) - TransportProtocol.Udp -> context.getString(R.string.udp) - } - inAddress.text = context.getString(R.string.in_address) + " $host:$port $protocol" - } else { - inAddress.text = "" - } - } - - private fun updateOutAddress(location: GeoIpLocation?) { - val addressAvailable = location != null && (location.ipv4 != null || location.ipv6 != null) - - if (isTunnelInfoVisible && addressAvailable && isTunnelInfoExpanded) { - val ipv4 = location!!.ipv4 - val ipv6 = location.ipv6 - val ipAddress: String - - if (ipv6 == null) { - ipAddress = ipv4!!.hostAddress - } else if (ipv4 == null) { - ipAddress = ipv6.hostAddress - } else { - ipAddress = "${ipv4.hostAddress} / ${ipv6.hostAddress}" - } - - outAddress.text = context.getString(R.string.out_address) + " $ipAddress" - } else { - outAddress.text = "" - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt index 8abf20bb12..4f330cb6be 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt @@ -3,19 +3,19 @@ package net.mullvad.mullvadvpn.ui import android.content.Context import android.util.AttributeSet import android.view.View -import android.widget.ScrollView +import androidx.compose.ui.platform.ComposeView import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior import net.mullvad.mullvadvpn.R class UnderNotificationBannerBehavior(context: Context, attributes: AttributeSet) : - Behavior<ScrollView>(context, attributes) { - override fun layoutDependsOn(parent: CoordinatorLayout, body: ScrollView, dependency: View) = + Behavior<ComposeView>(context, attributes) { + override fun layoutDependsOn(parent: CoordinatorLayout, body: ComposeView, dependency: View) = dependency.id == R.id.notification_banner override fun onDependentViewChanged( parent: CoordinatorLayout, - body: ScrollView, + body: ComposeView, dependency: View ): Boolean { val newPaddingTop = @@ -26,11 +26,11 @@ class UnderNotificationBannerBehavior(context: Context, attributes: AttributeSet } body.getChildAt(0).apply { - if (paddingTop != newPaddingTop) { + return if (paddingTop != newPaddingTop) { setPadding(paddingLeft, newPaddingTop, paddingRight, paddingBottom) - return true + true } else { - return false + false } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt index 86cbd3b49b..725c70eeb6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt @@ -6,6 +6,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -14,24 +16,20 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.screen.ConnectScreen +import net.mullvad.mullvadvpn.compose.theme.AppTheme import net.mullvad.mullvadvpn.constant.BuildTypes import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.ui.ConnectActionButton -import net.mullvad.mullvadvpn.ui.ConnectionStatus -import net.mullvad.mullvadvpn.ui.LocationInfo import net.mullvad.mullvadvpn.ui.NavigationBarPainter -import net.mullvad.mullvadvpn.ui.extension.requireMainActivity import net.mullvad.mullvadvpn.ui.notification.AccountExpiryNotification import net.mullvad.mullvadvpn.ui.notification.TunnelStateNotification import net.mullvad.mullvadvpn.ui.notification.VersionInfoNotification import net.mullvad.mullvadvpn.ui.paintNavigationBar import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.ui.widget.HeaderBar import net.mullvad.mullvadvpn.ui.widget.NotificationBanner -import net.mullvad.mullvadvpn.ui.widget.SwitchLocationButton import net.mullvad.mullvadvpn.util.JobTracker import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.talpid.tunnel.ErrorStateCause @@ -48,12 +46,8 @@ class ConnectFragment : BaseFragment(), NavigationBarPainter { private val tunnelStateNotification: TunnelStateNotification by inject() private val versionInfoNotification: VersionInfoNotification by inject() - private lateinit var actionButton: ConnectActionButton - private lateinit var switchLocationButton: SwitchLocationButton private lateinit var headerBar: HeaderBar private lateinit var notificationBanner: NotificationBanner - private lateinit var status: ConnectionStatus - private lateinit var locationInfo: LocationInfo @Deprecated("Refactor code to instead rely on Lifecycle.") private val jobTracker = JobTracker() @@ -96,24 +90,20 @@ class ConnectFragment : BaseFragment(), NavigationBarPainter { } } - status = ConnectionStatus(view, requireMainActivity()) - - locationInfo = - LocationInfo(view, requireContext()) { connectViewModel.toggleTunnelInfoExpansion() } - - actionButton = ConnectActionButton(view) - - actionButton.apply { - onConnect = { serviceConnectionManager.connectionProxy()?.connect() } - onCancel = { serviceConnectionManager.connectionProxy()?.disconnect() } - onReconnect = { serviceConnectionManager.connectionProxy()?.reconnect() } - onDisconnect = { serviceConnectionManager.connectionProxy()?.disconnect() } - } - - switchLocationButton = - view.findViewById<SwitchLocationButton>(R.id.switch_location).apply { - onClick = { openSwitchLocationScreen() } + view.findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val state = connectViewModel.uiState.collectAsState().value + ConnectScreen( + uiState = state, + onDisconnectClick = connectViewModel::onDisconnectClick, + onReconnectClick = connectViewModel::onReconnectClick, + onConnectClick = connectViewModel::onConnectClick, + onCancelClick = connectViewModel::onCancelClick, + onSwitchLocationClick = { openSwitchLocationScreen() }, + onToggleTunnelInfo = connectViewModel::toggleTunnelInfoExpansion + ) } + } return view } @@ -137,14 +127,11 @@ class ConnectFragment : BaseFragment(), NavigationBarPainter { private fun CoroutineScope.launchViewModelSubscription() = launch { connectViewModel.uiState.collect { uiState -> - locationInfo.location = uiState.location - switchLocationButton.location = uiState.relayLocation uiState.versionInfo?.let { versionInfoNotification.updateVersionInfo(uiState.versionInfo) } tunnelStateNotification.updateTunnelState(uiState.tunnelUiState) - updateTunnelState(uiState.tunnelUiState, uiState.tunnelRealState) - locationInfo.isTunnelInfoExpanded = uiState.isTunnelInfoExpanded + updateTunnelState(uiState.tunnelRealState) } } @@ -154,13 +141,8 @@ class ConnectFragment : BaseFragment(), NavigationBarPainter { } } - private fun updateTunnelState(uiState: TunnelState, realState: TunnelState) { - locationInfo.state = realState + private fun updateTunnelState(realState: TunnelState) { headerBar.tunnelState = realState - status.setState(realState) - - actionButton.tunnelState = uiState - switchLocationButton.tunnelState = uiState if (realState.isTunnelErrorStateDueToExpiredAccount()) { openOutOfTimeScreen() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt index 33a1ffb4e9..8cb33586d1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt @@ -14,7 +14,7 @@ import net.mullvad.mullvadvpn.util.trySendRequest import net.mullvad.talpid.tunnel.ActionAfterDisconnect import net.mullvad.talpid.util.EventNotifier -val ANTICIPATED_STATE_TIMEOUT_MS = 1500L +const val ANTICIPATED_STATE_TIMEOUT_MS = 1500L class ConnectionProxy(private val connection: Messenger, eventDispatcher: EventDispatcher) { private var resetAnticipatedStateJob: Job? = null diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SwitchLocationButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SwitchLocationButton.kt deleted file mode 100644 index 02ec153f0a..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SwitchLocationButton.kt +++ /dev/null @@ -1,89 +0,0 @@ -package net.mullvad.mullvadvpn.ui.widget - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.FrameLayout -import android.widget.TextView -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.relaylist.RelayItem -import net.mullvad.talpid.tunnel.ActionAfterDisconnect - -class SwitchLocationButton : FrameLayout { - private val container = - context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> - val inflater = service as LayoutInflater - - inflater.inflate(R.layout.switch_location_button, this) - } - - private val buttonWithLabel = - container.findViewById<View>(R.id.button_with_label).apply { - setOnClickListener { onClick?.invoke() } - } - - private val buttonWithLocation = - container.findViewById<TextView>(R.id.button_with_location).apply { - setOnClickListener { onClick?.invoke() } - } - - var onClick: (() -> Unit)? = null - - var location by - observable<RelayItem?>(null) { _, _, location -> - buttonWithLocation.text = location?.locationName ?: "" - } - - var tunnelState by - observable<TunnelState>(TunnelState.Disconnected) { _, _, state -> - when (state) { - is TunnelState.Disconnected -> showLocation() - is TunnelState.Disconnecting -> { - when (state.actionAfterDisconnect) { - ActionAfterDisconnect.Nothing -> showLocation() - ActionAfterDisconnect.Block -> showLocation() - ActionAfterDisconnect.Reconnect -> showLabel() - } - } - is TunnelState.Connecting -> showLabel() - is TunnelState.Connected -> showLabel() - is TunnelState.Error -> showLocation() - } - } - - constructor(context: Context) : super(context) - - constructor(context: Context, attributes: AttributeSet) : super(context, attributes) - - constructor( - context: Context, - attributes: AttributeSet, - defaultStyleAttribute: Int - ) : super(context, attributes, defaultStyleAttribute) - - private fun showLabel() { - updateButton(buttonWithLabel, true) - updateButton(buttonWithLocation, false) - } - - private fun showLocation() { - updateButton(buttonWithLabel, false) - updateButton(buttonWithLocation, true) - } - - private fun updateButton(button: View, show: Boolean) { - button.apply { - setEnabled(show) - - visibility = - if (show) { - View.VISIBLE - } else { - View.INVISIBLE - } - } - } -} 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 new file mode 100644 index 0000000000..dcde072970 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GeoIpLocationExtensions.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.util + +import net.mullvad.mullvadvpn.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 new file mode 100644 index 0000000000..d39104e67a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.util + +import net.mullvad.talpid.net.TransportProtocol +import net.mullvad.talpid.net.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 fb2702628d..826376ad9f 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 @@ -23,11 +23,16 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.util.appVersionCallbackFlow import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import net.mullvad.mullvadvpn.util.combine +import net.mullvad.mullvadvpn.util.toInAddress +import net.mullvad.mullvadvpn.util.toOutAddress +import net.mullvad.talpid.tunnel.ActionAfterDisconnect -class ConnectViewModel(serviceConnectionManager: ServiceConnectionManager) : ViewModel() { +class ConnectViewModel(private val serviceConnectionManager: ServiceConnectionManager) : + ViewModel() { private val _shared: SharedFlow<ServiceConnectionContainer> = serviceConnectionManager.connectionState .flatMapLatest { state -> @@ -64,7 +69,28 @@ class ConnectViewModel(serviceConnectionManager: ServiceConnectionManager) : Vie versionInfo = versionInfo, tunnelUiState = tunnelUiState, tunnelRealState = tunnelRealState, - isTunnelInfoExpanded = isTunnelInfoExpanded + isTunnelInfoExpanded = isTunnelInfoExpanded, + inAddress = + when (tunnelRealState) { + is TunnelState.Connected -> tunnelRealState.endpoint.toInAddress() + is TunnelState.Connecting -> tunnelRealState.endpoint?.toInAddress() + else -> null + }, + outAddress = location?.toOutAddress() ?: "", + showLocation = + when (tunnelUiState) { + is TunnelState.Disconnected -> true + is TunnelState.Disconnecting -> { + when (tunnelUiState.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> true + ActionAfterDisconnect.Block -> true + ActionAfterDisconnect.Reconnect -> false + } + } + is TunnelState.Connecting -> false + is TunnelState.Connected -> false + is TunnelState.Error -> true + } ) } } @@ -92,6 +118,19 @@ class ConnectViewModel(serviceConnectionManager: ServiceConnectionManager) : Vie _isTunnelInfoExpanded.value = _isTunnelInfoExpanded.value.not() } + fun onDisconnectClick() { + serviceConnectionManager.connectionProxy()?.disconnect() + } + fun onReconnectClick() { + serviceConnectionManager.connectionProxy()?.reconnect() + } + fun onConnectClick() { + serviceConnectionManager.connectionProxy()?.connect() + } + fun onCancelClick() { + serviceConnectionManager.connectionProxy()?.disconnect() + } + companion object { const val TUNNEL_STATE_UPDATE_DEBOUNCE_DURATION_MILLIS: Long = 200 } diff --git a/android/app/src/main/res/drawable/icon_chevron_expand.xml b/android/app/src/main/res/drawable/icon_chevron_expand.xml deleted file mode 100644 index f85e172a00..0000000000 --- a/android/app/src/main/res/drawable/icon_chevron_expand.xml +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<rotate xmlns:android="http://schemas.android.com/apk/res/android" - android:fromDegrees="90" - android:toDegrees="90" - android:pivotX="50%" - android:pivotY="50%" - android:drawable="@drawable/icon_chevron" /> diff --git a/android/app/src/main/res/drawable/transparent_red_left_half_button_background.xml b/android/app/src/main/res/drawable/transparent_red_left_half_button_background.xml deleted file mode 100644 index dab41c1f57..0000000000 --- a/android/app/src/main/res/drawable/transparent_red_left_half_button_background.xml +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<selector xmlns:android="http://schemas.android.com/apk/res/android"> - <item android:state_pressed="false" - android:state_focused="false"> - <shape android:shape="rectangle"> - <corners android:topLeftRadius="4dp" - android:bottomLeftRadius="4dp" /> - <solid android:color="@color/red60" /> - </shape> - </item> - <item android:state_pressed="false" - android:state_focused="true"> - <shape android:shape="rectangle"> - <corners android:topLeftRadius="4dp" - android:bottomLeftRadius="4dp" /> - <solid android:color="@color/red95" /> - </shape> - </item> - <item android:state_pressed="true"> - <shape android:shape="rectangle"> - <corners android:topLeftRadius="4dp" - android:bottomLeftRadius="4dp" /> - <solid android:color="@color/red80" /> - </shape> - </item> -</selector> diff --git a/android/app/src/main/res/drawable/transparent_red_right_half_button_background.xml b/android/app/src/main/res/drawable/transparent_red_right_half_button_background.xml deleted file mode 100644 index f23bde9841..0000000000 --- a/android/app/src/main/res/drawable/transparent_red_right_half_button_background.xml +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<selector xmlns:android="http://schemas.android.com/apk/res/android"> - <item android:state_pressed="false" - android:state_focused="false"> - <shape android:shape="rectangle"> - <corners android:topRightRadius="4dp" - android:bottomRightRadius="4dp" /> - <solid android:color="@color/red60" /> - </shape> - </item> - <item android:state_pressed="false" - android:state_focused="true"> - <shape android:shape="rectangle"> - <corners android:topRightRadius="4dp" - android:bottomRightRadius="4dp" /> - <solid android:color="@color/red95" /> - </shape> - </item> - <item android:state_pressed="true"> - <shape android:shape="rectangle"> - <corners android:topRightRadius="4dp" - android:bottomRightRadius="4dp" /> - <solid android:color="@color/red80" /> - </shape> - </item> -</selector> diff --git a/android/app/src/main/res/drawable/white20_button_background.xml b/android/app/src/main/res/drawable/white20_button_background.xml deleted file mode 100644 index f52c7cf182..0000000000 --- a/android/app/src/main/res/drawable/white20_button_background.xml +++ /dev/null @@ -1,23 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<selector xmlns:android="http://schemas.android.com/apk/res/android"> - <item android:state_pressed="false" - android:state_focused="false"> - <shape android:shape="rectangle"> - <corners android:radius="4dp" /> - <solid android:color="@color/white20" /> - </shape> - </item> - <item android:state_pressed="false" - android:state_focused="true"> - <shape android:shape="rectangle"> - <corners android:radius="4dp" /> - <solid android:color="@color/white60" /> - </shape> - </item> - <item android:state_pressed="true"> - <shape android:shape="rectangle"> - <corners android:radius="4dp" /> - <solid android:color="@color/white40" /> - </shape> - </item> -</selector> diff --git a/android/app/src/main/res/layout/connect.xml b/android/app/src/main/res/layout/connect.xml index 595832397e..9db162a667 100644 --- a/android/app/src/main/res/layout/connect.xml +++ b/android/app/src/main/res/layout/connect.xml @@ -1,7 +1,7 @@ <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto" android:orientation="vertical"> <net.mullvad.mullvadvpn.ui.widget.HeaderBar android:id="@+id/header_bar" android:layout_width="match_parent" @@ -13,139 +13,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:elevation="0.25dp" /> - <ScrollView android:id="@+id/body" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:fillViewport="true" - app:layout_behavior="net.mullvad.mullvadvpn.ui.UnderNotificationBannerBehavior"> - - <LinearLayout android:layout_width="match_parent" - android:layout_height="match_parent" - android:gravity="bottom" - android:orientation="vertical"> - <ProgressBar android:id="@+id/connecting_spinner" - android:layout_width="60dp" - android:layout_height="60dp" - android:layout_gravity="center" - android:layout_marginBottom="7dp" - android:indeterminate="true" - android:indeterminateDrawable="@drawable/icon_spinner" - android:indeterminateDuration="600" - android:indeterminateOnly="true" - android:visibility="invisible" /> - <LinearLayout android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="7dp" - android:layout_weight="0" - android:gravity="start" - android:orientation="vertical"> - <TextView android:id="@+id/connection_status" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginHorizontal="@dimen/side_margin" - android:layout_marginBottom="2dp" - android:text="@string/unsecured_connection" - android:textAllCaps="true" - android:textColor="@color/red" - android:textSize="@dimen/text_medium" - android:textStyle="bold" /> - <TextView android:id="@+id/country" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginHorizontal="@dimen/side_margin" - android:text="" - android:textColor="@color/white" - android:textSize="@dimen/text_huge" - android:textStyle="bold" /> - <TextView android:id="@+id/city" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginHorizontal="@dimen/side_margin" - android:layout_marginBottom="2dp" - android:text="" - android:textColor="@color/white" - android:textSize="@dimen/text_huge" - android:textStyle="bold" /> - <LinearLayout android:id="@+id/tunnel_info" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_weight="0" - android:background="?android:attr/selectableItemBackground" - android:clickable="true" - android:focusable="true" - android:gravity="bottom" - android:orientation="vertical" - android:paddingHorizontal="@dimen/side_margin"> - <LinearLayout android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:gravity="center" - android:orientation="horizontal"> - <TextView android:id="@+id/hostname" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="" - android:textColor="@color/white40" - android:textSize="@dimen/text_hostname" /> - <ImageView android:id="@+id/chevron" - android:layout_width="24dp" - android:layout_height="24dp" - android:layout_marginHorizontal="5dp" - android:alpha="0.4" - android:src="@drawable/icon_chevron_expand" /> - </LinearLayout> - <TextView android:id="@+id/tunnel_protocol" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="1dp" - android:text="" - android:textColor="@color/white" - android:textSize="@dimen/text_small" /> - <TextView android:id="@+id/in_address" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="1dp" - android:text="" - android:textColor="@color/white" - android:textSize="@dimen/text_small" /> - <TextView android:id="@+id/out_address" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="1dp" - android:text="" - android:textColor="@color/white" - android:textSize="@dimen/text_small" /> - </LinearLayout> - </LinearLayout> - <LinearLayout android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_weight="0" - android:orientation="vertical" - android:paddingHorizontal="@dimen/side_margin" - android:paddingTop="@dimen/button_separation" - android:paddingBottom="@dimen/screen_vertical_margin"> - <net.mullvad.mullvadvpn.ui.widget.SwitchLocationButton android:id="@+id/switch_location" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/button_separation" /> - <LinearLayout android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal"> - <Button android:id="@+id/action_button" - style="@style/GreenButton" - android:layout_weight="1" - android:text="@string/connect" /> - <ImageButton android:id="@+id/reconnect_button" - android:layout_width="50dp" - android:layout_height="match_parent" - android:layout_marginLeft="1dp" - android:layout_weight="0" - android:background="@drawable/transparent_red_right_half_button_background" - android:padding="9dp" - android:src="@drawable/icon_reload" - android:visibility="gone" /> - </LinearLayout> - </LinearLayout> - </LinearLayout> - </ScrollView> + <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" + app:layout_behavior="net.mullvad.mullvadvpn.ui.UnderNotificationBannerBehavior" + android:layout_width="match_parent" + android:layout_height="match_parent" /> </androidx.coordinatorlayout.widget.CoordinatorLayout> </LinearLayout> diff --git a/android/app/src/main/res/layout/switch_location_button.xml b/android/app/src/main/res/layout/switch_location_button.xml deleted file mode 100644 index d9ed79956f..0000000000 --- a/android/app/src/main/res/layout/switch_location_button.xml +++ /dev/null @@ -1,14 +0,0 @@ -<merge xmlns:android="http://schemas.android.com/apk/res/android"> - <Button android:id="@+id/button_with_label" - android:layout_gravity="bottom" - android:paddingHorizontal="9dp" - android:text="@string/switch_location" - style="@style/White20Button" /> - <Button android:id="@+id/button_with_location" - android:layout_gravity="bottom" - android:paddingHorizontal="9dp" - android:text="@string/switch_location" - android:drawableRight="@drawable/icon_chevron" - android:visibility="invisible" - style="@style/White20Button" /> -</merge> diff --git a/android/app/src/main/res/values/dimensions.xml b/android/app/src/main/res/values/dimensions.xml index 7c55e8f310..9928c3c759 100644 --- a/android/app/src/main/res/values/dimensions.xml +++ b/android/app/src/main/res/values/dimensions.xml @@ -25,7 +25,6 @@ <dimen name="chevron_width">14dp</dimen> <dimen name="chevron_height">24dp</dimen> <dimen name="text_small">13sp</dimen> - <dimen name="text_hostname">15sp</dimen> <dimen name="text_medium">16sp</dimen> <dimen name="text_medium_plus">18sp</dimen> <dimen name="text_big">24sp</dimen> diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index dab9229592..e03dd86700 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -38,9 +38,6 @@ <style name="BlueButton" parent="Button"> <item name="android:background">@drawable/blue_button_background</item> </style> - <style name="White20Button" parent="Button"> - <item name="android:background">@drawable/white20_button_background</item> - </style> <style name="SettingsHeader"> <item name="android:textColor">@color/white</item> <item name="android:textStyle">bold</item> 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 8e2bd28c52..5b1ee8e7e1 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 @@ -7,6 +7,7 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkAll +import io.mockk.verify import kotlin.test.assertEquals import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +26,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.util.appVersionCallbackFlow import net.mullvad.talpid.util.EventNotifier import org.junit.After @@ -68,6 +70,7 @@ class ConnectViewModelTest { @Before fun setup() { mockkStatic(CACHE_EXTENSION_CLASS) + mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) mockAppVersionInfoCache = mockk<AppVersionInfoCache>().apply { @@ -110,7 +113,7 @@ class ConnectViewModelTest { assertEquals(ConnectUiState.INITIAL, awaitItem()) serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockk()) + locationSlot.captured.invoke(mockk(relaxed = true)) relaySlot.captured.invoke(mockk(), mockk()) viewModel.toggleTunnelInfoExpansion() val result = awaitItem() @@ -121,13 +124,13 @@ class ConnectViewModelTest { @Test fun testTunnelRealStateUpdate() = runTest(testCoroutineRule.testDispatcher) { - val tunnelRealStateTestItem = TunnelState.Connected(mockk(), mockk()) + val tunnelRealStateTestItem = TunnelState.Connected(mockk(relaxed = true), mockk()) viewModel.uiState.test { assertEquals(ConnectUiState.INITIAL, awaitItem()) serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockk()) + locationSlot.captured.invoke(mockk(relaxed = true)) relaySlot.captured.invoke(mockk(), mockk()) eventNotifierTunnelRealState.notify(tunnelRealStateTestItem) val result = awaitItem() @@ -144,7 +147,7 @@ class ConnectViewModelTest { assertEquals(ConnectUiState.INITIAL, awaitItem()) serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockk()) + locationSlot.captured.invoke(mockk(relaxed = true)) relaySlot.captured.invoke(mockk(), mockk()) eventNotifierTunnelUiState.notify(tunnelUiStateTestItem) val result = awaitItem() @@ -167,7 +170,7 @@ class ConnectViewModelTest { assertEquals(ConnectUiState.INITIAL, awaitItem()) serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockk()) + locationSlot.captured.invoke(mockk(relaxed = true)) relaySlot.captured.invoke(mockk(), mockk()) versionInfo.value = versionInfoTestItem val result = awaitItem() @@ -185,7 +188,7 @@ class ConnectViewModelTest { assertEquals(ConnectUiState.INITIAL, awaitItem()) serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockk()) + locationSlot.captured.invoke(mockk(relaxed = true)) relaySlot.captured.invoke(mockk(), relayTestItem) val result = awaitItem() assertEquals(relayTestItem, result.relayLocation) @@ -197,8 +200,8 @@ class ConnectViewModelTest { runTest(testCoroutineRule.testDispatcher) { val locationTestItem = GeoIpLocation( - ipv4 = mockk(), - ipv6 = mockk(), + ipv4 = mockk(relaxed = true), + ipv6 = mockk(relaxed = true), country = "Sweden", city = "Gothenburg", hostname = "Host" @@ -215,7 +218,45 @@ class ConnectViewModelTest { } } + @Test + fun testOnDisconnectClick() = + runTest(testCoroutineRule.testDispatcher) { + val mockConnectionProxy: ConnectionProxy = mockk(relaxed = true) + every { mockServiceConnectionManager.connectionProxy() } returns mockConnectionProxy + viewModel.onDisconnectClick() + verify { mockConnectionProxy.disconnect() } + } + + @Test + fun testOnReconnectClick() = + runTest(testCoroutineRule.testDispatcher) { + val mockConnectionProxy: ConnectionProxy = mockk(relaxed = true) + every { mockServiceConnectionManager.connectionProxy() } returns mockConnectionProxy + viewModel.onReconnectClick() + verify { mockConnectionProxy.reconnect() } + } + + @Test + fun testOnConnectClick() = + runTest(testCoroutineRule.testDispatcher) { + val mockConnectionProxy: ConnectionProxy = mockk(relaxed = true) + every { mockServiceConnectionManager.connectionProxy() } returns mockConnectionProxy + viewModel.onConnectClick() + verify { mockConnectionProxy.connect() } + } + + @Test + fun testOnCancelClick() = + runTest(testCoroutineRule.testDispatcher) { + val mockConnectionProxy: ConnectionProxy = mockk(relaxed = true) + every { mockServiceConnectionManager.connectionProxy() } returns mockConnectionProxy + viewModel.onCancelClick() + verify { mockConnectionProxy.disconnect() } + } + companion object { private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt" + private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = + "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt" } } |
