summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson90@gmail.com>2023-10-13 11:12:22 +0200
committerDavid Göransson <david.goransson90@gmail.com>2023-10-23 11:28:23 +0200
commitc085b31acdc002076106a30f7cd1dcdcd43daf05 (patch)
tree3488bf2a2745f25de2560ed734556ed04b18ed00
parent4f521533f58c9e2f80470dd084a07b50c704a1b3 (diff)
downloadmullvadvpn-c085b31acdc002076106a30f7cd1dcdcd43daf05.tar.xz
mullvadvpn-c085b31acdc002076106a30f7cd1dcdcd43daf05.zip
Rework notifications
-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/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt16
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt2
22 files changed, 628 insertions, 386 deletions
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/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/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,