summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-10-23 13:37:15 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-10-23 13:37:15 +0200
commitf58efc23d12e3a17b0fe9be759abd6e99eb01aee (patch)
tree22f08db8c8f32fa6dcee7d38a45259c914383293
parent8f9cff2d73cb235daf79fddcc6f6e4e3a4a12d3b (diff)
parentc43b476f492eac0948efe574f974a2a0301f6c35 (diff)
downloadmullvadvpn-f58efc23d12e3a17b0fe9be759abd6e99eb01aee.tar.xz
mullvadvpn-f58efc23d12e3a17b0fe9be759abd6e99eb01aee.zip
Merge branch 'add-in-app-banner-message-for-a-new-device-droid-92'
-rw-r--r--CHANGELOG.md1
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt67
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NotificationBanner.kt297
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt199
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt121
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt17
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectNotificationState.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt85
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt47
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt48
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt55
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt3
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt102
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt75
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt81
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt94
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt114
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt91
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt4
-rw-r--r--android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt16
-rw-r--r--android/lib/resource/src/main/res/values-da/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-de/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-es/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-fi/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-fr/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-it/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-ja/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-ko/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-my/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-nb/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-nl/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-pl/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-pt/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-ru/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-sv/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-th/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-tr/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-zh-rCN/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-zh-rTW/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml2
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt2
51 files changed, 1183 insertions, 500 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ad5ade0bee..cf2d40fd85 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,7 @@ Line wrap the file at 100 chars. Th
- Migrate Report Problem view to compose.
- Migrate View Logs view to compose.
- Migrate voucher dialog to compose.
+- Add "New Device" in app notification & rework notification system
#### Linux
- Don't block forwarding of traffic when the split tunnel mark (ct mark) is set.
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt
index d6ef5d3311..56894addea 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
@@ -13,18 +13,18 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
-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.NOTIFICATION_BANNER_ACTION
import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG
import net.mullvad.mullvadvpn.model.GeoIpLocation
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import net.mullvad.talpid.net.TransportProtocol
@@ -86,8 +86,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowTunnelStateNotificationBlocked
+ inAppNotification = InAppNotification.TunnelStateBlocked
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -123,8 +122,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowTunnelStateNotificationBlocked
+ inAppNotification = InAppNotification.TunnelStateBlocked
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -158,7 +156,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -191,7 +189,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -225,7 +223,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -259,7 +257,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -295,8 +293,8 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowTunnelStateNotificationError(
+ inAppNotification =
+ InAppNotification.TunnelStateError(
ErrorState(ErrorStateCause.StartTunnelError, true)
)
),
@@ -335,8 +333,8 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowTunnelStateNotificationError(
+ inAppNotification =
+ InAppNotification.TunnelStateError(
ErrorState(ErrorStateCause.StartTunnelError, false)
)
),
@@ -372,8 +370,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowTunnelStateNotificationBlocked
+ inAppNotification = InAppNotification.TunnelStateBlocked
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -409,8 +406,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowTunnelStateNotificationBlocked
+ inAppNotification = InAppNotification.TunnelStateBlocked
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -446,7 +442,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onSwitchLocationClick = mockedClickHandler
@@ -479,7 +475,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onDisconnectClick = mockedClickHandler
@@ -512,7 +508,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onReconnectClick = mockedClickHandler
@@ -544,7 +540,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onConnectClick = mockedClickHandler
@@ -576,7 +572,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onCancelClick = mockedClickHandler
@@ -609,7 +605,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(),
onToggleTunnelInfo = mockedClickHandler
@@ -649,7 +645,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = true,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState = ConnectNotificationState.HideNotification
+ inAppNotification = null
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -688,8 +684,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowVersionInfoNotification(versionInfo)
+ inAppNotification = InAppNotification.UpdateAvailable(versionInfo)
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -726,8 +721,7 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowVersionInfoNotification(versionInfo)
+ inAppNotification = InAppNotification.UnsupportedVersion(versionInfo)
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -759,10 +753,9 @@ class ConnectScreenTest {
outAddress = "",
showLocation = false,
isTunnelInfoExpanded = false,
- deviceName = null,
+ deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowAccountExpiryNotification(expiryDate)
+ inAppNotification = InAppNotification.AccountExpiry(expiryDate)
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
@@ -801,15 +794,14 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowVersionInfoNotification(versionInfo)
+ inAppNotification = InAppNotification.UnsupportedVersion(versionInfo)
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
// Act
- composeTestRule.onNodeWithTag(NOTIFICATION_BANNER).performClick()
+ composeTestRule.onNodeWithTag(NOTIFICATION_BANNER_ACTION).performClick()
// Assert
verify { mockedClickHandler.invoke() }
@@ -835,15 +827,14 @@ class ConnectScreenTest {
isTunnelInfoExpanded = false,
deviceName = "",
daysLeftUntilExpiry = null,
- connectNotificationState =
- ConnectNotificationState.ShowAccountExpiryNotification(expiryDate)
+ inAppNotification = InAppNotification.AccountExpiry(expiryDate)
),
uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow()
)
}
// Act
- composeTestRule.onNodeWithTag(NOTIFICATION_BANNER).performClick()
+ composeTestRule.onNodeWithTag(NOTIFICATION_BANNER_ACTION).performClick()
// Assert
verify { mockedClickHandler.invoke() }
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
deleted file mode 100644
index 0f7fa74117..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NotificationBanner.kt
+++ /dev/null
@@ -1,297 +0,0 @@
-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.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.constant.IS_PLAY_BUILD
-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
-private 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 (IS_PLAY_BUILD) {
- null
- } else {
- onClickUpdateVersion
- }
- )
- }
- is ConnectNotificationState.ShowAccountExpiryNotification -> {
- accountExpiryNotification(
- expiry = connectNotificationState.expiry,
- onClickShowAccount =
- if (IS_PLAY_BUILD) {
- null
- } else {
- onClickShowAccount
- }
- )
- }
- 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)
- .then(onClick?.let { Modifier.clickable(onClick = onClick) } ?: Modifier)
- .padding(
- start = Dimens.notificationBannerStartPadding,
- end = Dimens.notificationBannerEndPadding,
- top = Dimens.smallPadding,
- bottom = Dimens.smallPadding
- )
- .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/component/notificationbanner/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt
new file mode 100644
index 0000000000..6078e4b392
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt
@@ -0,0 +1,199 @@
+package net.mullvad.mullvadvpn.compose.component.notificationbanner
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+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.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.constraintlayout.compose.Dimension
+import net.mullvad.mullvadvpn.compose.component.MullvadTopBar
+import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER
+import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION
+import net.mullvad.mullvadvpn.compose.util.rememberPrevious
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import net.mullvad.mullvadvpn.ui.VersionInfo
+import net.mullvad.mullvadvpn.ui.notification.StatusLevel
+import net.mullvad.talpid.tunnel.ErrorState
+import net.mullvad.talpid.tunnel.ErrorStateCause
+import org.joda.time.DateTime
+
+@Preview
+@Composable
+private fun PreviewNotificationBanner() {
+ AppTheme {
+ Column(
+ Modifier.background(color = MaterialTheme.colorScheme.surface),
+ ) {
+ val bannerDataList =
+ listOf(
+ InAppNotification.UnsupportedVersion(
+ versionInfo =
+ VersionInfo(
+ currentVersion = null,
+ upgradeVersion = null,
+ isOutdated = true,
+ isSupported = false
+ ),
+ ),
+ InAppNotification.AccountExpiry(expiry = DateTime.now()),
+ InAppNotification.TunnelStateBlocked,
+ InAppNotification.NewDevice("Courageous Turtle"),
+ InAppNotification.TunnelStateError(
+ error = ErrorState(ErrorStateCause.SetFirewallPolicyError, true)
+ )
+ )
+ .map { it.toNotificationData({}, {}, {}) }
+
+ bannerDataList.forEach {
+ MullvadTopBar(
+ containerColor = MaterialTheme.colorScheme.primary,
+ onSettingsClicked = {},
+ onAccountClicked = {},
+ iconTintColor = MaterialTheme.colorScheme.primary
+ )
+ Notification(it)
+ Spacer(modifier = Modifier.size(16.dp))
+ }
+ }
+ }
+}
+
+@Composable
+fun NotificationBanner(
+ notification: InAppNotification?,
+ onClickUpdateVersion: () -> Unit,
+ onClickShowAccount: () -> Unit,
+ onClickDismissNewDevice: () -> Unit
+) {
+ // Fix for animating to invisible state
+ val previous = rememberPrevious(current = notification, shouldUpdate = { _, _ -> true })
+ AnimatedVisibility(
+ visible = notification != null,
+ enter = slideInVertically(initialOffsetY = { -it }),
+ exit = slideOutVertically(targetOffsetY = { -it }),
+ modifier = Modifier.animateContentSize()
+ ) {
+ val visibleNotification = notification ?: previous
+ if (visibleNotification != null)
+ Notification(
+ visibleNotification.toNotificationData(
+ onClickUpdateVersion,
+ onClickShowAccount,
+ onClickDismissNewDevice
+ )
+ )
+ }
+}
+
+@Composable
+private fun Notification(notificationBannerData: NotificationData) {
+ val (title, message, statusLevel, action) = notificationBannerData
+ ConstraintLayout(
+ modifier =
+ Modifier.fillMaxWidth()
+ .background(color = MaterialTheme.colorScheme.background)
+ .padding(
+ start = Dimens.notificationBannerStartPadding,
+ end = Dimens.notificationBannerEndPadding,
+ top = Dimens.smallPadding,
+ bottom = Dimens.smallPadding
+ )
+ .animateContentSize()
+ .testTag(NOTIFICATION_BANNER)
+ ) {
+ val (status, textTitle, textMessage, actionIcon) = createRefs()
+ Box(
+ modifier =
+ Modifier.background(
+ color =
+ when (statusLevel) {
+ StatusLevel.Error -> MaterialTheme.colorScheme.error
+ StatusLevel.Warning -> MaterialTheme.colorScheme.errorContainer
+ StatusLevel.Info -> MaterialTheme.colorScheme.surface
+ },
+ 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(actionIcon.start)
+ width = Dimension.fillToConstraints
+ }
+ .padding(start = Dimens.smallPadding),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ message?.let {
+ Text(
+ text = message,
+ modifier =
+ Modifier.constrainAs(textMessage) {
+ top.linkTo(textTitle.bottom)
+ start.linkTo(textTitle.start)
+ bottom.linkTo(parent.bottom)
+ if (action != null) {
+ end.linkTo(actionIcon.start)
+ } else {
+ end.linkTo(parent.end)
+ }
+ width = Dimension.fillToConstraints
+ }
+ .padding(start = Dimens.smallPadding),
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaDescription),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+ action?.let {
+ IconButton(
+ modifier =
+ Modifier.constrainAs(actionIcon) {
+ top.linkTo(parent.top)
+ end.linkTo(parent.end)
+ bottom.linkTo(parent.bottom)
+ }
+ .testTag(NOTIFICATION_BANNER_ACTION)
+ .padding(all = Dimens.notificationEndIconPadding),
+ onClick = it.onClick
+ ) {
+ Icon(
+ painter = painterResource(id = it.icon),
+ contentDescription = null,
+ tint = Color.Unspecified
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt
new file mode 100644
index 0000000000..3fbf0ad095
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt
@@ -0,0 +1,121 @@
+package net.mullvad.mullvadvpn.compose.component.notificationbanner
+
+import androidx.annotation.DrawableRes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.core.text.HtmlCompat
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.extensions.getExpiryQuantityString
+import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
+import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
+import net.mullvad.mullvadvpn.lib.common.util.getErrorNotificationResources
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import net.mullvad.mullvadvpn.ui.notification.StatusLevel
+import net.mullvad.talpid.tunnel.ErrorState
+
+data class NotificationData(
+ val title: String,
+ val message: AnnotatedString? = null,
+ val statusLevel: StatusLevel,
+ val action: NotificationAction? = null
+) {
+ constructor(
+ title: String,
+ message: String?,
+ statusLevel: StatusLevel,
+ action: NotificationAction?
+ ) : this(title, message?.let { AnnotatedString(it) }, statusLevel, action)
+}
+
+data class NotificationAction(
+ @DrawableRes val icon: Int,
+ val onClick: (() -> Unit),
+)
+
+@Composable
+fun InAppNotification.toNotificationData(
+ onClickUpdateVersion: () -> Unit,
+ onClickShowAccount: () -> Unit,
+ onDismissNewDevice: () -> Unit
+) =
+ when (this) {
+ is InAppNotification.NewDevice ->
+ NotificationData(
+ title = stringResource(id = R.string.new_device_notification_title),
+ message =
+ HtmlCompat.fromHtml(
+ stringResource(
+ id = R.string.new_device_notification_message,
+ deviceName
+ ),
+ HtmlCompat.FROM_HTML_MODE_COMPACT
+ )
+ .toAnnotatedString(
+ boldSpanStyle =
+ SpanStyle(
+ color = MaterialTheme.colorScheme.onBackground,
+ fontWeight = FontWeight.ExtraBold
+ ),
+ ),
+ statusLevel = StatusLevel.Info,
+ action = NotificationAction(R.drawable.icon_close, onDismissNewDevice)
+ )
+ is InAppNotification.AccountExpiry ->
+ NotificationData(
+ title = stringResource(id = R.string.account_credit_expires_soon),
+ message = LocalContext.current.resources.getExpiryQuantityString(expiry),
+ statusLevel = StatusLevel.Error,
+ action =
+ if (IS_PLAY_BUILD) null
+ else
+ NotificationAction(
+ R.drawable.icon_extlink,
+ onClickShowAccount,
+ ),
+ )
+ InAppNotification.TunnelStateBlocked ->
+ NotificationData(
+ title = stringResource(id = R.string.blocking_internet),
+ statusLevel = StatusLevel.Error
+ )
+ is InAppNotification.TunnelStateError -> errorMessageBannerData(error)
+ is InAppNotification.UnsupportedVersion ->
+ NotificationData(
+ title = stringResource(id = R.string.unsupported_version),
+ message = stringResource(id = R.string.unsupported_version_description),
+ statusLevel = StatusLevel.Error,
+ action =
+ if (IS_PLAY_BUILD) null
+ else NotificationAction(R.drawable.icon_extlink, onClickUpdateVersion)
+ )
+ is InAppNotification.UpdateAvailable ->
+ NotificationData(
+ title = stringResource(id = R.string.update_available),
+ message =
+ stringResource(
+ id = R.string.update_available_description,
+ versionInfo.upgradeVersion ?: "" // TODO Verify
+ ),
+ statusLevel = StatusLevel.Warning,
+ action =
+ if (IS_PLAY_BUILD) null
+ else NotificationAction(R.drawable.icon_extlink, onClickUpdateVersion)
+ )
+ }
+
+@Composable
+private fun errorMessageBannerData(error: ErrorState) =
+ error.getErrorNotificationResources(LocalContext.current).run {
+ NotificationData(
+ title = stringResource(id = titleResourceId),
+ message = optionalMessageArgument?.let { stringResource(id = messageResourceId, it) }
+ ?: stringResource(id = messageResourceId),
+ statusLevel = StatusLevel.Error,
+ action = null
+ )
+ }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt
index e57e9be563..b88f0a86ba 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt
@@ -14,6 +14,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.DialogProperties
import androidx.core.text.HtmlCompat
@@ -62,7 +63,7 @@ fun InfoDialog(message: String, additionalInfo: String? = null, onDismiss: () ->
Spacer(modifier = Modifier.height(Dimens.verticalSpace))
val htmlFormattedString =
HtmlCompat.fromHtml(additionalInfo, HtmlCompat.FROM_HTML_MODE_COMPACT)
- val annotated = htmlFormattedString.toAnnotatedString()
+ val annotated = htmlFormattedString.toAnnotatedString(FontWeight.Bold)
// fromHtml may add a trailing newline when using HTML tags, so we remove it
val trimmed = annotated.substring(0, annotated.trimEnd().length)
Text(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt
index d5cdaf0f88..6c294e6207 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt
@@ -24,3 +24,20 @@ fun Spanned.toAnnotatedString(boldFontWeight: FontWeight = FontWeight.Bold): Ann
}
}
}
+
+fun Spanned.toAnnotatedString(
+ boldSpanStyle: SpanStyle = SpanStyle(fontWeight = FontWeight.ExtraBold)
+): AnnotatedString = buildAnnotatedString {
+ val spanned = this@toAnnotatedString
+ append(spanned.toString())
+ getSpans(0, spanned.length, Any::class.java).forEach { span ->
+ val start = getSpanStart(span)
+ val end = getSpanEnd(span)
+ when (span) {
+ is StyleSpan ->
+ when (span.style) {
+ Typeface.BOLD -> addStyle(boldSpanStyle, start, end)
+ }
+ }
+ }
+}
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 a0beecb655..b2a6bc9b9e 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
@@ -35,9 +35,9 @@ 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.component.ScaffoldWithTopBarAndDeviceName
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
+import net.mullvad.mullvadvpn.compose.component.notificationbanner.NotificationBanner
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
@@ -83,7 +83,8 @@ fun ConnectScreen(
onManageAccountClick: () -> Unit = {},
onOpenOutOfTimeScreen: () -> Unit = {},
onSettingsClick: () -> Unit = {},
- onAccountClick: () -> Unit = {}
+ onAccountClick: () -> Unit = {},
+ onDismissNewDeviceClick: () -> Unit = {}
) {
val context = LocalContext.current
@@ -160,10 +161,11 @@ fun ConnectScreen(
.padding(bottom = Dimens.screenVerticalMargin)
.testTag(SCROLLABLE_COLUMN_TEST_TAG)
) {
- Notification(
- connectNotificationState = uiState.connectNotificationState,
+ NotificationBanner(
+ notification = uiState.inAppNotification,
onClickUpdateVersion = onUpdateVersionClick,
- onClickShowAccount = onManageAccountClick
+ onClickShowAccount = onManageAccountClick,
+ onClickDismissNewDevice = onDismissNewDeviceClick,
)
Spacer(modifier = Modifier.weight(1f))
if (
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
index 71ba71e54c..8439680500 100644
--- 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
@@ -1,17 +1 @@
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 93b9df5b7a..6ab4839bd1 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,6 +3,7 @@ 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.repository.InAppNotification
import net.mullvad.talpid.net.TransportProtocol
data class ConnectUiState(
@@ -13,7 +14,7 @@ data class ConnectUiState(
val inAddress: Triple<String, Int, TransportProtocol>?,
val outAddress: String,
val showLocation: Boolean,
- val connectNotificationState: ConnectNotificationState,
+ val inAppNotification: InAppNotification?,
val isTunnelInfoExpanded: Boolean,
val deviceName: String?,
val daysLeftUntilExpiry: Int?
@@ -29,7 +30,7 @@ data class ConnectUiState(
outAddress = "",
showLocation = false,
isTunnelInfoExpanded = false,
- connectNotificationState = ConnectNotificationState.HideNotification,
+ inAppNotification = null,
deviceName = null,
daysLeftUntilExpiry = null
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
index 3cf06f201b..dea9e12a3d 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
@@ -24,5 +24,6 @@ const val LOCATION_INFO_TEST_TAG = "location_info_test_tag"
// ConnectScreen - Notification banner
const val NOTIFICATION_BANNER = "notification_banner"
+const val NOTIFICATION_BANNER_ACTION = "notification_banner_action"
const val LOGIN_TITLE_TEST_TAG = "login_title_test_tag"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt
index dff48b6228..7f7e4acf45 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt
@@ -1,3 +1,4 @@
package net.mullvad.mullvadvpn.constant
const val ACCOUNT_EXPIRY_POLL_INTERVAL: Long = 15 /* s */ * 1000 /* ms */
+const val ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS = 3
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 1d4421b063..398e27820e 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
@@ -5,6 +5,7 @@ import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Messenger
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
import net.mullvad.mullvadvpn.BuildConfig
import net.mullvad.mullvadvpn.applist.ApplicationsIconManager
import net.mullvad.mullvadvpn.applist.ApplicationsProvider
@@ -13,11 +14,16 @@ import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.ChangelogRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
+import net.mullvad.mullvadvpn.repository.InAppNotificationController
import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.ui.serviceconnection.MessageHandler
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling
+import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase
import net.mullvad.mullvadvpn.util.ChangelogDataProvider
import net.mullvad.mullvadvpn.util.IChangelogDataProvider
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
@@ -78,6 +84,13 @@ val uiModule = module {
single { SettingsRepository(get()) }
single { MullvadProblemReport(get()) }
+ single { AccountExpiryNotificationUseCase(get()) }
+ single { TunnelStateNotificationUseCase(get()) }
+ single { VersionNotificationUseCase(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS) }
+ single { NewDeviceNotificationUseCase(get()) }
+
+ single { InAppNotificationController(get(), get(), get(), get(), MainScope()) }
+
single<IChangelogDataProvider> { ChangelogDataProvider(get()) }
// View models
@@ -85,12 +98,10 @@ val uiModule = module {
viewModel {
ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG)
}
- viewModel {
- ConnectViewModel(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS, get(), get())
- }
+ viewModel { ConnectViewModel(get(), get(), get(), get(), get()) }
viewModel { DeviceListViewModel(get(), get()) }
viewModel { DeviceRevokedViewModel(get(), get()) }
- viewModel { LoginViewModel(get(), get()) }
+ viewModel { LoginViewModel(get(), get(), get()) }
viewModel { PrivacyDisclaimerViewModel(get()) }
viewModel { SelectLocationViewModel(get()) }
viewModel { SettingsViewModel(get(), get()) }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt
new file mode 100644
index 0000000000..0751d0b1f7
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt
@@ -0,0 +1,85 @@
+package net.mullvad.mullvadvpn.repository
+
+import java.util.UUID
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import net.mullvad.mullvadvpn.ui.VersionInfo
+import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase
+import net.mullvad.talpid.tunnel.ErrorState
+import org.joda.time.DateTime
+
+enum class StatusLevel {
+ Error,
+ Warning,
+ Info,
+}
+
+sealed class InAppNotification {
+ val uuid: UUID = UUID.randomUUID()
+ abstract val statusLevel: StatusLevel
+ abstract val priority: Long
+
+ data class TunnelStateError(val error: ErrorState) : InAppNotification() {
+ override val statusLevel = StatusLevel.Error
+ override val priority: Long = 1001
+ }
+
+ data object TunnelStateBlocked : InAppNotification() {
+ override val statusLevel = StatusLevel.Error
+ override val priority: Long = 1000
+ }
+
+ data class UnsupportedVersion(val versionInfo: VersionInfo) : InAppNotification() {
+ override val statusLevel = StatusLevel.Error
+ override val priority: Long = 999
+ }
+
+ data class AccountExpiry(val expiry: DateTime) : InAppNotification() {
+ override val statusLevel = StatusLevel.Warning
+ override val priority: Long = 1001
+ }
+
+ data class NewDevice(val deviceName: String) : InAppNotification() {
+ override val statusLevel = StatusLevel.Info
+ override val priority: Long = 1001
+ }
+
+ data class UpdateAvailable(val versionInfo: VersionInfo) : InAppNotification() {
+ override val statusLevel = StatusLevel.Info
+ override val priority: Long = 1000
+ }
+}
+
+class InAppNotificationController(
+ accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase,
+ newDeviceNotificationUseCase: NewDeviceNotificationUseCase,
+ versionNotificationUseCase: VersionNotificationUseCase,
+ tunnelStateNotificationUseCase: TunnelStateNotificationUseCase,
+ scope: CoroutineScope,
+) {
+
+ val notifications =
+ combine(
+ tunnelStateNotificationUseCase.notifications(),
+ versionNotificationUseCase.notifications(),
+ accountExpiryNotificationUseCase.notifications(),
+ newDeviceNotificationUseCase.notifications(),
+ ) { a, b, c, d ->
+ a + b + c + d
+ }
+ .map {
+ it.sortedWith(
+ compareBy(
+ { notification -> notification.statusLevel.ordinal },
+ { notification -> -notification.priority }
+ )
+ )
+ }
+ .stateIn(scope, SharingStarted.Eagerly, emptyList())
+}
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 b83ce973c1..532787ff4f 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
@@ -48,7 +48,8 @@ class ConnectFragment : BaseFragment() {
onManageAccountClick = connectViewModel::onManageAccountClick,
onOpenOutOfTimeScreen = ::openOutOfTimeScreen,
onSettingsClick = ::openSettingsView,
- onAccountClick = ::openAccountView
+ onAccountClick = ::openAccountView,
+ onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification,
)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt
index 6960fd656b..acac4ae7f6 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt
@@ -3,4 +3,5 @@ package net.mullvad.mullvadvpn.ui.notification
enum class StatusLevel {
Error,
Warning,
+ Info,
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt
new file mode 100644
index 0000000000..a4961bafe7
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt
@@ -0,0 +1,31 @@
+package net.mullvad.mullvadvpn.usecase
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS
+import net.mullvad.mullvadvpn.model.AccountExpiry
+import net.mullvad.mullvadvpn.repository.AccountRepository
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import org.joda.time.DateTime
+
+class AccountExpiryNotificationUseCase(
+ private val accountRepository: AccountRepository,
+) {
+ fun notifications(): Flow<List<InAppNotification>> =
+ accountRepository.accountExpiryState
+ .map(::accountExpiryNotification)
+ .map(::listOfNotNull)
+ .distinctUntilChanged()
+
+ private fun accountExpiryNotification(accountExpiry: AccountExpiry) =
+ if (accountExpiry.isCloseToExpiring()) {
+ InAppNotification.AccountExpiry(accountExpiry.date() ?: DateTime.now())
+ } else null
+
+ private fun AccountExpiry.isCloseToExpiring(): Boolean {
+ val threeDaysFromNow =
+ DateTime.now().plusDays(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS)
+ return this.date()?.isBefore(threeDaysFromNow) == true
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt
new file mode 100644
index 0000000000..628cc555ec
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt
@@ -0,0 +1,32 @@
+package net.mullvad.mullvadvpn.usecase
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import net.mullvad.mullvadvpn.repository.DeviceRepository
+import net.mullvad.mullvadvpn.repository.InAppNotification
+
+class NewDeviceNotificationUseCase(private val deviceRepository: DeviceRepository) {
+ private val _mutableShowNewDeviceNotification = MutableStateFlow(false)
+
+ fun notifications() =
+ combine(
+ deviceRepository.deviceState.map { it.deviceName() }.distinctUntilChanged(),
+ _mutableShowNewDeviceNotification
+ ) { deviceName, newDeviceCreated ->
+ if (newDeviceCreated && deviceName != null) {
+ InAppNotification.NewDevice(deviceName)
+ } else null
+ }
+ .map(::listOfNotNull)
+ .distinctUntilChanged()
+
+ fun newDeviceCreated() {
+ _mutableShowNewDeviceNotification.value = true
+ }
+
+ fun clearNewDeviceCreatedNotification() {
+ _mutableShowNewDeviceNotification.value = false
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt
new file mode 100644
index 0000000000..f228bd7dbe
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt
@@ -0,0 +1,47 @@
+package net.mullvad.mullvadvpn.usecase
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
+import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault
+import net.mullvad.talpid.tunnel.ActionAfterDisconnect
+
+class TunnelStateNotificationUseCase(
+ private val serviceConnectionManager: ServiceConnectionManager,
+) {
+ fun notifications(): Flow<List<InAppNotification>> =
+ serviceConnectionManager.connectionState
+ .flatMapReadyConnectionOrDefault(flowOf(emptyList())) {
+ it.container.connectionProxy
+ .tunnelUiStateFlow()
+ .distinctUntilChanged()
+ .map(::tunnelStateNotification)
+ .map(::listOfNotNull)
+ }
+ .distinctUntilChanged()
+
+ private fun tunnelStateNotification(tunnelUiState: TunnelState): InAppNotification? =
+ when (tunnelUiState) {
+ is TunnelState.Connecting -> InAppNotification.TunnelStateBlocked
+ is TunnelState.Disconnecting -> {
+ if (
+ tunnelUiState.actionAfterDisconnect == ActionAfterDisconnect.Block ||
+ tunnelUiState.actionAfterDisconnect == ActionAfterDisconnect.Reconnect
+ ) {
+ InAppNotification.TunnelStateBlocked
+ } else null
+ }
+ is TunnelState.Error -> InAppNotification.TunnelStateError(tunnelUiState.errorState)
+ is TunnelState.Connected,
+ TunnelState.Disconnected -> null
+ }
+
+ private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> =
+ callbackFlowFromNotifier(this.onUiStateChange)
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt
new file mode 100644
index 0000000000..28496c4639
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt
@@ -0,0 +1,48 @@
+package net.mullvad.mullvadvpn.usecase
+
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import net.mullvad.mullvadvpn.ui.VersionInfo
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.util.appVersionCallbackFlow
+import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault
+
+class VersionNotificationUseCase(
+ private val serviceConnectionManager: ServiceConnectionManager,
+ private val isVersionInfoNotificationEnabled: Boolean,
+) {
+
+ fun notifications() =
+ serviceConnectionManager.connectionState
+ .flatMapReadyConnectionOrDefault(flowOf(emptyList())) {
+ it.container.appVersionInfoCache.appVersionCallbackFlow().map { versionInfo ->
+ listOfNotNull(
+ unsupportedVersionNotification(versionInfo),
+ updateAvailableNotification(versionInfo)
+ )
+ }
+ }
+ .distinctUntilChanged()
+
+ private fun updateAvailableNotification(versionInfo: VersionInfo): InAppNotification? {
+ if (!isVersionInfoNotificationEnabled) {
+ return null
+ }
+
+ return if (versionInfo.isOutdated) {
+ InAppNotification.UpdateAvailable(versionInfo)
+ } else null
+ }
+
+ private fun unsupportedVersionNotification(versionInfo: VersionInfo): InAppNotification? {
+ if (!isVersionInfoNotificationEnabled) {
+ return null
+ }
+
+ return if (!versionInfo.isSupported) {
+ InAppNotification.UnsupportedVersion(versionInfo)
+ } else null
+ }
+}
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 01ba71ff86..8a4f087d64 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
@@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.map
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.repository.DeviceRepository
-import net.mullvad.mullvadvpn.ui.VersionInfo
+import net.mullvad.mullvadvpn.repository.InAppNotificationController
import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy
import net.mullvad.mullvadvpn.ui.serviceconnection.LocationInfoCache
import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener
@@ -35,7 +33,7 @@ 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.usecase.NewDeviceNotificationUseCase
import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
import net.mullvad.mullvadvpn.util.combine
import net.mullvad.mullvadvpn.util.daysFromNow
@@ -43,14 +41,14 @@ import net.mullvad.mullvadvpn.util.toInAddress
import net.mullvad.mullvadvpn.util.toOutAddress
import net.mullvad.talpid.tunnel.ActionAfterDisconnect
import net.mullvad.talpid.tunnel.ErrorStateCause
-import org.joda.time.DateTime
@OptIn(FlowPreview::class)
class ConnectViewModel(
private val serviceConnectionManager: ServiceConnectionManager,
- private val isVersionInfoNotificationEnabled: Boolean,
accountRepository: AccountRepository,
private val deviceRepository: DeviceRepository,
+ private val inAppNotificationController: InAppNotificationController,
+ private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase
) : ViewModel() {
private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1)
val uiSideEffect = _uiSideEffect.asSharedFlow()
@@ -74,7 +72,7 @@ class ConnectViewModel(
combine(
serviceConnection.locationInfoCache.locationCallbackFlow(),
serviceConnection.relayListListener.relayListCallbackFlow(),
- serviceConnection.appVersionInfoCache.appVersionCallbackFlow(),
+ inAppNotificationController.notifications,
serviceConnection.connectionProxy.tunnelUiStateFlow(),
serviceConnection.connectionProxy.tunnelRealStateFlow(),
accountRepository.accountExpiryState,
@@ -83,7 +81,7 @@ class ConnectViewModel(
) {
location,
relayLocation,
- versionInfo,
+ notifications,
tunnelUiState,
tunnelRealState,
accountExpiry,
@@ -125,12 +123,7 @@ class ConnectViewModel(
is TunnelState.Connected -> false
is TunnelState.Error -> true
},
- connectNotificationState =
- evaluateNotificationState(
- tunnelUiState = tunnelUiState,
- versionInfo = versionInfo,
- accountExpiry = accountExpiry
- ),
+ inAppNotification = notifications.firstOrNull(),
deviceName = deviceName,
daysLeftUntilExpiry = accountExpiry.date()?.daysFromNow()
)
@@ -155,36 +148,6 @@ class ConnectViewModel(
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
- }
-
private fun TunnelState.isTunnelErrorStateDueToExpiredAccount(): Boolean {
return ((this as? TunnelState.Error)?.errorState?.cause as? ErrorStateCause.AuthFailed)
?.isCausedByExpiredAccount()
@@ -221,6 +184,10 @@ class ConnectViewModel(
}
}
+ fun dismissNewDeviceNotification() {
+ newDeviceNotificationUseCase.clearNewDeviceCreatedNotification()
+ }
+
sealed interface UiSideEffect {
data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
index 953e59f388..b31478ce1a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
@@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.model.AccountToken
import net.mullvad.mullvadvpn.model.LoginResult
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
+import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L
@@ -38,6 +39,7 @@ sealed interface LoginUiSideEffect {
class LoginViewModel(
private val accountRepository: AccountRepository,
private val deviceRepository: DeviceRepository,
+ private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
private val _loginState = MutableStateFlow(LoginUiState.INITIAL.loginState)
@@ -85,6 +87,7 @@ class LoginViewModel(
delay(1000)
_uiSideEffect.emit(LoginUiSideEffect.NavigateToConnect)
}
+ newDeviceNotificationUseCase.newDeviceCreated()
Success
}
LoginResult.InvalidAccount -> Idle(LoginError.InvalidCredentials)
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt
new file mode 100644
index 0000000000..30b54cea11
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt
@@ -0,0 +1,102 @@
+package net.mullvad.mullvadvpn
+
+import app.cash.turbine.test
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlin.test.assertEquals
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import net.mullvad.mullvadvpn.repository.InAppNotificationController
+import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase
+import net.mullvad.talpid.tunnel.ErrorState
+import org.joda.time.DateTime
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class InAppNotificationControllerTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private lateinit var inAppNotificationController: InAppNotificationController
+ private val accountExpiryNotifications = MutableStateFlow(emptyList<InAppNotification>())
+ private val newDeviceNotifications = MutableStateFlow(emptyList<InAppNotification.NewDevice>())
+ private val versionNotifications = MutableStateFlow(emptyList<InAppNotification>())
+ private val tunnelStateNotifications = MutableStateFlow(emptyList<InAppNotification>())
+
+ private lateinit var job: Job
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ val accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase = mockk()
+ val newDeviceNotificationUseCase: NewDeviceNotificationUseCase = mockk()
+ val versionNotificationUseCase: VersionNotificationUseCase = mockk()
+ val tunnelStateNotificationUseCase: TunnelStateNotificationUseCase = mockk()
+ every { accountExpiryNotificationUseCase.notifications() } returns
+ accountExpiryNotifications
+ every { newDeviceNotificationUseCase.notifications() } returns newDeviceNotifications
+ every { versionNotificationUseCase.notifications() } returns versionNotifications
+ every { tunnelStateNotificationUseCase.notifications() } returns tunnelStateNotifications
+ job = Job()
+
+ inAppNotificationController =
+ InAppNotificationController(
+ accountExpiryNotificationUseCase,
+ newDeviceNotificationUseCase,
+ versionNotificationUseCase,
+ tunnelStateNotificationUseCase,
+ CoroutineScope(job + testCoroutineRule.testDispatcher)
+ )
+ }
+
+ @After
+ fun teardown() {
+ job.cancel()
+ unmockkAll()
+ }
+
+ @Test
+ fun `ensure all notifications have the right priority`() = runTest {
+ val newDevice = InAppNotification.NewDevice("")
+ newDeviceNotifications.value = listOf(newDevice)
+
+ val errorState: ErrorState = mockk()
+ val tunnelStateBlocked = InAppNotification.TunnelStateBlocked
+ val tunnelStateError = InAppNotification.TunnelStateError(errorState)
+ tunnelStateNotifications.value = listOf(tunnelStateBlocked, tunnelStateError)
+
+ val unsupportedVersion = InAppNotification.UnsupportedVersion(mockk())
+ val updateAvailable = InAppNotification.UpdateAvailable(mockk())
+ versionNotifications.value = listOf(unsupportedVersion, updateAvailable)
+
+ val accountExpiry = InAppNotification.AccountExpiry(DateTime.now())
+ accountExpiryNotifications.value = listOf(accountExpiry)
+
+ inAppNotificationController.notifications.test {
+ val notifications = awaitItem()
+
+ assertEquals(
+ listOf(
+ tunnelStateError,
+ tunnelStateBlocked,
+ unsupportedVersion,
+ accountExpiry,
+ newDevice,
+ updateAvailable,
+ ),
+ notifications
+ )
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt
new file mode 100644
index 0000000000..5341708d3b
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt
@@ -0,0 +1,75 @@
+package net.mullvad.mullvadvpn.usecase
+
+import app.cash.turbine.test
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.AccountExpiry
+import net.mullvad.mullvadvpn.repository.AccountRepository
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import org.joda.time.DateTime
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class AccountExpiryNotificationUseCaseTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val accountExpiry = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing)
+ private lateinit var accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ val accountRepository = mockk<AccountRepository>()
+ every { accountRepository.accountExpiryState } returns accountExpiry
+
+ accountExpiryNotificationUseCase = AccountExpiryNotificationUseCase(accountRepository)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `ensure notifications are empty by default`() = runTest {
+ // Arrange, Act, Assert
+ accountExpiryNotificationUseCase.notifications().test {
+ assertTrue { awaitItem().isEmpty() }
+ }
+ }
+
+ @Test
+ fun `ensure account expiry within 3 days generates notification`() = runTest {
+ // Arrange, Act, Assert
+ accountExpiryNotificationUseCase.notifications().test {
+ assertTrue { awaitItem().isEmpty() }
+ val closeToExpiry = AccountExpiry.Available(DateTime.now().plusDays(2))
+ accountExpiry.value = closeToExpiry
+
+ assertEquals(
+ listOf(InAppNotification.AccountExpiry(closeToExpiry.expiryDateTime)),
+ awaitItem()
+ )
+ }
+ }
+
+ @Test
+ fun `ensure an expire of 4 days in the future does not produce a notification`() = runTest {
+ // Arrange, Act, Assert
+ accountExpiryNotificationUseCase.notifications().test {
+ assertTrue { awaitItem().isEmpty() }
+ accountExpiry.value = AccountExpiry.Available(DateTime.now().plusDays(4))
+ expectNoEvents()
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt
new file mode 100644
index 0000000000..bd375d729a
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt
@@ -0,0 +1,81 @@
+package net.mullvad.mullvadvpn.usecase
+
+import app.cash.turbine.test
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.AccountAndDevice
+import net.mullvad.mullvadvpn.model.Device
+import net.mullvad.mullvadvpn.model.DeviceState
+import net.mullvad.mullvadvpn.repository.DeviceRepository
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class NewDeviceUseNotificationCaseTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val deviceName = "Frank Zebra"
+ private val deviceState =
+ MutableStateFlow<DeviceState>(
+ DeviceState.LoggedIn(
+ accountAndDevice = AccountAndDevice("", Device("", deviceName, byteArrayOf(), ""))
+ )
+ )
+ private lateinit var newDeviceNotificationUseCase: NewDeviceNotificationUseCase
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+
+ val mockDeviceRepository: DeviceRepository = mockk()
+ every { mockDeviceRepository.deviceState } returns deviceState
+ newDeviceNotificationUseCase =
+ NewDeviceNotificationUseCase(deviceRepository = mockDeviceRepository)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `ensure empty by default`() = runTest {
+ // Arrange, Act, Assert
+ newDeviceNotificationUseCase.notifications().test { assertTrue { awaitItem().isEmpty() } }
+ }
+
+ @Test
+ fun `ensure NewDevice notification is created and contains device name`() = runTest {
+ newDeviceNotificationUseCase.notifications().test {
+ // Arrange, Act
+ awaitItem()
+ newDeviceNotificationUseCase.newDeviceCreated()
+
+ // Assert
+ assertEquals(awaitItem(), listOf(InAppNotification.NewDevice(deviceName)))
+ }
+ }
+
+ @Test
+ fun `ensure NewDevice notification is cleared`() = runTest {
+ newDeviceNotificationUseCase.notifications().test {
+ // Arrange, Act
+ awaitItem()
+ newDeviceNotificationUseCase.newDeviceCreated()
+ awaitItem()
+ newDeviceNotificationUseCase.clearNewDeviceCreatedNotification()
+
+ // Assert
+ assertEquals(awaitItem(), emptyList())
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt
new file mode 100644
index 0000000000..1b89c92be7
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt
@@ -0,0 +1,94 @@
+package net.mullvad.mullvadvpn.usecase
+
+import app.cash.turbine.test
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+import net.mullvad.talpid.tunnel.ActionAfterDisconnect
+import net.mullvad.talpid.tunnel.ErrorState
+import net.mullvad.talpid.util.EventNotifier
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class TunnelStateNotificationUseCaseTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+ private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
+ private val mockConnectionProxy: ConnectionProxy = mockk()
+
+ private val serviceConnectionState =
+ MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
+ private lateinit var tunnelStateNotificationUseCase: TunnelStateNotificationUseCase
+
+ private val eventNotifierTunnelUiState = EventNotifier<TunnelState>(TunnelState.Disconnected)
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState
+
+ every { mockServiceConnectionManager.connectionState } returns serviceConnectionState
+ every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy
+
+ tunnelStateNotificationUseCase =
+ TunnelStateNotificationUseCase(serviceConnectionManager = mockServiceConnectionManager)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `ensure notifications are empty by default`() = runTest {
+ // Arrange, Act, Assert
+ tunnelStateNotificationUseCase.notifications().test { assertTrue { awaitItem().isEmpty() } }
+ }
+
+ @Test
+ fun `ensure TunnelState with error will produce TunnelStateError notification`() = runTest {
+ tunnelStateNotificationUseCase.notifications().test {
+ // Arrange, Act
+ assertEquals(emptyList(), awaitItem())
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ val errorState: ErrorState = mockk()
+ eventNotifierTunnelUiState.notify(TunnelState.Error(errorState))
+
+ // Assert
+ assertEquals(listOf(InAppNotification.TunnelStateError(errorState)), awaitItem())
+ }
+ }
+
+ @Test
+ fun `ensure disconnecting TunnelState with blocking will produce TunnelStateBlocked notification`() =
+ runTest {
+ tunnelStateNotificationUseCase.notifications().test {
+ // Arrange, Act
+ assertEquals(emptyList(), awaitItem())
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ eventNotifierTunnelUiState.notify(
+ TunnelState.Disconnecting(ActionAfterDisconnect.Block)
+ )
+
+ // Assert
+ assertEquals(listOf(InAppNotification.TunnelStateBlocked), awaitItem())
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt
new file mode 100644
index 0000000000..5aba70c938
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt
@@ -0,0 +1,114 @@
+package net.mullvad.mullvadvpn.usecase
+
+import app.cash.turbine.test
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import net.mullvad.mullvadvpn.ui.VersionInfo
+import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache
+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.util.appVersionCallbackFlow
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class VersionNotificationUseCaseTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+ private lateinit var mockAppVersionInfoCache: AppVersionInfoCache
+ private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
+
+ private val serviceConnectionState =
+ MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
+ private val versionInfo =
+ MutableStateFlow(
+ VersionInfo(
+ currentVersion = null,
+ upgradeVersion = null,
+ isOutdated = false,
+ isSupported = true
+ )
+ )
+ private lateinit var versionNotificationUseCase: VersionNotificationUseCase
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ mockkStatic(CACHE_EXTENSION_CLASS)
+ mockAppVersionInfoCache =
+ mockk<AppVersionInfoCache>().apply {
+ every { appVersionCallbackFlow() } returns versionInfo
+ }
+
+ every { mockServiceConnectionManager.connectionState } returns serviceConnectionState
+ every { mockServiceConnectionContainer.appVersionInfoCache } returns mockAppVersionInfoCache
+ every { mockAppVersionInfoCache.onUpdate = any() } answers {}
+
+ versionNotificationUseCase =
+ VersionNotificationUseCase(
+ serviceConnectionManager = mockServiceConnectionManager,
+ isVersionInfoNotificationEnabled = true
+ )
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `ensure notifications are empty by default`() = runTest {
+ // Arrange, Act, Assert
+ versionNotificationUseCase.notifications().test { assertTrue { awaitItem().isEmpty() } }
+ }
+
+ @Test
+ fun `ensure UpdateAvailable notification is created`() = runTest {
+ versionNotificationUseCase.notifications().test {
+ // Arrange, Act
+ val upgradeVersionInfo =
+ VersionInfo("1.0", "1.1", isOutdated = true, isSupported = true)
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ awaitItem()
+ versionInfo.value = upgradeVersionInfo
+
+ // Assert
+ assertEquals(awaitItem(), listOf(InAppNotification.UpdateAvailable(upgradeVersionInfo)))
+ }
+ }
+
+ @Test
+ fun `ensure UnsupportedVersion notification is created`() = runTest {
+ versionNotificationUseCase.notifications().test {
+ // Arrange, Act
+ val upgradeVersionInfo = VersionInfo("1.0", "", isOutdated = false, isSupported = false)
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ awaitItem()
+ versionInfo.value = upgradeVersionInfo
+
+ // Assert
+ assertEquals(
+ awaitItem(),
+ listOf(InAppNotification.UnsupportedVersion(upgradeVersionInfo))
+ )
+ }
+ }
+
+ companion object {
+ private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt"
+ }
+}
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 bddaee353e..5839e575c1 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
@@ -11,12 +11,12 @@ import io.mockk.unmockkAll
import io.mockk.verify
import kotlin.test.assertEquals
import kotlin.test.assertIs
+import kotlin.test.assertTrue
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
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
@@ -27,6 +27,8 @@ import net.mullvad.mullvadvpn.relaylist.RelayCountry
import net.mullvad.mullvadvpn.relaylist.RelayItem
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
+import net.mullvad.mullvadvpn.repository.InAppNotification
+import net.mullvad.mullvadvpn.repository.InAppNotificationController
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache
import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache
@@ -42,8 +44,6 @@ import net.mullvad.mullvadvpn.util.appVersionCallbackFlow
import net.mullvad.talpid.tunnel.ErrorState
import net.mullvad.talpid.tunnel.ErrorStateCause
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
@@ -68,6 +68,7 @@ class ConnectViewModelTest {
)
private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing)
private val deviceState = MutableStateFlow<DeviceState>(DeviceState.Initial)
+ private val notifications = MutableStateFlow<List<InAppNotification>>(emptyList())
// Service connections
private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
@@ -83,6 +84,9 @@ class ConnectViewModelTest {
// Device Repository
private val mockDeviceRepository: DeviceRepository = mockk()
+ // In App Notifications
+ private val mockInAppNotificationController: InAppNotificationController = mockk()
+
// Captures
private val locationSlot = slot<((GeoIpLocation?) -> Unit)>()
private val relaySlot = slot<(List<RelayCountry>, RelayItem?) -> Unit>()
@@ -111,6 +115,8 @@ class ConnectViewModelTest {
every { mockDeviceRepository.deviceState } returns deviceState
+ every { mockInAppNotificationController.notifications } returns notifications
+
every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState
every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState
@@ -126,7 +132,8 @@ class ConnectViewModelTest {
serviceConnectionManager = mockServiceConnectionManager,
accountRepository = mockAccountRepository,
deviceRepository = mockDeviceRepository,
- isVersionInfoNotificationEnabled = true
+ inAppNotificationController = mockInAppNotificationController,
+ newDeviceNotificationUseCase = mockk()
)
}
@@ -144,8 +151,6 @@ class ConnectViewModelTest {
@Test
fun testTunnelInfoExpandedUpdate() =
runTest(testCoroutineRule.testDispatcher) {
- val expectedResult = true
-
viewModel.uiState.test {
assertEquals(ConnectUiState.INITIAL, awaitItem())
serviceConnectionState.value =
@@ -154,7 +159,7 @@ class ConnectViewModelTest {
relaySlot.captured.invoke(mockk(), mockk())
viewModel.toggleTunnelInfoExpansion()
val result = awaitItem()
- assertEquals(expectedResult, result.isTunnelInfoExpanded)
+ assertTrue(result.isTunnelInfoExpanded)
}
}
@@ -288,34 +293,14 @@ class ConnectViewModelTest {
}
@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)
+ InAppNotification.TunnelStateError(mockErrorState)
val tunnelUiState = TunnelState.Error(mockErrorState)
+ notifications.value = listOf(expectedConnectNotificationState)
// Act, Assert
viewModel.uiState.test {
@@ -326,53 +311,7 @@ class ConnectViewModelTest {
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
- every { mockDateTime.toInstant().millis } returns 0
-
- // 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)
+ assertEquals(expectedConnectNotificationState, result.inAppNotification)
}
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
index 744989a922..2ada5bf767 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
@@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.model.DeviceListEvent
import net.mullvad.mullvadvpn.model.LoginResult
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
+import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
@@ -34,6 +35,7 @@ class LoginViewModelTest {
@MockK private lateinit var mockedAccountRepository: AccountRepository
@MockK private lateinit var mockedDeviceRepository: DeviceRepository
+ @MockK private lateinit var mockedNewDeviceNotificationUseCase: NewDeviceNotificationUseCase
private lateinit var loginViewModel: LoginViewModel
private val accountHistoryTestEvents = MutableStateFlow<AccountHistory>(AccountHistory.Missing)
@@ -44,11 +46,13 @@ class LoginViewModelTest {
MockKAnnotations.init(this, relaxUnitFun = true)
every { mockedAccountRepository.accountHistory } returns accountHistoryTestEvents
+ every { mockedNewDeviceNotificationUseCase.newDeviceCreated() } returns Unit
loginViewModel =
LoginViewModel(
mockedAccountRepository,
mockedDeviceRepository,
+ mockedNewDeviceNotificationUseCase,
UnconfinedTestDispatcher()
)
}
diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt
index f009f4857b..bbdd2a56a5 100644
--- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt
+++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt
@@ -1,21 +1,7 @@
package net.mullvad.mullvadvpn.lib.common.util
-import android.content.res.Resources
-
data class ErrorNotificationMessage(
val titleResourceId: Int,
val messageResourceId: Int,
val optionalMessageArgument: String? = null
-) {
- fun getTitleText(resources: Resources): String {
- return resources.getString(titleResourceId)
- }
-
- fun getMessageText(resources: Resources): String {
- return if (optionalMessageArgument != null) {
- resources.getString(messageResourceId, optionalMessageArgument)
- } else {
- resources.getString(messageResourceId)
- }
- }
-}
+)
diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml
index b98455f7d9..64a7243b03 100644
--- a/android/lib/resource/src/main/res/values-da/strings.xml
+++ b/android/lib/resource/src/main/res/values-da/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Log ud af mindst én ved at fjerne den fra listen nedenfor. Du kan finde det tilsvarende enhedsnavn under enhedens kontoindstillinger.</string>
<string name="max_devices_warning_title">For mange enheder</string>
<string name="mullvad_account_number">Mullvad-kontonummer</string>
+ <string name="new_device_notification_message">Velkommen! Denne enhed hedder nu &lt;b&gt;%1$s&lt;/b&gt;. Se info-knappen i Konto for at flere oplysninger.</string>
+ <string name="new_device_notification_title">NY ENHED OPRETTET</string>
<string name="no_matching_relay">Ingen servere matcher dine indstillinger. Prøv at ændre server eller andre indstillinger.</string>
<string name="no_wireguard_key">Gyldig WireGuard-nøgle mangler. Administrer nøgler under Avancerede indstillinger.</string>
<string name="not_blocking_internet">DU LÆKKER MÅSKE NETVÆRKSTRAFIK</string>
diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml
index 19120efa7b..0f1bbdade0 100644
--- a/android/lib/resource/src/main/res/values-de/strings.xml
+++ b/android/lib/resource/src/main/res/values-de/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Bitte melden Sie sich von mindestens einem Gerät ab, indem Sie es aus der Liste unten entfernen. Sie finden den entsprechenden Gerätenamen unter den Kontoeinstellungen des Geräts.</string>
<string name="max_devices_warning_title">Zu viele Geräte</string>
<string name="mullvad_account_number">Mullvad-Kontonummer</string>
+ <string name="new_device_notification_message">Dieses Gerät heißt jetzt &lt;b&gt;%1$s&lt;/b&gt;. Weitere Details finden Sie über die Info-Schaltfläche in Ihrem Konto.</string>
+ <string name="new_device_notification_title">NEUES GERÄT ERSTELLT</string>
<string name="no_matching_relay">Kein Server entspricht Ihren Einstellungen. Versuchen Sie, den Server oder andere Einstellungen zu ändern.</string>
<string name="no_wireguard_key">Gültiger WireGuard-Schlüssel fehlt. Sie können Ihre Schlüssel in den erweiterten Einstellungen verwalten.</string>
<string name="not_blocking_internet">MÖGLICHERWEISE IST IHR NETZWERKVERKEHR UNSICHER</string>
diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml
index e5c5f7d657..a1122c7cf6 100644
--- a/android/lib/resource/src/main/res/values-es/strings.xml
+++ b/android/lib/resource/src/main/res/values-es/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Cierre la sesión como mínimo en un dispositivo (para hacerlo, quítelo de la lista siguiente). Consulte el nombre del dispositivo en la configuración de la cuenta del dispositivo.</string>
<string name="max_devices_warning_title">Demasiados dispositivos</string>
<string name="mullvad_account_number">Número de cuenta de Mullvad</string>
+ <string name="new_device_notification_message">Hola, este dispositivo se llama ahora &lt;b&gt;%1$s&lt;/b&gt;. Para más información, consulte el botón de información en la Cuenta.</string>
+ <string name="new_device_notification_title">NUEVO DISPOSITIVO CREADO</string>
<string name="no_matching_relay">Ningún servidor coincide con su configuración, pruebe con otro servidor u otra configuración.</string>
<string name="no_wireguard_key">Falta una clave de WireGuard válida. Para administrar las claves, vaya a Configuración avanzada.</string>
<string name="not_blocking_internet">PUEDE QUE SE ESTÉ FILTRANDO EL TRÁFICO DE RED</string>
diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml
index 379d8dd4bb..ac68c16284 100644
--- a/android/lib/resource/src/main/res/values-fi/strings.xml
+++ b/android/lib/resource/src/main/res/values-fi/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Kirjaudu ulos vähintään yhdestä luettelon laitteesta poistamalla se. Löydät vastaavan laitteen nimen laitteen tiliasetuksista.</string>
<string name="max_devices_warning_title">Liikaa laitteita</string>
<string name="mullvad_account_number">Mullvad-tilin numero</string>
+ <string name="new_device_notification_message">Tervetuloa! Tämän laitteen nimi on nyt &lt;b&gt;%1$s&lt;/b&gt;. Katso lisätietoja tilin infopainikkeesta.</string>
+ <string name="new_device_notification_title">UUSI LAITE LUOTIIN</string>
<string name="no_matching_relay">Mikään palvelin ei vastaa asetuksiasi. Kokeile vaihtaa palvelinta tai muuttaa muita asetuksia.</string>
<string name="no_wireguard_key">Käypä WireGuard-avain puuttuu. Voit hallinnoida avaimia lisäasetuksissa.</string>
<string name="not_blocking_internet">VERKKOLIIKENTEESI SAATTAA VUOTAA</string>
diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml
index 9da5482c92..3ca0a3dc49 100644
--- a/android/lib/resource/src/main/res/values-fr/strings.xml
+++ b/android/lib/resource/src/main/res/values-fr/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Merci de vous déconnecter d\'au moins un appareil en le supprimant de la liste ci-dessous. Vous trouverez le nom de l\'appareil correspondant dans les paramètres du compte de l\'appareil.</string>
<string name="max_devices_warning_title">Trop d\'appareils</string>
<string name="mullvad_account_number">Numéro de compte Mullvad</string>
+ <string name="new_device_notification_message">Bienvenue, cet appareil s\'appelle désormais &lt;b&gt;%1$s&lt;/b&gt;. Pour plus d\'informations, consultez le bouton d\'information sous Compte.</string>
+ <string name="new_device_notification_title">NOUVEL APPAREIL CRÉÉ</string>
<string name="no_matching_relay">Aucun serveur ne correspond à vos paramètres, essayez de modifier les paramètres du serveur ou d\'autres paramètres.</string>
<string name="no_wireguard_key">Une clé WireGuard valide manque. Gérez les clés dans les paramètres avancés.</string>
<string name="not_blocking_internet">VOUS POURRIEZ AVOIR DES FUITES DE TRAFIC RÉSEAU</string>
diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml
index e91aaecdb9..6362a17086 100644
--- a/android/lib/resource/src/main/res/values-it/strings.xml
+++ b/android/lib/resource/src/main/res/values-it/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Disconnettiti da almeno un dispositivo rimuovendolo dall\'elenco seguente. Puoi trovare il nome del dispositivo corrispondente nelle impostazioni dell\'account del dispositivo.</string>
<string name="max_devices_warning_title">Troppi dispositivi</string>
<string name="mullvad_account_number">Numero di account Mullvad</string>
+ <string name="new_device_notification_message">Benvenuto, questo dispositivo ora si chiama &lt;b&gt;%1$s&lt;/b&gt;. Per maggiori dettagli, premi il pulsante delle informazioni in Account.</string>
+ <string name="new_device_notification_title">NUOVO DISPOSITIVO CREATO</string>
<string name="no_matching_relay">Nessun server corrispondente alle tue impostazioni, prova a cambiare server o impostazioni.</string>
<string name="no_wireguard_key">Manca una chiave WireGuard valida. Gestisci le chiavi da Impostazioni avanzate.</string>
<string name="not_blocking_internet">POSSIBILI PERDITE NEL TRAFFICO DI RETE</string>
diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml
index 3112ec2b1c..061357c515 100644
--- a/android/lib/resource/src/main/res/values-ja/strings.xml
+++ b/android/lib/resource/src/main/res/values-ja/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">以下のリストから少なくとも1つを削除してログアウトしてください。対応するデバイス名はデバイスのアカウント設定で確認できます。</string>
<string name="max_devices_warning_title">デバイスが多すぎます</string>
<string name="mullvad_account_number">Mullvadアカウント番号</string>
+ <string name="new_device_notification_message">ようこそ。このデバイスの名前は&lt;b&gt;%1$s&lt;/b&gt;です。詳細はアカウントの情報ボタンで確認してください。</string>
+ <string name="new_device_notification_title">新しいデバイスが作成されました</string>
<string name="no_matching_relay">設定に一致するサーバーはありません。サーバーまたは他の設定を変更してみてください。</string>
<string name="no_wireguard_key">有効なWireGuard鍵が見つかりません。詳細設定で鍵を管理してください。</string>
<string name="not_blocking_internet">ネットワーク通信が漏洩している可能性があります</string>
diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml
index b535966911..e3e4b7b1ec 100644
--- a/android/lib/resource/src/main/res/values-ko/strings.xml
+++ b/android/lib/resource/src/main/res/values-ko/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">하나 이상의 항목을 아래 목록에서 제거하여 로그아웃하세요. 장치의 계정 설정에서 해당 장치 이름을 찾을 수 있습니다.</string>
<string name="max_devices_warning_title">장치가 너무 많음</string>
<string name="mullvad_account_number">Mullvad 계정 번호</string>
+ <string name="new_device_notification_message">환영합니다! 이제 이 장치의 이름은 &lt;b&gt;%1$s&lt;/b&gt;입니다. 자세한 내용을 보려면 계정의 정보 버튼을 누르세요.</string>
+ <string name="new_device_notification_title">새 장치가 생성됨</string>
<string name="no_matching_relay">설정과 일치하는 서버가 없습니다. 서버 또는 기타 설정을 변경해 보세요.</string>
<string name="no_wireguard_key">유효한 WireGuard 키가 없습니다. 고급 설정에서 키를 관리하세요.</string>
<string name="not_blocking_internet">네트워크 트래픽이 유출될 수 있습니다.</string>
diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml
index 6a0f2ba377..b156349756 100644
--- a/android/lib/resource/src/main/res/values-my/strings.xml
+++ b/android/lib/resource/src/main/res/values-my/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">အောက်ပါစာရင်းမှ အနည်းဆုံး တစ်ခုကို ဖယ်ရှားခြင်းဖြင့် ၎င်းမှ ထွက်ပါ။ စက်၏ အကောင့်ဆက်တင်အောက်တွင် သက်ဆိုင်သော စက်အမည်ကို သင် ရှာနိုင်သည်။</string>
<string name="max_devices_warning_title">စက်များလွန်းနေသည်</string>
<string name="mullvad_account_number">Mullvad အကောင့်နံပါတ်</string>
+ <string name="new_device_notification_message">ကြိုဆိုပါသည်၊ ယခုမှစ၍ ဤစက်ကို &lt;b&gt;%1$s&lt;/b&gt; ဟု ခေါ်ဆိုပါမည်။ နောက်ထပ်အသေးစိတ်တို့အတွက် အကောင့်တွင် အချက်အလက် ခလုတ်ကို နှိပ်၍ ကြည့်နိုင်သည်။</string>
+ <string name="new_device_notification_title">စက်အသစ် ဖန်တီးထားသည်</string>
<string name="no_matching_relay">သင့်ဆက်တင်နှင့် ကိုက်ညီသော ဆာဗာများ မရှိပါ၊ ဆာဗာ သို့မဟုတ် အခြားဆက်တင်တို့ကို ပြောင်းလဲရန် ကြိုးစားကြည့်ပါ။</string>
<string name="no_wireguard_key">အကျုံးဝင်သည့် WireGuard ကီး မရှိပါ။ အဆင့်မြင့်ဆက်တင် အောက်တွင် ကီးများကို စီမံခန့်ခွဲပါ။</string>
<string name="not_blocking_internet">ကွန်ရက် ကူးလူးမှု ပေါက်ကြားနေနိုင်ပါသည်</string>
diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml
index 6872608306..385b3bdd93 100644
--- a/android/lib/resource/src/main/res/values-nb/strings.xml
+++ b/android/lib/resource/src/main/res/values-nb/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Logg ut av minst én ved å fjerne den fra listen nedenfor. Du finner det tilsvarende enhetsnavnet under enhetens kontoinnstillinger.</string>
<string name="max_devices_warning_title">For mange enheter</string>
<string name="mullvad_account_number">Mullvad-kontonummer</string>
+ <string name="new_device_notification_message">Velkommen. Denne enheten har fått navnet &lt;b&gt;%1$s&lt;/b&gt;. For å finne ut mer kan du bruke informasjonsknappen under Konto.</string>
+ <string name="new_device_notification_title">NY ENHET OPPRETTET</string>
<string name="no_matching_relay">Ingen servere passer til innstillingene dine. Prøv å endre server eller andre innstillinger.</string>
<string name="no_wireguard_key">Det mangler en gyldig WireGuard-nøkkel. Du kan behandle nøklene under avanserte innstillinger.</string>
<string name="not_blocking_internet">DET KAN VÆRE EN NETTVERKSLEKKASJE HOS DEG</string>
diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml
index 005f1c6907..db496c3822 100644
--- a/android/lib/resource/src/main/res/values-nl/strings.xml
+++ b/android/lib/resource/src/main/res/values-nl/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Meld u bij minstens één apparaat af door het te verwijderen uit de onderstaande lijst. U kunt de bijbehorende apparaatnaam vinden in de accountinstellingen van het apparaat.</string>
<string name="max_devices_warning_title">Te veel apparaten</string>
<string name="mullvad_account_number">Mullvad-accountnummer</string>
+ <string name="new_device_notification_message">Welkom, dit apparaat heet nu &lt;b&gt;%1$s&lt;/b&gt;. Zie voor meer informatie de infoknop in Account.</string>
+ <string name="new_device_notification_title">NIEUW APPARAAT GEMAAKT</string>
<string name="no_matching_relay">Er zijn geen servers die overeenkomen met uw instellingen. Probeer een andere server of andere instellingen.</string>
<string name="no_wireguard_key">Geldige WireGuard-sleutel ontbreekt. Beheer sleutels onder Geavanceerde instellingen.</string>
<string name="not_blocking_internet">U LEKT MOGELIJK NETWERKVERKEER</string>
diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml
index 98b69a66a8..0e64f904ab 100644
--- a/android/lib/resource/src/main/res/values-pl/strings.xml
+++ b/android/lib/resource/src/main/res/values-pl/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Wyloguj się z co najmniej jednego urządzenia, usuwając je z poniższej listy. Odpowiednią nazwę urządzenia można znaleźć w ustawieniach konta urządzenia.</string>
<string name="max_devices_warning_title">Zbyt wiele urządzeń</string>
<string name="mullvad_account_number">Numer konta Mullvad</string>
+ <string name="new_device_notification_message">Witaj, to urządzenie nazywa się teraz &lt;b&gt;%1$s&lt;/b&gt;. Więcej szczegółów znajdziesz, korzystając z przycisku Informacje na koncie.</string>
+ <string name="new_device_notification_title">UTWORZONO NOWE URZĄDZENIE</string>
<string name="no_matching_relay">Żaden serwer nie odpowiada ustawieniom. Spróbuj zmienić serwer lub inne ustawienia.</string>
<string name="no_wireguard_key">Brak prawidłowego klucza WireGuard. Zarządzaj kluczami w Ustawieniach zaawansowanych.</string>
<string name="not_blocking_internet">TWÓJ RUCH SIECIOWY MOŻE WYCIEKAĆ</string>
diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml
index 5dd4fd61ea..ce3fb68f31 100644
--- a/android/lib/resource/src/main/res/values-pt/strings.xml
+++ b/android/lib/resource/src/main/res/values-pt/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Desligue-se de pelo menos um dos dispositivos removendo-o da lista abaixo. Pode encontrar o nome do dispositivo correspondente nas definições de Conta do dispositivo.</string>
<string name="max_devices_warning_title">Demasiados dispositivos</string>
<string name="mullvad_account_number">Número de conta Mullvad</string>
+ <string name="new_device_notification_message">Bem-vindo, este dispositivo é agora chamado &lt;b&gt;%1$s&lt;/b&gt;. Para mais detalhes consulte o botão de informação na Conta.</string>
+ <string name="new_device_notification_title">NOVO DISPOSITIVO CRIADO</string>
<string name="no_matching_relay">Nenhum servidor corresponde às suas definições. Tente alterar o servidor ou outras definições.</string>
<string name="no_wireguard_key">Chave WireGuard válida em falta. Faça a gestão das chaves em Definições Avançadas.</string>
<string name="not_blocking_internet">PODERÁ ESTAR A PERDER TRÁFEGO DE REDE</string>
diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml
index 7b9acc9195..14414945e5 100644
--- a/android/lib/resource/src/main/res/values-ru/strings.xml
+++ b/android/lib/resource/src/main/res/values-ru/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Выйдите из учетной записи хотя бы на одном из устройств, удалив его из списка ниже. Имя устройства указано в настройках учетной записи.</string>
<string name="max_devices_warning_title">Слишком много устройств</string>
<string name="mullvad_account_number">Номер учетной записи Mullvad</string>
+ <string name="new_device_notification_message">Добро пожаловать, теперь это устройство называется &lt;b&gt;%1$s&lt;/b&gt;. Для получения более подробной нажмите на кнопку «Информация» в учетной записи.</string>
+ <string name="new_device_notification_title">СОЗДАНО НОВОЕ УСТРОЙСТВО</string>
<string name="no_matching_relay">Нет серверов, соответствующих вашим настройкам. Попробуйте изменить сервер или задайте другие настройки.</string>
<string name="no_wireguard_key">Не найден действительный ключ WireGuard. Управлять ключами можно в дополнительных настройках.</string>
<string name="not_blocking_internet">ВОЗМОЖНА УТЕЧКА СЕТЕВОГО ТРАФИКА</string>
diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml
index d8183b2435..216828a1c1 100644
--- a/android/lib/resource/src/main/res/values-sv/strings.xml
+++ b/android/lib/resource/src/main/res/values-sv/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Logga ut på minst en enhet genom att ta bort den från listan nedan. Du hittar motsvarande enhetsnamn i enhetens kontoinställningar.</string>
<string name="max_devices_warning_title">För många enheter</string>
<string name="mullvad_account_number">Mullvad-kontonummer</string>
+ <string name="new_device_notification_message">Välkommen! Den här enheten heter nu &lt;b&gt;%1$s&lt;/b&gt;. Använd informationsknappen i Konto för mer information.</string>
+ <string name="new_device_notification_title">NY ENHET HAR SKAPATS</string>
<string name="no_matching_relay">Inga servrar matchar dina inställningar. Försök att byta server eller ändra inställningarna.</string>
<string name="no_wireguard_key">Giltig WireGuard-nyckel saknas. Hantera nycklar i Avancerade inställningar.</string>
<string name="not_blocking_internet">DU KANSKE HAR LÄCKAGE I NÄTVERKSTRAFIKEN</string>
diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml
index 7afc8a7b44..d02d60bc85 100644
--- a/android/lib/resource/src/main/res/values-th/strings.xml
+++ b/android/lib/resource/src/main/res/values-th/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">โปรดลงชื่อออกจากระบบบนอุปกรณ์อย่างน้อยหนึ่งเครื่อง เพื่อนำอุปกรณ์ออกจากรายการด้านล่าง คุณสามารถดูชื่ออุปกรณ์ที่เกี่ยวข้องได้ ภายใต้การตั้งค่าบัญชีของอุปกรณ์</string>
<string name="max_devices_warning_title">มีอุปกรณ์มากเกินไป</string>
<string name="mullvad_account_number">หมายเลขบัญชี Mullvad</string>
+ <string name="new_device_notification_message">ยินดีต้อนรับ ขณะนี้อุปกรณ์นี้จะมีชื่อว่า &lt;b&gt;%1$s&lt;/b&gt; สำหรับข้อมูลเพิ่มเติม โปรดกดปุ่มข้อมูลในบัญชี</string>
+ <string name="new_device_notification_title">สร้างอุปกรณ์ใหม่แล้ว</string>
<string name="no_matching_relay">ไม่มีเซิร์ฟเวอร์ที่ตรงกับการตั้งค่าของคุณ โปรดลองเปลี่ยนเซิร์ฟเวอร์ หรือการตั้งค่าอื่นๆ</string>
<string name="no_wireguard_key">คีย์ WireGuard ที่ใช้ได้ขาดหายไป จัดการคีย์ภายใต้การตั้งค่าขั้นสูง</string>
<string name="not_blocking_internet">คุณอาจมีการรับส่งข้อมูลทางเครือข่ายที่รั่วไหลอยู่</string>
diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml
index 908f9ce2d9..7d9ddf4f76 100644
--- a/android/lib/resource/src/main/res/values-tr/strings.xml
+++ b/android/lib/resource/src/main/res/values-tr/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">Lütfen aşağıdaki listeden en az bir cihazı kaldırarak çıkış yapın. İlgili cihaz adını cihazın Hesap ayarları altında bulabilirsiniz.</string>
<string name="max_devices_warning_title">Cihaz sayısı çok fazla</string>
<string name="mullvad_account_number">Mullvad hesap numarası</string>
+ <string name="new_device_notification_message">Hoş geldiniz, bu cihaz artık &lt;b&gt;%1$s&lt;/b&gt; olarak adlandırılıyor. Daha fazla ayrıntı için Hesaptaki bilgi düğmesine bakın.</string>
+ <string name="new_device_notification_title">YENİ CİHAZ OLUŞTURULDU</string>
<string name="no_matching_relay">Ayarlarınızla eşleşen sunucu yok. Sunucuyu veya diğer ayarları değiştirmeyi deneyin.</string>
<string name="no_wireguard_key">Geçerli WireGuard anahtarı eksik. Gelişmiş ayarlardan anahtarları yönetin.</string>
<string name="not_blocking_internet">AĞ TRAFİĞİNİZDE SIZINTI OLABİLİR</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
index 667ffbcde9..251754caa5 100644
--- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">请通过从以下列表中移除的方式退出至少一个帐户。您可以在设备的帐户设置下找到相应设备名称。</string>
<string name="max_devices_warning_title">设备过多</string>
<string name="mullvad_account_number">Mullvad 帐号</string>
+ <string name="new_device_notification_message">欢迎,此设备现在名为 &lt;b&gt;%1$s&lt;/b&gt;。有关详情,请点击“帐户”中的信息按钮。</string>
+ <string name="new_device_notification_title">已创建新设备</string>
<string name="no_matching_relay">没有与您的设置匹配的服务器,请尝试更改服务器或其他设置。</string>
<string name="no_wireguard_key">缺少有效的 WireGuard 密钥。在“高级”设置下管理密钥。</string>
<string name="not_blocking_internet">您的网络流量可能在泄露</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
index 5378b92550..322f4fa512 100644
--- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
@@ -119,6 +119,8 @@
<string name="max_devices_warning_description">請從底下清單至少移除一個裝置來將其登出。您可以在裝置的「帳戶」設定下找到相應裝置名稱。</string>
<string name="max_devices_warning_title">裝置過多</string>
<string name="mullvad_account_number">Mullvad 帳號</string>
+ <string name="new_device_notification_message">歡迎,此裝置現在稱為 &lt;b&gt;%1$s&lt;/b&gt;。如需詳細資訊,請點按「帳戶」中的資訊按鈕。</string>
+ <string name="new_device_notification_title">已建立新裝置</string>
<string name="no_matching_relay">沒有與您的設定相符的伺服器,請嘗試變更伺服器或其他設定。</string>
<string name="no_wireguard_key">缺少有效的 WireGuard 金鑰。在「進階」設定下管理金鑰。</string>
<string name="not_blocking_internet">您的網路流量可能正在洩露</string>
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index c9c837d38d..033d3b9463 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -158,6 +158,8 @@
</string>
<string name="vpn_permission_error_notification_title">VPN permission error</string>
<string name="vpn_permission_error_notification_message">Always-on VPN might be enabled for another app</string>
+ <string name="new_device_notification_title">NEW DEVICE CREATED</string>
+ <string name="new_device_notification_message"><![CDATA[Welcome, this device is now called <b>%s</b>. For more details see the info button in Account.]]></string>
<string name="agree_and_continue">Agree and continue</string>
<string name="privacy_disclaimer_title">Privacy</string>
<string name="privacy_disclaimer_body">To make sure that you have the most secure version and to inform you of any issues with the current version that is running, the app performs version checks automatically. This sends the app version and Android system version to Mullvad servers. Mullvad keeps counters on number of used app versions and Android versions. The data is never stored or used in any way that can identify you.\n\nIf the split tunneling feature is used, then the app queries your system for a list of all installed applications. This list is only retrieved in the split tunneling view. The list of installed applications is never sent from the device.</string>
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 ec2c2ff18e..3bb59368f3 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
@@ -37,7 +37,7 @@ data class Dimensions(
val loadingSpinnerStrokeWidth: Dp = 6.dp,
val loginIconContainerSize: Dp = 44.dp,
val mediumPadding: Dp = 16.dp,
- val notificationBannerEndPadding: Dp = 12.dp,
+ val notificationBannerEndPadding: Dp = 8.dp,
val notificationBannerStartPadding: Dp = 16.dp,
val notificationEndIconPadding: Dp = 4.dp,
val notificationStatusIconSize: Dp = 10.dp,