summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-09-06 10:52:30 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-09-06 10:52:30 +0200
commitcfc1aa9b12de7accc86e7e65606058d11ab76102 (patch)
tree3660af744574f9dfc59c0f5098ed9132a5be4bbc /android
parent0447c6013428e386fbbfe619318dfd605246bd43 (diff)
parented325f028e55232f6aa86c7a2bc1d271f5ba0e93 (diff)
downloadmullvadvpn-cfc1aa9b12de7accc86e7e65606058d11ab76102.tar.xz
mullvadvpn-cfc1aa9b12de7accc86e7e65606058d11ab76102.zip
Merge branch 'migrate-notificationbanner-to-compose-droid-187'
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt333
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt45
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NotificationBanner.kt298
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt72
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectNotificationState.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RememberPrevious.kt43
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/UnderNotificationBannerBehavior.kt37
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt82
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt28
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt45
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt59
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt55
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt53
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt171
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt25
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt76
-rw-r--r--android/app/src/main/res/layout/connect.xml4
-rw-r--r--android/app/src/main/res/layout/notification_banner.xml52
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt153
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt4
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt1
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt4
30 files changed, 1002 insertions, 742 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt
index 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,