diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-09-06 10:52:30 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-09-06 10:52:30 +0200 |
| commit | cfc1aa9b12de7accc86e7e65606058d11ab76102 (patch) | |
| tree | 3660af744574f9dfc59c0f5098ed9132a5be4bbc | |
| parent | 0447c6013428e386fbbfe619318dfd605246bd43 (diff) | |
| parent | ed325f028e55232f6aa86c7a2bc1d271f5ba0e93 (diff) | |
| download | mullvadvpn-cfc1aa9b12de7accc86e7e65606058d11ab76102.tar.xz mullvadvpn-cfc1aa9b12de7accc86e7e65606058d11ab76102.zip | |
Merge branch 'migrate-notificationbanner-to-compose-droid-187'
31 files changed, 1003 insertions, 742 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index b0ca160245..42f9dce3ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Line wrap the file at 100 chars. Th ### Changed #### Android - Migrate welcome view to compose. +- Migrate in app notifications to compose. #### Linux - Don't block forwarding of traffic when the split tunnel mark (ct mark) is set. diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index 083443b259..67e53da747 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -9,20 +9,29 @@ import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import net.mullvad.mullvadvpn.compose.state.ConnectNotificationState 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.NOTIFICATION_BANNER import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.model.GeoIpLocation import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel 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.joda.time.DateTime import org.junit.After import org.junit.Before import org.junit.Rule @@ -44,10 +53,16 @@ class ConnectScreenTest { @Test fun testDefaultState() { // Arrange - composeTestRule.setContent { ConnectScreen(uiState = ConnectUiState.INITIAL) } + composeTestRule.setContent { + ConnectScreen( + uiState = ConnectUiState.INITIAL, + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() + ) + } // Assert composeTestRule.apply { + onNodeWithTag(SCROLLABLE_COLUMN_TEST_TAG).assertExists() onNodeWithText("UNSECURED CONNECTION").assertExists() onNodeWithText("Secure my connection").assertExists() } @@ -62,14 +77,16 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Connecting(null, null), tunnelRealState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false - ) + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowTunnelStateNotificationBlocked + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -79,6 +96,7 @@ class ConnectScreenTest { onNodeWithText("CREATING SECURE CONNECTION").assertExists() onNodeWithText("Switch location").assertExists() onNodeWithText("Cancel").assertExists() + onNodeWithText("BLOCKING INTERNET").assertExists() } } @@ -93,15 +111,17 @@ class ConnectScreenTest { 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 - ) + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowTunnelStateNotificationBlocked + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -111,6 +131,7 @@ class ConnectScreenTest { onNodeWithText("CREATING QUANTUM SECURE CONNECTION").assertExists() onNodeWithText("Switch location").assertExists() onNodeWithText("Cancel").assertExists() + onNodeWithText("BLOCKING INTERNET").assertExists() } } @@ -124,14 +145,15 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false - ) + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -154,14 +176,15 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false - ) + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -185,14 +208,15 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = mockRelayLocation, - versionInfo = null, tunnelUiState = TunnelState.Disconnecting(ActionAfterDisconnect.Nothing), tunnelRealState = TunnelState.Disconnecting(ActionAfterDisconnect.Nothing), inAddress = null, outAddress = "", showLocation = true, - isTunnelInfoExpanded = false - ) + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -216,14 +240,15 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = mockRelayLocation, - versionInfo = null, tunnelUiState = TunnelState.Disconnected, tunnelRealState = TunnelState.Disconnected, inAddress = null, outAddress = "", showLocation = true, - isTunnelInfoExpanded = false - ) + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -247,7 +272,6 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = mockRelayLocation, - versionInfo = null, tunnelUiState = TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, true)), tunnelRealState = @@ -255,8 +279,13 @@ class ConnectScreenTest { inAddress = null, outAddress = "", showLocation = true, - isTunnelInfoExpanded = false - ) + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowTunnelStateNotificationError( + ErrorState(ErrorStateCause.StartTunnelError, true) + ) + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -265,6 +294,7 @@ class ConnectScreenTest { onNodeWithText("BLOCKED CONNECTION").assertExists() onNodeWithText(mockLocationName).assertExists() onNodeWithText("Disconnect").assertExists() + onNodeWithText("BLOCKING INTERNET").assertExists() } } @@ -280,7 +310,6 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = mockRelayLocation, - versionInfo = null, tunnelUiState = TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, false)), tunnelRealState = @@ -288,8 +317,13 @@ class ConnectScreenTest { inAddress = null, outAddress = "", showLocation = true, - isTunnelInfoExpanded = false - ) + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowTunnelStateNotificationError( + ErrorState(ErrorStateCause.StartTunnelError, false) + ) + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -298,6 +332,8 @@ class ConnectScreenTest { onNodeWithText("FAILED TO SECURE CONNECTION").assertExists() onNodeWithText(mockLocationName).assertExists() onNodeWithText("Dismiss").assertExists() + onNodeWithText(text = "Critical error (your attention is required)", ignoreCase = true) + .assertExists() } } @@ -310,15 +346,17 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect), tunnelRealState = TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect), inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false - ) + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowTunnelStateNotificationBlocked + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -328,6 +366,7 @@ class ConnectScreenTest { onNodeWithText("CREATING SECURE CONNECTION").assertExists() onNodeWithText("Switch location").assertExists() onNodeWithText("Disconnect").assertExists() + onNodeWithText("BLOCKING INTERNET").assertExists() } } @@ -343,14 +382,16 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = mockRelayLocation, - versionInfo = null, tunnelUiState = TunnelState.Disconnecting(ActionAfterDisconnect.Block), tunnelRealState = TunnelState.Disconnecting(ActionAfterDisconnect.Block), inAddress = null, outAddress = "", showLocation = true, - isTunnelInfoExpanded = false - ) + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowTunnelStateNotificationBlocked + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -359,6 +400,7 @@ class ConnectScreenTest { onNodeWithText("SECURE CONNECTION").assertExists() onNodeWithText(mockLocationName).assertExists() onNodeWithText("Disconnect").assertExists() + onNodeWithText("BLOCKING INTERNET").assertExists() } } @@ -375,14 +417,15 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = mockRelayLocation, - versionInfo = null, tunnelUiState = TunnelState.Disconnected, tunnelRealState = TunnelState.Disconnected, inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow(), onSwitchLocationClick = mockedClickHandler ) } @@ -405,14 +448,15 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow(), onDisconnectClick = mockedClickHandler ) } @@ -435,14 +479,15 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow(), onReconnectClick = mockedClickHandler ) } @@ -464,14 +509,15 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Disconnected, tunnelRealState = TunnelState.Disconnected, inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow(), onConnectClick = mockedClickHandler ) } @@ -493,14 +539,15 @@ class ConnectScreenTest { ConnectUiState( location = null, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Connecting(null, null), tunnelRealState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow(), onCancelClick = mockedClickHandler ) } @@ -523,14 +570,15 @@ class ConnectScreenTest { ConnectUiState( location = dummyLocation, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Connecting(null, null), tunnelRealState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow(), onToggleTunnelInfo = mockedClickHandler ) } @@ -560,14 +608,15 @@ class ConnectScreenTest { 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 - ) + isTunnelInfoExpanded = true, + connectNotificationState = ConnectNotificationState.HideNotification + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() ) } @@ -579,4 +628,196 @@ class ConnectScreenTest { onNodeWithText("Out $mockOutAddress").assertExists() } } + + @Test + fun testOutdatedVersionNotification() { + // Arrange + val versionInfo = + VersionInfo( + currentVersion = "1.0", + upgradeVersion = "1.1", + isOutdated = true, + isSupported = true + ) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + tunnelUiState = TunnelState.Connecting(null, null), + tunnelRealState = TunnelState.Connecting(null, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("UPDATE AVAILABLE").assertExists() + onNodeWithText("Install Mullvad VPN (1.1) to stay up to date").assertExists() + } + } + + @Test + fun testUnsupportedVersionNotification() { + // Arrange + val versionInfo = + VersionInfo( + currentVersion = "1.0", + upgradeVersion = "1.1", + isOutdated = true, + isSupported = false + ) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + tunnelUiState = TunnelState.Connecting(null, null), + tunnelRealState = TunnelState.Connecting(null, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("UNSUPPORTED VERSION").assertExists() + onNodeWithText( + "Your privacy might be at risk with this unsupported app version. Please update now." + ) + .assertExists() + } + } + + @Test + fun testAccountExpiredNotification() { + // Arrange + val expiryDate = DateTime(2020, 11, 11, 10, 10) + composeTestRule.setContent { + ConnectScreen( + uiState = + ConnectUiState( + location = null, + relayLocation = null, + tunnelUiState = TunnelState.Connecting(null, null), + tunnelRealState = TunnelState.Connecting(null, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowAccountExpiryNotification(expiryDate) + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("ACCOUNT CREDIT EXPIRES SOON").assertExists() + onNodeWithText("Out of time").assertExists() + } + } + + @Test + fun testOnUpdateVersionClick() { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + val versionInfo = + VersionInfo( + currentVersion = "1.0", + upgradeVersion = "1.1", + isOutdated = true, + isSupported = false + ) + composeTestRule.setContent { + ConnectScreen( + onUpdateVersionClick = mockedClickHandler, + uiState = + ConnectUiState( + location = null, + relayLocation = null, + tunnelUiState = TunnelState.Connecting(null, null), + tunnelRealState = TunnelState.Connecting(null, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() + ) + } + + // Act + composeTestRule.onNodeWithTag(NOTIFICATION_BANNER).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun testOnShowAccountClick() { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + val expiryDate = DateTime(2020, 11, 11, 10, 10) + composeTestRule.setContent { + ConnectScreen( + onManageAccountClick = mockedClickHandler, + uiState = + ConnectUiState( + location = null, + relayLocation = null, + tunnelUiState = TunnelState.Connecting(null, null), + tunnelRealState = TunnelState.Connecting(null, null), + inAddress = null, + outAddress = "", + showLocation = false, + isTunnelInfoExpanded = false, + connectNotificationState = + ConnectNotificationState.ShowAccountExpiryNotification(expiryDate) + ), + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() + ) + } + + // Act + composeTestRule.onNodeWithTag(NOTIFICATION_BANNER).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun testOpenAccountView() { + // Arrange + composeTestRule.setContent { + ConnectScreen( + uiState = ConnectUiState.INITIAL, + viewActions = + MutableStateFlow( + ConnectViewModel.ViewAction.OpenAccountManagementPageInBrowser("222") + ) + ) + } + + // Assert + composeTestRule.apply { onNodeWithTag(SCROLLABLE_COLUMN_TEST_TAG).assertDoesNotExist() } + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt index 19efbde9ca..2a944f2cdd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt @@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.compose.cell import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth 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 @@ -18,12 +19,14 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.lib.theme.MullvadHelmetYellow +import net.mullvad.mullvadvpn.lib.theme.AppTheme @Preview @Composable private fun PreviewDnsCell() { - DnsCell(address = "0.0.0.0", isUnreachableLocalDnsWarningVisible = true, onClick = {}) + AppTheme { + DnsCell(address = "0.0.0.0", isUnreachableLocalDnsWarningVisible = true, onClick = {}) + } } @Composable @@ -43,7 +46,7 @@ fun DnsCell( Icon( painter = painterResource(id = R.drawable.icon_alert), contentDescription = stringResource(id = R.string.confirm_local_dns), - tint = MullvadHelmetYellow + tint = MaterialTheme.colorScheme.errorContainer ) } }, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt index 2f5374a3c1..fa896b2c9a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt @@ -3,6 +3,7 @@ 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.Modifier import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -27,33 +28,42 @@ fun PreviewConnectionStatusText() { } @Composable -fun ConnectionStatusText(state: TunnelState) { +fun ConnectionStatusText(state: TunnelState, modifier: Modifier = Modifier) { when (state) { is TunnelState.Disconnecting -> { when (state.actionAfterDisconnect) { - ActionAfterDisconnect.Nothing -> DisconnectedText() - ActionAfterDisconnect.Block -> ConnectedText(false) - ActionAfterDisconnect.Reconnect -> ConnectingText(false) + ActionAfterDisconnect.Nothing -> DisconnectedText(modifier = modifier) + ActionAfterDisconnect.Block -> + ConnectedText(isQuantumResistant = false, modifier = modifier) + ActionAfterDisconnect.Reconnect -> + ConnectingText(isQuantumResistant = false, modifier = modifier) } } - 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) + is TunnelState.Disconnected -> DisconnectedText(modifier = modifier) + is TunnelState.Connecting -> + ConnectingText( + isQuantumResistant = state.endpoint?.quantumResistant == true, + modifier = modifier + ) + is TunnelState.Connected -> + ConnectedText(isQuantumResistant = state.endpoint.quantumResistant, modifier = modifier) + is TunnelState.Error -> + ErrorText(isBlocking = state.errorState.isBlocking, modifier = modifier) } } @Composable -private fun DisconnectedText() { +private fun DisconnectedText(modifier: Modifier) { Text( text = textResource(id = R.string.unsecured_connection), color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.connectionStatus + style = MaterialTheme.typography.connectionStatus, + modifier = modifier ) } @Composable -private fun ConnectingText(isQuantumResistant: Boolean) { +private fun ConnectingText(isQuantumResistant: Boolean, modifier: Modifier) { Text( text = textResource( @@ -62,12 +72,13 @@ private fun ConnectingText(isQuantumResistant: Boolean) { else R.string.creating_secure_connection ), color = MaterialTheme.colorScheme.onPrimary, - style = MaterialTheme.typography.connectionStatus + style = MaterialTheme.typography.connectionStatus, + modifier = modifier ) } @Composable -private fun ConnectedText(isQuantumResistant: Boolean) { +private fun ConnectedText(isQuantumResistant: Boolean, modifier: Modifier) { Text( text = textResource( @@ -76,12 +87,13 @@ private fun ConnectedText(isQuantumResistant: Boolean) { else R.string.secure_connection ), color = MaterialTheme.colorScheme.surface, - style = MaterialTheme.typography.connectionStatus + style = MaterialTheme.typography.connectionStatus, + modifier = modifier ) } @Composable -private fun ErrorText(isBlocking: Boolean) { +private fun ErrorText(isBlocking: Boolean, modifier: Modifier) { Text( text = textResource( @@ -89,6 +101,7 @@ private fun ErrorText(isBlocking: Boolean) { ), color = if (isBlocking) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.connectionStatus + style = MaterialTheme.typography.connectionStatus, + modifier = modifier ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NotificationBanner.kt new file mode 100644 index 0000000000..f50596eee1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NotificationBanner.kt @@ -0,0 +1,298 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.LocalContext +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.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import net.mullvad.mullvadvpn.BuildConfig +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.extensions.getExpiryQuantityString +import net.mullvad.mullvadvpn.compose.state.ConnectNotificationState +import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER +import net.mullvad.mullvadvpn.compose.util.rememberPrevious +import net.mullvad.mullvadvpn.lib.common.constant.BuildTypes +import net.mullvad.mullvadvpn.lib.common.util.getErrorNotificationResources +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.ui.notification.StatusLevel +import net.mullvad.talpid.tunnel.ErrorState +import org.joda.time.DateTime + +@Preview +@Composable +fun PreviewNotificationBanner() { + AppTheme { + SpacedColumn(Modifier.background(color = MaterialTheme.colorScheme.background)) { + val versionInfoNotification = + versionInfoNotification( + versionInfo = + VersionInfo( + currentVersion = null, + upgradeVersion = null, + isOutdated = true, + isSupported = false + ), + onClickUpdateVersion = {} + ) + NotificationBanner( + title = versionInfoNotification.title, + message = versionInfoNotification.message, + onClick = versionInfoNotification.onClick, + statusLevel = versionInfoNotification.statusLevel + ) + val accountExpiryNotification = + accountExpiryNotification(expiry = DateTime.now(), onClickShowAccount = {}) + NotificationBanner( + title = accountExpiryNotification.title, + message = accountExpiryNotification.message, + statusLevel = accountExpiryNotification.statusLevel, + onClick = accountExpiryNotification.onClick + ) + val genericBlockingMessage = genericBlockingMessage() + NotificationBanner( + title = genericBlockingMessage.title, + message = genericBlockingMessage.message, + onClick = genericBlockingMessage.onClick, + statusLevel = genericBlockingMessage.statusLevel + ) + } + } +} + +@Composable +fun Notification( + connectNotificationState: ConnectNotificationState, + onClickUpdateVersion: () -> Unit, + onClickShowAccount: () -> Unit +) { + val isVisible = connectNotificationState != ConnectNotificationState.HideNotification + // Fix for animating to hide + val lastState: ConnectNotificationState = + rememberPrevious(connectNotificationState) ?: ConnectNotificationState.HideNotification + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(), + exit = slideOutVertically(), + modifier = Modifier.animateContentSize() + ) { + ShowNotification( + connectNotificationState = if (isVisible) connectNotificationState else lastState, + onClickUpdateVersion = onClickUpdateVersion, + onClickShowAccount = onClickShowAccount + ) + } +} + +@Composable +private fun ShowNotification( + connectNotificationState: ConnectNotificationState, + onClickUpdateVersion: () -> Unit, + onClickShowAccount: () -> Unit +) { + val notificationData: NotificationBannerData? = + when (connectNotificationState) { + ConnectNotificationState.ShowTunnelStateNotificationBlocked -> { + genericBlockingMessage() + } + is ConnectNotificationState.ShowTunnelStateNotificationError -> { + errorMessage(error = connectNotificationState.error) + } + is ConnectNotificationState.ShowVersionInfoNotification -> { + versionInfoNotification( + versionInfo = connectNotificationState.versionInfo, + onClickUpdateVersion = + if (BuildConfig.BUILD_TYPE != BuildTypes.RELEASE) { + onClickUpdateVersion + } else { + null + } + ) + } + is ConnectNotificationState.ShowAccountExpiryNotification -> { + accountExpiryNotification( + expiry = connectNotificationState.expiry, + onClickShowAccount = + if (BuildConfig.BUILD_TYPE != BuildTypes.RELEASE) { + onClickShowAccount + } else { + null + } + ) + } + is ConnectNotificationState.HideNotification -> { + // Hide notification banner + null + } + } + notificationData?.let { + NotificationBanner( + title = notificationData.title, + message = notificationData.message, + statusLevel = notificationData.statusLevel, + onClick = notificationData.onClick + ) + } +} + +@Composable +private fun NotificationBanner( + title: String, + message: String?, + statusLevel: StatusLevel, + onClick: (() -> Unit)? +) { + ConstraintLayout( + modifier = + Modifier.fillMaxWidth() + .background(color = MaterialTheme.colorScheme.background) + .padding( + start = Dimens.notificationBannerStartPadding, + end = Dimens.notificationBannerEndPadding, + top = Dimens.smallPadding, + bottom = Dimens.smallPadding + ) + .then(onClick?.let { Modifier.clickable(onClick = onClick) } ?: Modifier) + .animateContentSize() + .testTag(NOTIFICATION_BANNER) + ) { + val (status, textTitle, textMessage, icon) = createRefs() + Box( + modifier = + Modifier.background( + color = + if (statusLevel == StatusLevel.Warning) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.error + }, + shape = CircleShape + ) + .size(Dimens.notificationStatusIconSize) + .constrainAs(status) { + top.linkTo(textTitle.top) + start.linkTo(parent.start) + bottom.linkTo(textTitle.bottom) + } + ) + Text( + text = title.uppercase(), + modifier = + Modifier.constrainAs(textTitle) { + top.linkTo(parent.top) + start.linkTo(status.end) + bottom.linkTo(anchor = textMessage.top) + end.linkTo(icon.start) + width = Dimension.fillToConstraints + } + .padding(start = Dimens.smallPadding), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold + ) + message?.let { + Text( + text = message, + modifier = + Modifier.constrainAs(textMessage) { + top.linkTo(textTitle.bottom) + start.linkTo(textTitle.start) + bottom.linkTo(parent.bottom) + end.linkTo(icon.start) + width = Dimension.fillToConstraints + } + .padding(start = Dimens.smallPadding), + style = MaterialTheme.typography.labelMedium + ) + } + onClick?.let { + Image( + painter = painterResource(id = R.drawable.icon_extlink), + contentDescription = null, + modifier = + Modifier.constrainAs(icon) { + top.linkTo(parent.top) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + } + .padding(all = Dimens.notificationEndIconPadding) + ) + } + } +} + +@Composable +private fun genericBlockingMessage() = + NotificationBannerData( + title = stringResource(id = R.string.blocking_internet), + statusLevel = StatusLevel.Error + ) + +@Composable +private fun errorMessage(error: ErrorState) = + error.getErrorNotificationResources(LocalContext.current).run { + NotificationBannerData( + title = stringResource(id = titleResourceId), + message = optionalMessageArgument?.let { stringResource(id = messageResourceId, it) } + ?: stringResource(id = messageResourceId), + statusLevel = StatusLevel.Error + ) + } + +@Composable +private fun accountExpiryNotification(expiry: DateTime, onClickShowAccount: (() -> Unit)?) = + NotificationBannerData( + title = stringResource(id = R.string.account_credit_expires_soon), + message = LocalContext.current.resources.getExpiryQuantityString(expiry), + statusLevel = StatusLevel.Error, + onClick = onClickShowAccount + ) + +@Composable +private fun versionInfoNotification(versionInfo: VersionInfo, onClickUpdateVersion: (() -> Unit)?) = + when { + versionInfo.upgradeVersion != null && versionInfo.isSupported -> + NotificationBannerData( + title = stringResource(id = R.string.update_available), + message = + stringResource( + id = R.string.update_available_description, + versionInfo.upgradeVersion + ), + statusLevel = StatusLevel.Warning, + onClick = onClickUpdateVersion + ) + else -> + NotificationBannerData( + title = stringResource(id = R.string.unsupported_version), + message = stringResource(id = R.string.unsupported_version_description), + statusLevel = StatusLevel.Error, + onClick = onClickUpdateVersion + ) + } + +private data class NotificationBannerData( + val title: String, + val message: String? = null, + val statusLevel: StatusLevel, + val onClick: (() -> Unit)? = null +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index c9f90b4d53..863d938506 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -95,7 +95,7 @@ fun AccountScreen( ) { LaunchedEffect(Unit) { viewActions.collect { viewAction -> - if (viewAction is AccountViewModel.ViewAction.OpenAccountView) { + if (viewAction is AccountViewModel.ViewAction.OpenAccountManagementPageInBrowser) { context.openAccountPageInBrowser(viewAction.token) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 6070f9221b..0a50e339a6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -3,8 +3,8 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize 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 @@ -15,29 +15,38 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow 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.component.Notification 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.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.talpid.tunnel.ActionAfterDisconnect private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000 @@ -46,21 +55,38 @@ private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000 @Composable fun PreviewConnectScreen() { val state = ConnectUiState.INITIAL - AppTheme { ConnectScreen(state) } + AppTheme { + ConnectScreen( + uiState = state, + viewActions = MutableSharedFlow<ConnectViewModel.ViewAction>().asSharedFlow() + ) + } } @Composable fun ConnectScreen( uiState: ConnectUiState, + viewActions: SharedFlow<ConnectViewModel.ViewAction>, onDisconnectClick: () -> Unit = {}, onReconnectClick: () -> Unit = {}, onConnectClick: () -> Unit = {}, onCancelClick: () -> Unit = {}, onSwitchLocationClick: () -> Unit = {}, - onToggleTunnelInfo: () -> Unit = {} + onToggleTunnelInfo: () -> Unit = {}, + onUpdateVersionClick: () -> Unit = {}, + onManageAccountClick: () -> Unit = {} ) { + val context = LocalContext.current + LaunchedEffect(key1 = Unit) { + viewActions.collect { viewAction -> + if (viewAction is ConnectViewModel.ViewAction.OpenAccountManagementPageInBrowser) { + context.openAccountPageInBrowser(viewAction.token) + } + } + } + val scrollState = rememberScrollState() - var lastConnectionActionTimestamp by remember { mutableStateOf(0L) } + var lastConnectionActionTimestamp by remember { mutableLongStateOf(0L) } fun handleThrottledAction(action: () -> Unit) { val currentTime = System.currentTimeMillis() @@ -75,10 +101,17 @@ fun ConnectScreen( horizontalAlignment = Alignment.Start, modifier = Modifier.background(color = MaterialTheme.colorScheme.primary) - .height(IntrinsicSize.Max) - .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenVerticalMargin) + .fillMaxHeight() .verticalScroll(scrollState) + .padding(bottom = Dimens.screenVerticalMargin) + .testTag(SCROLLABLE_COLUMN_TEST_TAG) ) { + Notification( + connectNotificationState = uiState.connectNotificationState, + onClickUpdateVersion = onUpdateVersionClick, + onClickShowAccount = onManageAccountClick + ) + Spacer(modifier = Modifier.weight(1f)) if ( uiState.tunnelRealState is TunnelState.Connecting || (uiState.tunnelRealState is TunnelState.Disconnecting && @@ -88,7 +121,12 @@ fun ConnectScreen( CircularProgressIndicator( color = MaterialTheme.colorScheme.onPrimary, modifier = - Modifier.size( + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + top = Dimens.mediumPadding + ) + .size( width = Dimens.progressIndicatorSize, height = Dimens.progressIndicatorSize ) @@ -97,16 +135,21 @@ fun ConnectScreen( ) } Spacer(modifier = Modifier.height(Dimens.mediumPadding)) - ConnectionStatusText(state = uiState.tunnelRealState) + ConnectionStatusText( + state = uiState.tunnelRealState, + modifier = Modifier.padding(horizontal = Dimens.sideMargin) + ) Text( text = uiState.location?.country ?: "", style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = Dimens.sideMargin) ) Text( text = uiState.location?.city ?: "", style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = Dimens.sideMargin) ) LocationInfo( onToggleTunnelInfo = onToggleTunnelInfo, @@ -117,13 +160,17 @@ fun ConnectScreen( location = uiState.location, inAddress = uiState.inAddress, outAddress = uiState.outAddress, - modifier = Modifier.fillMaxWidth().testTag(LOCATION_INFO_TEST_TAG) + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimens.sideMargin) + .testTag(LOCATION_INFO_TEST_TAG) ) Spacer(modifier = Modifier.height(Dimens.buttonSeparation)) SwitchLocationButton( modifier = Modifier.fillMaxWidth() .height(Dimens.selectLocationButtonHeight) + .padding(horizontal = Dimens.sideMargin) .testTag(SELECT_LOCATION_BUTTON_TEST_TAG), onClick = onSwitchLocationClick, showChevron = uiState.showLocation, @@ -140,6 +187,7 @@ fun ConnectScreen( modifier = Modifier.fillMaxWidth() .height(Dimens.connectButtonHeight) + .padding(horizontal = Dimens.sideMargin) .testTag(CONNECT_BUTTON_TEST_TAG), disconnectClick = onDisconnectClick, reconnectClick = { handleThrottledAction(onReconnectClick) }, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectNotificationState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectNotificationState.kt new file mode 100644 index 0000000000..71ba71e54c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectNotificationState.kt @@ -0,0 +1,17 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.talpid.tunnel.ErrorState +import org.joda.time.DateTime + +sealed interface ConnectNotificationState { + data object ShowTunnelStateNotificationBlocked : ConnectNotificationState + + data class ShowTunnelStateNotificationError(val error: ErrorState) : ConnectNotificationState + + data class ShowVersionInfoNotification(val versionInfo: VersionInfo) : ConnectNotificationState + + data class ShowAccountExpiryNotification(val expiry: DateTime) : ConnectNotificationState + + data object HideNotification : ConnectNotificationState +} 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 417f589b6e..3c9c7352fe 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 @@ -3,18 +3,17 @@ package net.mullvad.mullvadvpn.compose.state 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?, val relayLocation: RelayItem?, - val versionInfo: VersionInfo?, val tunnelUiState: TunnelState, val tunnelRealState: TunnelState, val inAddress: Triple<String, Int, TransportProtocol>?, val outAddress: String, val showLocation: Boolean, + val connectNotificationState: ConnectNotificationState, val isTunnelInfoExpanded: Boolean ) { companion object { @@ -22,13 +21,13 @@ data class ConnectUiState( ConnectUiState( location = null, relayLocation = null, - versionInfo = null, tunnelUiState = TunnelState.Disconnected, tunnelRealState = TunnelState.Disconnected, inAddress = null, outAddress = "", showLocation = false, - isTunnelInfoExpanded = false + isTunnelInfoExpanded = false, + connectNotificationState = ConnectNotificationState.HideNotification ) } } 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 7e16641191..b6c9169ab4 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 @@ -16,7 +16,11 @@ const val CUSTOM_PORT_DIALOG_INPUT_TEST_TAG = "custom_port_dialog_input_test_tag const val CIRCULAR_PROGRESS_INDICATOR = "circular_progress_indicator" // ConnectScreen +const val SCROLLABLE_COLUMN_TEST_TAG = "scrollable_column_test_tag" const val SELECT_LOCATION_BUTTON_TEST_TAG = "select_location_button_test_tag" const val CONNECT_BUTTON_TEST_TAG = "connect_button_test_tag" const val RECONNECT_BUTTON_TEST_TAG = "reconnect_button_test_tag" const val LOCATION_INFO_TEST_TAG = "location_info_test_tag" + +// ConnectScreen - Notification banner +const val NOTIFICATION_BANNER = "notification_banner" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RememberPrevious.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RememberPrevious.kt new file mode 100644 index 0000000000..ef47f61472 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RememberPrevious.kt @@ -0,0 +1,43 @@ +package net.mullvad.mullvadvpn.compose.util + +/* + * Code snippet taken from: + * https://stackoverflow.com/questions/67801939/get-previous-value-of-state-in-composable-jetpack-compose + */ + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember + +@Composable +fun <T> rememberPrevious( + current: T, + shouldUpdate: (prev: T?, curr: T) -> Boolean = { a: T?, b: T -> a != b }, +): T? { + val ref = rememberRef<T>() + + // launched after render, so the current render will have the old value anyway + SideEffect { + if (shouldUpdate(ref.value, current)) { + ref.value = current + } + } + + return ref.value +} + +@Composable +private fun <T> rememberRef(): MutableState<T?> { + // for some reason it always recreated the value with vararg keys, + // leaving out the keys as a parameter for remember for now + return remember() { + object : MutableState<T?> { + override var value: T? = null + + override fun component1(): T? = value + + override fun component2(): (T?) -> Unit = { value = it } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 047ff2a26e..05e7548b9f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -14,9 +14,6 @@ import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.repository.SettingsRepository -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.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling import net.mullvad.mullvadvpn.util.ChangelogDataProvider @@ -64,10 +61,6 @@ val uiModule = module { single { ChangelogRepository(get(named(APP_PREFERENCES_NAME)), get()) } - single { AccountExpiryNotification(get()) } - single { TunnelStateNotification(get()) } - single { VersionInfoNotification(BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS, get()) } - single { AccountRepository(get()) } single { DeviceRepository(get()) } single { @@ -84,7 +77,7 @@ val uiModule = module { viewModel { ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG) } - viewModel { ConnectViewModel(get()) } + viewModel { ConnectViewModel(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS, get()) } viewModel { DeviceListViewModel(get(), get()) } viewModel { DeviceRevokedViewModel(get(), get()) } viewModel { LoginViewModel(get(), get()) } 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 deleted file mode 100644 index 4f330cb6be..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt +++ /dev/null @@ -1,37 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -import android.content.Context -import android.util.AttributeSet -import android.view.View -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<ComposeView>(context, attributes) { - override fun layoutDependsOn(parent: CoordinatorLayout, body: ComposeView, dependency: View) = - dependency.id == R.id.notification_banner - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - body: ComposeView, - dependency: View - ): Boolean { - val newPaddingTop = - if (dependency.visibility == View.VISIBLE) { - dependency.height + dependency.translationY.toInt() - } else { - 0 - } - - body.getChildAt(0).apply { - return if (paddingTop != newPaddingTop) { - setPadding(paddingLeft, newPaddingTop, paddingRight, paddingBottom) - true - } else { - 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 2fbaf591d3..7bd3c56edd 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 @@ -14,40 +14,25 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle 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.lib.common.constant.BuildTypes import net.mullvad.mullvadvpn.lib.common.util.JobTracker +import net.mullvad.mullvadvpn.lib.common.util.appendHideNavOnReleaseBuild import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.ui.NavigationBarPainter -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.widget.HeaderBar -import net.mullvad.mullvadvpn.ui.widget.NotificationBanner import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.talpid.tunnel.ErrorStateCause -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel class ConnectFragment : BaseFragment(), NavigationBarPainter { // Injected dependencies - private val accountRepository: AccountRepository by inject() - private val accountExpiryNotification: AccountExpiryNotification by inject() private val connectViewModel: ConnectViewModel by viewModel() - private val serviceConnectionManager: ServiceConnectionManager by inject() - private val tunnelStateNotification: TunnelStateNotification by inject() - private val versionInfoNotification: VersionInfoNotification by inject() private lateinit var headerBar: HeaderBar - private lateinit var notificationBanner: NotificationBanner @Deprecated("Refactor code to instead rely on Lifecycle.") private val jobTracker = JobTracker() @@ -68,39 +53,20 @@ class ConnectFragment : BaseFragment(), NavigationBarPainter { tunnelState = connectViewModel.uiState.value.tunnelUiState } - if (BuildTypes.RELEASE == BuildConfig.BUILD_TYPE) { - accountExpiryNotification.onClick = null - } else { - accountExpiryNotification.onClick = { - serviceConnectionManager.authTokenCache()?.fetchAuthToken()?.let { token -> - val url = getString(R.string.account_url) - val ready = Uri.parse("$url?token=$token") - requireContext().startActivity(Intent(Intent.ACTION_VIEW, ready)) - } - } - } - - notificationBanner = - view.findViewById<NotificationBanner>(R.id.notification_banner).apply { - notifications.apply { - // NOTE: The order of below notifications is significant. - register(tunnelStateNotification) - register(versionInfoNotification) - register(accountExpiryNotification) - } - } - view.findViewById<ComposeView>(R.id.compose_view).setContent { AppTheme { val state = connectViewModel.uiState.collectAsState().value ConnectScreen( uiState = state, + viewActions = connectViewModel.viewActions, onDisconnectClick = connectViewModel::onDisconnectClick, onReconnectClick = connectViewModel::onReconnectClick, onConnectClick = connectViewModel::onConnectClick, onCancelClick = connectViewModel::onCancelClick, onSwitchLocationClick = { openSwitchLocationScreen() }, - onToggleTunnelInfo = connectViewModel::toggleTunnelInfoExpansion + onToggleTunnelInfo = connectViewModel::toggleTunnelInfoExpansion, + onUpdateVersionClick = { openDownloadUrl() }, + onManageAccountClick = connectViewModel::onManageAccountClick ) } } @@ -108,37 +74,31 @@ class ConnectFragment : BaseFragment(), NavigationBarPainter { return view } - override fun onStart() { - super.onStart() - notificationBanner.onResume() - } - override fun onResume() { super.onResume() paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.blue)) } - private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { - launchViewModelSubscription() - launchAccountExpirySubscription() - } + private fun openDownloadUrl() { + val intent = + Intent( + Intent.ACTION_VIEW, + Uri.parse( + requireContext() + .getString(R.string.download_url) + .appendHideNavOnReleaseBuild() + ) + ) + .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } + requireContext().startActivity(intent) } - private fun CoroutineScope.launchViewModelSubscription() = launch { - connectViewModel.uiState.collect { uiState -> - uiState.versionInfo?.let { - versionInfoNotification.updateVersionInfo(uiState.versionInfo) - } - tunnelStateNotification.updateTunnelState(uiState.tunnelUiState) - updateTunnelState(uiState.tunnelRealState) - } + private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { launchViewModelSubscription() } } - private fun CoroutineScope.launchAccountExpirySubscription() = launch { - accountRepository.accountExpiryState.collect { - accountExpiryNotification.updateAccountExpiry(it.date()) - } + private fun CoroutineScope.launchViewModelSubscription() = launch { + connectViewModel.uiState.collect { uiState -> updateTunnelState(uiState.tunnelRealState) } } private fun updateTunnelState(realState: TunnelState) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt deleted file mode 100644 index 2e674ebc45..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt +++ /dev/null @@ -1,28 +0,0 @@ -package net.mullvad.mullvadvpn.ui.notification - -import android.content.Context -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.extensions.getExpiryQuantityString -import org.joda.time.DateTime - -class AccountExpiryNotification( - val context: Context, -) : InAppNotification() { - init { - status = StatusLevel.Error - title = context.getString(R.string.account_credit_expires_soon) - } - - fun updateAccountExpiry(expiry: DateTime?) { - val threeDaysFromNow = DateTime.now().plusDays(3) - - if (expiry != null && expiry.isBefore(threeDaysFromNow)) { - message = context.resources.getExpiryQuantityString(expiry) - shouldShow = true - } else { - shouldShow = false - } - - update() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt deleted file mode 100644 index ddba8656eb..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt +++ /dev/null @@ -1,45 +0,0 @@ -package net.mullvad.mullvadvpn.ui.notification - -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.util.ChangeMonitor - -abstract class InAppNotification { - private val changeMonitor = ChangeMonitor() - protected val jobTracker = JobTracker() - - var controller: InAppNotificationController? = null - - var status by changeMonitor.monitor(StatusLevel.Error) - protected set - - var title by changeMonitor.monitor("") - protected set - - var message by changeMonitor.monitor<String?>(null) - protected set - - var onClick by changeMonitor.monitor<(suspend () -> Unit)?>(null) - - var showIcon by changeMonitor.monitor(false) - protected set - - var shouldShow by changeMonitor.monitor(false) - protected set - - open fun onResume() {} - - open fun onPause() {} - - open fun onDestroy() { - jobTracker.cancelAllJobs() - } - - protected fun update() { - val controller = this.controller - - if (controller != null && changeMonitor.changed) { - controller.notificationChanged(this@InAppNotification) - changeMonitor.reset() - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt deleted file mode 100644 index 09b4947bbc..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt +++ /dev/null @@ -1,59 +0,0 @@ -package net.mullvad.mullvadvpn.ui.notification - -import java.util.PriorityQueue -import kotlin.properties.Delegates.observable - -class InAppNotificationController(private val onNotificationChanged: (InAppNotification?) -> Unit) { - private val notificationPrioritizer = - compareByDescending<InAppNotification> { it.shouldShow } - .thenBy { it.status } - .thenBy { notifications.get(it)!! } - - private val activeNotifications = PriorityQueue(notificationPrioritizer) - private val notifications = HashMap<InAppNotification, Int>() - - var current by - observable<InAppNotification?>(null) { _, oldNotification, newNotification -> - if (oldNotification != newNotification) { - onNotificationChanged.invoke(newNotification) - } - } - - fun register(notification: InAppNotification) { - notification.controller = this - - notifications.put(notification, notifications.size) - - notificationChanged(notification) - } - - fun onResume() { - for (notification in notifications.keys) { - notification.onResume() - } - } - - fun onPause() { - for (notification in notifications.keys) { - notification.onPause() - } - } - - fun onDestroy() { - for (notification in notifications.keys) { - notification.onDestroy() - } - } - - fun notificationChanged(notification: InAppNotification) { - if (notification.shouldShow) { - if (activeNotifications.contains(notification).not()) { - activeNotifications.add(notification) - } - } else { - activeNotifications.remove(notification) - } - - current = activeNotifications.peek() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt deleted file mode 100644 index ea36973660..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt +++ /dev/null @@ -1,26 +0,0 @@ -package net.mullvad.mullvadvpn.ui.notification - -import android.content.Context -import android.content.Intent -import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.net.Uri - -abstract class NotificationWithUrl(protected val context: Context, urlString: String) : - InAppNotification() { - private val url = Uri.parse(urlString) - - protected val openUrl: suspend () -> Unit = { - val intent = Intent(Intent.ACTION_VIEW, url).apply { flags = FLAG_ACTIVITY_NEW_TASK } - context.startActivity(intent) - } - - init { - onClick = openUrl - showIcon = true - } - - internal fun disableExternalLink() { - showIcon = false - onClick = null - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt deleted file mode 100644 index 6c761f47b2..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt +++ /dev/null @@ -1,26 +0,0 @@ -package net.mullvad.mullvadvpn.ui.notification - -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.annotation.StringRes -import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache - -abstract class NotificationWithUrlWithToken( - protected val context: Context, - protected val authTokenCache: AuthTokenCache, - @StringRes urlId: Int -) : InAppNotification() { - private val url = context.getString(urlId) - - protected val openUrl: suspend () -> Unit = { - context.startActivity(Intent(Intent.ACTION_VIEW, buildUrl())) - } - - init { - onClick = openUrl - showIcon = true - } - - private suspend fun buildUrl() = Uri.parse("$url?token=${authTokenCache.fetchAuthToken()}") -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt deleted file mode 100644 index 3c76a4d4eb..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.mullvad.mullvadvpn.ui.notification - -import android.content.Context -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.lib.common.util.getErrorNotificationResources -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.tunnel.ErrorState - -class TunnelStateNotification( - private val context: Context, -) : InAppNotification() { - init { - status = StatusLevel.Error - onClick = null - showIcon = false - } - - fun updateTunnelState(state: TunnelState) { - when (state) { - is TunnelState.Disconnecting -> { - when (state.actionAfterDisconnect) { - ActionAfterDisconnect.Nothing -> hide() - ActionAfterDisconnect.Block -> showGenericBlockingMessage() - ActionAfterDisconnect.Reconnect -> showGenericBlockingMessage() - } - } - is TunnelState.Disconnected -> hide() - is TunnelState.Connecting -> showGenericBlockingMessage() - is TunnelState.Connected -> hide() - is TunnelState.Error -> showError(state.errorState) - } - - update() - } - - private fun showError(error: ErrorState) { - // if the error state is null, we can assume that we are secure - error.getErrorNotificationResources(context).apply { - title = this.getTitleText(context.resources) - message = this.getMessageText(context.resources) - } - shouldShow = true - } - - private fun showGenericBlockingMessage() { - title = context.getString(R.string.blocking_internet) - message = null - shouldShow = true - } - - private fun hide() { - shouldShow = false - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt deleted file mode 100644 index 6e0e5f9846..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt +++ /dev/null @@ -1,53 +0,0 @@ -package net.mullvad.mullvadvpn.ui.notification - -import android.content.Context -import net.mullvad.mullvadvpn.BuildConfig -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.lib.common.constant.BuildTypes -import net.mullvad.mullvadvpn.lib.common.util.appendHideNavOnReleaseBuild -import net.mullvad.mullvadvpn.ui.VersionInfo - -class VersionInfoNotification(val isEnabled: Boolean, context: Context) : - NotificationWithUrl( - context, - context.getString(R.string.download_url).appendHideNavOnReleaseBuild() - ) { - private val unsupportedVersion = context.getString(R.string.unsupported_version) - private val updateAvailable = context.getString(R.string.update_available) - - fun updateVersionInfo(versionInfo: VersionInfo) { - val shouldShowNotification = - isEnabled && (versionInfo.isOutdated || !versionInfo.isSupported) - - if (shouldShowNotification) { - if (versionInfo.upgradeVersion != null) { - message = - if (versionInfo.isSupported) { - status = StatusLevel.Warning - title = updateAvailable - context.getString( - R.string.update_available_description, - versionInfo.upgradeVersion - ) - } else { - status = StatusLevel.Error - title = unsupportedVersion - context.getString(R.string.unsupported_version_description) - } - } else { - status = StatusLevel.Error - title = unsupportedVersion - message = context.getString(R.string.unsupported_version_without_upgrade) - } - - shouldShow = true - if (BuildConfig.BUILD_TYPE == BuildTypes.RELEASE) { - disableExternalLink() - } - } else { - shouldShow = false - } - - update() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt deleted file mode 100644 index da30945abc..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt +++ /dev/null @@ -1,171 +0,0 @@ -package net.mullvad.mullvadvpn.ui.widget - -import android.animation.Animator -import android.animation.Animator.AnimatorListener -import android.animation.ObjectAnimator -import android.content.Context -import android.text.Html -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.TextView -import androidx.core.content.res.ResourcesCompat -import androidx.core.text.HtmlCompat -import androidx.core.view.isVisible -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.ui.notification.InAppNotification -import net.mullvad.mullvadvpn.ui.notification.InAppNotificationController -import net.mullvad.mullvadvpn.ui.notification.StatusLevel - -class NotificationBanner : FrameLayout { - private val jobTracker = JobTracker() - - private val animationListener = - object : AnimatorListener { - override fun onAnimationCancel(animation: Animator) {} - - override fun onAnimationRepeat(animation: Animator) {} - - override fun onAnimationStart(animation: Animator) { - visibility = View.VISIBLE - } - - override fun onAnimationEnd(animation: Animator) { - synchronized(this@NotificationBanner) { - if (reversedAnimation) { - // Banner is now hidden - val notification = notifications.current - - visibility = View.INVISIBLE - - if (notification != null) { - // Notification changed, restart animation - update(notification) - reversedAnimation = false - animation.start() - } - } - } - } - } - - private val animation = - ObjectAnimator.ofFloat(this, "translationY", 0.0f).apply { - addListener(animationListener) - setDuration(350) - } - - private val container = - context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> - val inflater = service as LayoutInflater - - inflater.inflate(R.layout.notification_banner, this) - } - - private val errorImage = - ResourcesCompat.getDrawable(resources, R.drawable.icon_notification_error, null) - private val warningImage = - ResourcesCompat.getDrawable(resources, R.drawable.icon_notification_warning, null) - - private val status: ImageView = container.findViewById(R.id.notification_status) - private val title: TextView = container.findViewById(R.id.notification_title) - private val message: TextView = container.findViewById(R.id.notification_message) - private val icon: View = container.findViewById(R.id.notification_icon) - - private var reversedAnimation = false - - val notifications = InAppNotificationController { _ -> - synchronized(this@NotificationBanner) { animateChange() } - } - - constructor(context: Context) : super(context) - - constructor(context: Context, attributes: AttributeSet) : super(context, attributes) - - constructor( - context: Context, - attributes: AttributeSet, - defaultStyleAttribute: Int - ) : super(context, attributes, defaultStyleAttribute) - - init { - setBackgroundResource(R.color.darkBlue) - - setOnClickListener { jobTracker.newUiJob("click") { onClick() } } - - visibility = View.INVISIBLE - } - - fun onResume() { - notifications.onResume() - } - - fun onPause() { - notifications.onPause() - } - - fun onDestroy() { - notifications.onDestroy() - jobTracker.cancelAllJobs() - } - - protected override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { - animation.setFloatValues(-height.toFloat(), 0.0f) - } - - private suspend fun onClick() { - notifications.current?.onClick?.let { action -> - alpha = 0.5f - setClickable(false) - - jobTracker.runOnBackground(action) - - setClickable(true) - alpha = 1.0f - } - } - - private fun update(notification: InAppNotification) { - val notificationMessage = notification.message - val clickAction = notification.onClick - - when (notification.status) { - StatusLevel.Error -> status.setImageDrawable(errorImage) - StatusLevel.Warning -> status.setImageDrawable(warningImage) - } - - title.text = notification.title.uppercase() - - if (notificationMessage != null) { - message.text = Html.fromHtml(notificationMessage, HtmlCompat.FROM_HTML_MODE_LEGACY) - message.visibility = View.VISIBLE - } else { - message.visibility = View.GONE - } - - if (notification.showIcon) { - icon.visibility = View.VISIBLE - } else { - icon.visibility = View.GONE - } - - setClickable(clickAction != null) - } - - private fun animateChange() { - val notification = notifications.current - val hasOngoingHideAnimation = animation.isRunning && reversedAnimation - - if (isVisible.not() && notification != null) { - reversedAnimation = false - update(notification) - animation.start() - } else if (hasOngoingHideAnimation.not()) { - reversedAnimation = true - animation.reverse() - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt index e4cdc95070..77caddec85 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -73,3 +73,28 @@ inline fun <T1, T2, T3, T4, T5, T6, R> combine( ) } } + +inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine( + flow: Flow<T1>, + flow2: Flow<T2>, + flow3: Flow<T3>, + flow4: Flow<T4>, + flow5: Flow<T5>, + flow6: Flow<T6>, + flow7: Flow<T7>, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R +): Flow<R> { + return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { + args: Array<*> -> + @Suppress("UNCHECKED_CAST") + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7 + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt index 30c1464a91..4f6a2d52ca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt @@ -49,7 +49,7 @@ class AccountViewModel( fun onManageAccountClick() { viewModelScope.launch { _viewActions.tryEmit( - ViewAction.OpenAccountView( + ViewAction.OpenAccountManagementPageInBrowser( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) ) @@ -61,6 +61,6 @@ class AccountViewModel( } sealed class ViewAction { - data class OpenAccountView(val token: String) : ViewAction() + data class OpenAccountManagementPageInBrowser(val token: String) : ViewAction() } } 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 7021667101..b67cd754c4 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 @@ -2,12 +2,15 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow @@ -16,14 +19,20 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.ConnectNotificationState import net.mullvad.mullvadvpn.compose.state.ConnectUiState +import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.LocationInfoCache 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.authTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.util.appVersionCallbackFlow import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier @@ -31,9 +40,17 @@ import net.mullvad.mullvadvpn.util.combine import net.mullvad.mullvadvpn.util.toInAddress import net.mullvad.mullvadvpn.util.toOutAddress import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import org.joda.time.DateTime + +@OptIn(FlowPreview::class) +class ConnectViewModel( + private val serviceConnectionManager: ServiceConnectionManager, + private val isVersionInfoNotificationEnabled: Boolean, + accountRepository: AccountRepository, +) : ViewModel() { + private val _viewActions = MutableSharedFlow<ViewAction>(extraBufferCapacity = 1) + val viewActions = _viewActions.asSharedFlow() -class ConnectViewModel(private val serviceConnectionManager: ServiceConnectionManager) : - ViewModel() { private val _shared: SharedFlow<ServiceConnectionContainer> = serviceConnectionManager.connectionState .flatMapLatest { state -> @@ -56,6 +73,7 @@ class ConnectViewModel(private val serviceConnectionManager: ServiceConnectionMa serviceConnection.appVersionInfoCache.appVersionCallbackFlow(), serviceConnection.connectionProxy.tunnelUiStateFlow(), serviceConnection.connectionProxy.tunnelRealStateFlow(), + accountRepository.accountExpiryState, _isTunnelInfoExpanded ) { location, @@ -63,6 +81,7 @@ class ConnectViewModel(private val serviceConnectionManager: ServiceConnectionMa versionInfo, tunnelUiState, tunnelRealState, + accountExpiry, isTunnelInfoExpanded -> ConnectUiState( location = @@ -73,7 +92,6 @@ class ConnectViewModel(private val serviceConnectionManager: ServiceConnectionMa } ?: location, relayLocation = relayLocation, - versionInfo = versionInfo, tunnelUiState = tunnelUiState, tunnelRealState = tunnelRealState, isTunnelInfoExpanded = isTunnelInfoExpanded, @@ -97,7 +115,13 @@ class ConnectViewModel(private val serviceConnectionManager: ServiceConnectionMa is TunnelState.Connecting -> false is TunnelState.Connected -> false is TunnelState.Error -> true - } + }, + connectNotificationState = + evaluateNotificationState( + tunnelUiState = tunnelUiState, + versionInfo = versionInfo, + accountExpiry = accountExpiry + ) ) } } @@ -123,6 +147,36 @@ class ConnectViewModel(private val serviceConnectionManager: ServiceConnectionMa private fun ConnectionProxy.tunnelRealStateFlow(): Flow<TunnelState> = callbackFlowFromNotifier(this.onStateChange) + private fun evaluateNotificationState( + tunnelUiState: TunnelState, + versionInfo: VersionInfo?, + accountExpiry: AccountExpiry + ): ConnectNotificationState = + when { + tunnelUiState is TunnelState.Connecting -> + ConnectNotificationState.ShowTunnelStateNotificationBlocked + tunnelUiState is TunnelState.Disconnecting && + (tunnelUiState.actionAfterDisconnect == ActionAfterDisconnect.Block || + tunnelUiState.actionAfterDisconnect == ActionAfterDisconnect.Reconnect) -> + ConnectNotificationState.ShowTunnelStateNotificationBlocked + tunnelUiState is TunnelState.Error -> + ConnectNotificationState.ShowTunnelStateNotificationError(tunnelUiState.errorState) + isVersionInfoNotificationEnabled && + versionInfo != null && + (versionInfo.isOutdated || !versionInfo.isSupported) -> + ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + accountExpiry.isCloseToExpiring() -> + ConnectNotificationState.ShowAccountExpiryNotification( + accountExpiry.date() ?: DateTime.now() + ) + else -> ConnectNotificationState.HideNotification + } + + private fun AccountExpiry.isCloseToExpiring(): Boolean { + val threeDaysFromNow = DateTime.now().plusDays(3) + return this.date()?.isBefore(threeDaysFromNow) == true + } + fun toggleTunnelInfoExpansion() { _isTunnelInfoExpanded.value = _isTunnelInfoExpanded.value.not() } @@ -143,6 +197,20 @@ class ConnectViewModel(private val serviceConnectionManager: ServiceConnectionMa serviceConnectionManager.connectionProxy()?.disconnect() } + fun onManageAccountClick() { + viewModelScope.launch { + _viewActions.tryEmit( + ViewAction.OpenAccountManagementPageInBrowser( + serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" + ) + ) + } + } + + sealed interface ViewAction { + data class OpenAccountManagementPageInBrowser(val token: String) : ViewAction + } + companion object { const val UI_STATE_DEBOUNCE_DURATION_MILLIS: Long = 200 } diff --git a/android/app/src/main/res/layout/connect.xml b/android/app/src/main/res/layout/connect.xml index dafc2bc39e..7c0267d903 100644 --- a/android/app/src/main/res/layout/connect.xml +++ b/android/app/src/main/res/layout/connect.xml @@ -7,10 +7,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:elevation="0.5dp" /> - <net.mullvad.mullvadvpn.ui.widget.NotificationBanner android:id="@+id/notification_banner" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:elevation="0.25dp" /> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" android:layout_width="match_parent" android:layout_height="match_parent" diff --git a/android/app/src/main/res/layout/notification_banner.xml b/android/app/src/main/res/layout/notification_banner.xml deleted file mode 100644 index 0c420431a1..0000000000 --- a/android/app/src/main/res/layout/notification_banner.xml +++ /dev/null @@ -1,52 +0,0 @@ -<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingVertical="8dp" - android:paddingStart="16dp" - android:paddingEnd="12dp" - android:focusable="true" - android:background="?android:attr/selectableItemBackground"> - <RelativeLayout android:id="@+id/notification_status_container" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:layout_alignParentStart="true" - android:layout_alignBottom="@id/notification_title"> - <ImageView android:id="@+id/notification_status" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_centerInParent="true" - android:src="@drawable/icon_notification_error" /> - </RelativeLayout> - <TextView android:id="@+id/notification_title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:layout_toStartOf="@id/notification_icon" - android:layout_toEndOf="@id/notification_status_container" - android:layout_marginStart="7dp" - android:textSize="@dimen/text_small" - android:textStyle="bold" - android:text="@string/blocking_internet" - android:textAllCaps="true" /> - <TextView android:id="@+id/notification_message" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignWithParentIfMissing="true" - android:layout_toStartOf="@id/notification_icon" - android:layout_alignStart="@id/notification_title" - android:layout_below="@id/notification_title" - android:textSize="@dimen/text_small" - android:textColor="@color/white60" - android:text="" - android:visibility="gone" /> - <ImageView android:id="@+id/notification_icon" - android:layout_width="20dp" - android:layout_height="20dp" - android:layout_alignParentEnd="true" - android:layout_centerVertical="true" - android:alpha="0.6" - android:padding="4dp" - android:src="@drawable/icon_extlink" - android:visibility="gone" /> -</RelativeLayout> 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 a621e80ff2..d8ec2b26f7 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 @@ -2,6 +2,7 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -9,26 +10,35 @@ import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.ConnectNotificationState import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.GeoIpLocation import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.relaylist.RelayCountry import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache +import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.LocationInfoCache 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.authTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.util.appVersionCallbackFlow +import net.mullvad.talpid.tunnel.ErrorState import net.mullvad.talpid.util.EventNotifier +import org.joda.time.DateTime +import org.joda.time.ReadableInstant import org.junit.After import org.junit.Before import org.junit.Rule @@ -48,9 +58,10 @@ class ConnectViewModelTest { currentVersion = null, upgradeVersion = null, isOutdated = false, - isSupported = false + isSupported = true ) ) + private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) // Service connections private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() @@ -60,6 +71,9 @@ class ConnectViewModelTest { private val mockConnectionProxy: ConnectionProxy = mockk() private val mockLocation: GeoIpLocation = mockk(relaxed = true) + // Account Repository + private val mockAccountRepository: AccountRepository = mockk() + // Captures private val locationSlot = slot<((GeoIpLocation?) -> Unit)>() private val relaySlot = slot<(List<RelayCountry>, RelayItem?) -> Unit>() @@ -84,6 +98,8 @@ class ConnectViewModelTest { every { mockServiceConnectionContainer.appVersionInfoCache } returns mockAppVersionInfoCache every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy + every { mockAccountRepository.accountExpiryState } returns accountExpiryState + every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState @@ -94,7 +110,12 @@ class ConnectViewModelTest { every { mockRelayListListener.onRelayCountriesChange = capture(relaySlot) } answers {} every { mockAppVersionInfoCache.onUpdate = any() } answers {} - viewModel = ConnectViewModel(mockServiceConnectionManager) + viewModel = + ConnectViewModel( + serviceConnectionManager = mockServiceConnectionManager, + accountRepository = mockAccountRepository, + isVersionInfoNotificationEnabled = true + ) } @After @@ -160,29 +181,6 @@ class ConnectViewModelTest { } @Test - fun testAppVersionInfoUpdate() = - runTest(testCoroutineRule.testDispatcher) { - val versionInfoTestItem = - VersionInfo( - currentVersion = "1.0", - upgradeVersion = "2.0", - isOutdated = false, - isSupported = false - ) - - viewModel.uiState.test { - assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockLocation) - relaySlot.captured.invoke(mockk(), mockk()) - versionInfo.value = versionInfoTestItem - val result = awaitItem() - assertEquals(versionInfoTestItem, result.versionInfo) - } - } - - @Test fun testRelayItemUpdate() = runTest(testCoroutineRule.testDispatcher) { val relayTestItem = @@ -258,6 +256,111 @@ class ConnectViewModelTest { verify { mockConnectionProxy.disconnect() } } + @Test + fun testBlockingNotificationState() = + runTest(testCoroutineRule.testDispatcher) { + // Arrange + val expectedConnectNotificationState = + ConnectNotificationState.ShowTunnelStateNotificationBlocked + val tunnelUiState = TunnelState.Connecting(null, null) + + // Act, Assert + viewModel.uiState.test { + assertEquals(ConnectUiState.INITIAL, awaitItem()) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + locationSlot.captured.invoke(mockLocation) + relaySlot.captured.invoke(mockk(), mockk()) + eventNotifierTunnelUiState.notify(tunnelUiState) + val result = awaitItem() + assertEquals(expectedConnectNotificationState, result.connectNotificationState) + } + } + + @Test + fun testErrorNotificationState() = + runTest(testCoroutineRule.testDispatcher) { + // Arrange + val mockErrorState: ErrorState = mockk() + val expectedConnectNotificationState = + ConnectNotificationState.ShowTunnelStateNotificationError(mockErrorState) + val tunnelUiState = TunnelState.Error(mockErrorState) + + // Act, Assert + viewModel.uiState.test { + assertEquals(ConnectUiState.INITIAL, awaitItem()) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + locationSlot.captured.invoke(mockLocation) + relaySlot.captured.invoke(mockk(), mockk()) + eventNotifierTunnelUiState.notify(tunnelUiState) + val result = awaitItem() + assertEquals(expectedConnectNotificationState, result.connectNotificationState) + } + } + + @Test + fun testVersionInfoNotificationState() = + runTest(testCoroutineRule.testDispatcher) { + // Arrange + val mockVersionInfo: VersionInfo = mockk() + val expectedConnectNotificationState = + ConnectNotificationState.ShowVersionInfoNotification(mockVersionInfo) + every { mockVersionInfo.isOutdated } returns true + + // Act, Assert + viewModel.uiState.test { + assertEquals(ConnectUiState.INITIAL, awaitItem()) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + locationSlot.captured.invoke(mockLocation) + relaySlot.captured.invoke(mockk(), mockk()) + versionInfo.value = mockVersionInfo + val result = awaitItem() + assertEquals(expectedConnectNotificationState, result.connectNotificationState) + } + } + + @Test + fun testAccountExpiryNotificationState() = + runTest(testCoroutineRule.testDispatcher) { + // Arrange + val mockDateTime: DateTime = mockk() + val expectedConnectNotificationState = + ConnectNotificationState.ShowAccountExpiryNotification(mockDateTime) + every { mockDateTime.isBefore(any<ReadableInstant>()) } returns true + + // Act, Assert + viewModel.uiState.test { + assertEquals(ConnectUiState.INITIAL, awaitItem()) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + locationSlot.captured.invoke(mockLocation) + relaySlot.captured.invoke(mockk(), mockk()) + accountExpiryState.value = AccountExpiry.Available(mockDateTime) + val result = awaitItem() + assertEquals(expectedConnectNotificationState, result.connectNotificationState) + } + } + + @Test + fun testOnShowAccountClick() = + runTest(testCoroutineRule.testDispatcher) { + // Arrange + val mockToken = "4444 5555 6666 7777" + val mockAuthTokenCache: AuthTokenCache = mockk(relaxed = true) + every { mockServiceConnectionManager.authTokenCache() } returns mockAuthTokenCache + coEvery { mockAuthTokenCache.fetchAuthToken() } returns mockToken + + // Act, Assert + viewModel.viewActions.test { + viewModel.onManageAccountClick() + val action = awaitItem() + assertIs<ConnectViewModel.ViewAction.OpenAccountManagementPageInBrowser>(action) + assertEquals(mockToken, action.token) + } + } + companion object { private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt" private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt index 639b183e86..e55bdc605a 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt @@ -33,10 +33,6 @@ val MullvadRed = Color(0xFFE34039) @Deprecated( "Deprecated for external usage and will be marked as internal in the future. Use material colors instead." ) -val MullvadHelmetYellow = Color(0xFFFFD524) -@Deprecated( - "Deprecated for external usage and will be marked as internal in the future. Use material colors instead." -) val MullvadWhite = Color(0xFFFFFFFF) @Deprecated( "Deprecated for external usage and will be marked as internal in the future. Use material colors instead." diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt index cad213e548..132f2f9dc3 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt @@ -72,6 +72,7 @@ private val MullvadColorPalette = onSurface = MullvadWhite, inversePrimary = MullvadGreen, error = MullvadRed, + errorContainer = MullvadYellow, outlineVariant = Color.Transparent, // Used by divider inverseSurface = MullvadWhite ) diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index 9013fb6a28..aeee6593da 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -31,6 +31,10 @@ data class Dimensions( val loadingSpinnerSizeMedium: Dp = 28.dp, val loadingSpinnerStrokeWidth: Dp = 3.dp, val mediumPadding: Dp = 16.dp, + val notificationBannerStartPadding: Dp = 16.dp, + val notificationBannerEndPadding: Dp = 12.dp, + val notificationEndIconPadding: Dp = 4.dp, + val notificationStatusIconSize: Dp = 10.dp, val progressIndicatorSize: Dp = 60.dp, val relayCircleSize: Dp = 16.dp, val relayRowPadding: Dp = 50.dp, |
