diff options
| author | David Göransson <david.goransson90@gmail.com> | 2023-10-13 11:12:22 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson90@gmail.com> | 2023-10-23 11:28:23 +0200 |
| commit | c085b31acdc002076106a30f7cd1dcdcd43daf05 (patch) | |
| tree | 3488bf2a2745f25de2560ed734556ed04b18ed00 | |
| parent | 4f521533f58c9e2f80470dd084a07b50c704a1b3 (diff) | |
| download | mullvadvpn-c085b31acdc002076106a30f7cd1dcdcd43daf05.tar.xz mullvadvpn-c085b31acdc002076106a30f7cd1dcdcd43daf05.zip | |
Rework notifications
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, |
