summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-07-14 11:37:24 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-07-14 11:37:24 +0200
commit0836eb0babb61baa95b2cb5bd3bdb12647fc5df7 (patch)
tree4d3b43e32de8277d3f2eb3ae43e2cf8a4be4a041
parent50a01d641fe081fafb648132e56476e9a320053e (diff)
parentb82f65d4b19f4e534cdd69e8ead7f79aa176c6b2 (diff)
downloadmullvadvpn-0836eb0babb61baa95b2cb5bd3bdb12647fc5df7.tar.xz
mullvadvpn-0836eb0babb61baa95b2cb5bd3bdb12647fc5df7.zip
Merge branch 'migrate-connect-fragment-main-screen-to-compose-droid-188'
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt581
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ActionButton.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Button.kt)22
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt180
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SwitchLocationButton.kt66
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Chevron.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt94
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt119
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt134
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/TypeScale.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/typeface/Typeface.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt28
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectActionButton.kt134
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectionStatus.kt78
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LocationInfo.kt146
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt56
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SwitchLocationButton.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GeoIpLocationExtensions.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt43
-rw-r--r--android/app/src/main/res/drawable/icon_chevron_expand.xml7
-rw-r--r--android/app/src/main/res/drawable/transparent_red_left_half_button_background.xml26
-rw-r--r--android/app/src/main/res/drawable/transparent_red_right_half_button_background.xml26
-rw-r--r--android/app/src/main/res/drawable/white20_button_background.xml23
-rw-r--r--android/app/src/main/res/layout/connect.xml140
-rw-r--r--android/app/src/main/res/layout/switch_location_button.xml14
-rw-r--r--android/app/src/main/res/values/dimensions.xml1
-rw-r--r--android/app/src/main/res/values/styles.xml3
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt57
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"
}
}