diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-10-23 13:37:15 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-10-23 13:37:15 +0200 |
| commit | f58efc23d12e3a17b0fe9be759abd6e99eb01aee (patch) | |
| tree | 22f08db8c8f32fa6dcee7d38a45259c914383293 /android/app/src | |
| parent | 8f9cff2d73cb235daf79fddcc6f6e4e3a4a12d3b (diff) | |
| parent | c43b476f492eac0948efe574f974a2a0301f6c35 (diff) | |
| download | mullvadvpn-f58efc23d12e3a17b0fe9be759abd6e99eb01aee.tar.xz mullvadvpn-f58efc23d12e3a17b0fe9be759abd6e99eb01aee.zip | |
Merge branch 'add-in-app-banner-message-for-a-new-device-droid-92'
Diffstat (limited to 'android/app/src')
28 files changed, 1140 insertions, 484 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index d6ef5d3311..56894addea 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -13,18 +13,18 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.ConnectNotificationState import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER +import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.model.GeoIpLocation import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.talpid.net.TransportProtocol @@ -86,8 +86,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationBlocked + inAppNotification = InAppNotification.TunnelStateBlocked ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -123,8 +122,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationBlocked + inAppNotification = InAppNotification.TunnelStateBlocked ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -158,7 +156,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -191,7 +189,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -225,7 +223,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -259,7 +257,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -295,8 +293,8 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationError( + inAppNotification = + InAppNotification.TunnelStateError( ErrorState(ErrorStateCause.StartTunnelError, true) ) ), @@ -335,8 +333,8 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationError( + inAppNotification = + InAppNotification.TunnelStateError( ErrorState(ErrorStateCause.StartTunnelError, false) ) ), @@ -372,8 +370,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationBlocked + inAppNotification = InAppNotification.TunnelStateBlocked ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -409,8 +406,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationBlocked + inAppNotification = InAppNotification.TunnelStateBlocked ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -446,7 +442,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(), onSwitchLocationClick = mockedClickHandler @@ -479,7 +475,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(), onDisconnectClick = mockedClickHandler @@ -512,7 +508,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(), onReconnectClick = mockedClickHandler @@ -544,7 +540,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(), onConnectClick = mockedClickHandler @@ -576,7 +572,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(), onCancelClick = mockedClickHandler @@ -609,7 +605,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow(), onToggleTunnelInfo = mockedClickHandler @@ -649,7 +645,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = true, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -688,8 +684,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + inAppNotification = InAppNotification.UpdateAvailable(versionInfo) ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -726,8 +721,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + inAppNotification = InAppNotification.UnsupportedVersion(versionInfo) ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -759,10 +753,9 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, - deviceName = null, + deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowAccountExpiryNotification(expiryDate) + inAppNotification = InAppNotification.AccountExpiry(expiryDate) ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) @@ -801,15 +794,14 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + inAppNotification = InAppNotification.UnsupportedVersion(versionInfo) ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) } // Act - composeTestRule.onNodeWithTag(NOTIFICATION_BANNER).performClick() + composeTestRule.onNodeWithTag(NOTIFICATION_BANNER_ACTION).performClick() // Assert verify { mockedClickHandler.invoke() } @@ -835,15 +827,14 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowAccountExpiryNotification(expiryDate) + inAppNotification = InAppNotification.AccountExpiry(expiryDate) ), uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) } // Act - composeTestRule.onNodeWithTag(NOTIFICATION_BANNER).performClick() + composeTestRule.onNodeWithTag(NOTIFICATION_BANNER_ACTION).performClick() // Assert verify { mockedClickHandler.invoke() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NotificationBanner.kt deleted file mode 100644 index 0f7fa74117..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/NotificationBanner.kt +++ /dev/null @@ -1,297 +0,0 @@ -package net.mullvad.mullvadvpn.compose.component - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.extensions.getExpiryQuantityString -import net.mullvad.mullvadvpn.compose.state.ConnectNotificationState -import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER -import net.mullvad.mullvadvpn.compose.util.rememberPrevious -import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.lib.common.util.getErrorNotificationResources -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.mullvadvpn.ui.notification.StatusLevel -import net.mullvad.talpid.tunnel.ErrorState -import org.joda.time.DateTime - -@Preview -@Composable -private fun PreviewNotificationBanner() { - AppTheme { - SpacedColumn(Modifier.background(color = MaterialTheme.colorScheme.background)) { - val versionInfoNotification = - versionInfoNotification( - versionInfo = - VersionInfo( - currentVersion = null, - upgradeVersion = null, - isOutdated = true, - isSupported = false - ), - onClickUpdateVersion = {} - ) - NotificationBanner( - title = versionInfoNotification.title, - message = versionInfoNotification.message, - onClick = versionInfoNotification.onClick, - statusLevel = versionInfoNotification.statusLevel - ) - val accountExpiryNotification = - accountExpiryNotification(expiry = DateTime.now(), onClickShowAccount = {}) - NotificationBanner( - title = accountExpiryNotification.title, - message = accountExpiryNotification.message, - statusLevel = accountExpiryNotification.statusLevel, - onClick = accountExpiryNotification.onClick - ) - val genericBlockingMessage = genericBlockingMessage() - NotificationBanner( - title = genericBlockingMessage.title, - message = genericBlockingMessage.message, - onClick = genericBlockingMessage.onClick, - statusLevel = genericBlockingMessage.statusLevel - ) - } - } -} - -@Composable -fun Notification( - connectNotificationState: ConnectNotificationState, - onClickUpdateVersion: () -> Unit, - onClickShowAccount: () -> Unit -) { - val isVisible = connectNotificationState != ConnectNotificationState.HideNotification - // Fix for animating to hide - val lastState: ConnectNotificationState = - rememberPrevious(connectNotificationState) ?: ConnectNotificationState.HideNotification - AnimatedVisibility( - visible = isVisible, - enter = slideInVertically(), - exit = slideOutVertically(), - modifier = Modifier.animateContentSize() - ) { - ShowNotification( - connectNotificationState = if (isVisible) connectNotificationState else lastState, - onClickUpdateVersion = onClickUpdateVersion, - onClickShowAccount = onClickShowAccount - ) - } -} - -@Composable -private fun ShowNotification( - connectNotificationState: ConnectNotificationState, - onClickUpdateVersion: () -> Unit, - onClickShowAccount: () -> Unit -) { - val notificationData: NotificationBannerData? = - when (connectNotificationState) { - ConnectNotificationState.ShowTunnelStateNotificationBlocked -> { - genericBlockingMessage() - } - is ConnectNotificationState.ShowTunnelStateNotificationError -> { - errorMessage(error = connectNotificationState.error) - } - is ConnectNotificationState.ShowVersionInfoNotification -> { - versionInfoNotification( - versionInfo = connectNotificationState.versionInfo, - onClickUpdateVersion = - if (IS_PLAY_BUILD) { - null - } else { - onClickUpdateVersion - } - ) - } - is ConnectNotificationState.ShowAccountExpiryNotification -> { - accountExpiryNotification( - expiry = connectNotificationState.expiry, - onClickShowAccount = - if (IS_PLAY_BUILD) { - null - } else { - onClickShowAccount - } - ) - } - is ConnectNotificationState.HideNotification -> { - // Hide notification banner - null - } - } - notificationData?.let { - NotificationBanner( - title = notificationData.title, - message = notificationData.message, - statusLevel = notificationData.statusLevel, - onClick = notificationData.onClick - ) - } -} - -@Composable -private fun NotificationBanner( - title: String, - message: String?, - statusLevel: StatusLevel, - onClick: (() -> Unit)? -) { - ConstraintLayout( - modifier = - Modifier.fillMaxWidth() - .background(color = MaterialTheme.colorScheme.background) - .then(onClick?.let { Modifier.clickable(onClick = onClick) } ?: Modifier) - .padding( - start = Dimens.notificationBannerStartPadding, - end = Dimens.notificationBannerEndPadding, - top = Dimens.smallPadding, - bottom = Dimens.smallPadding - ) - .animateContentSize() - .testTag(NOTIFICATION_BANNER) - ) { - val (status, textTitle, textMessage, icon) = createRefs() - Box( - modifier = - Modifier.background( - color = - if (statusLevel == StatusLevel.Warning) { - MaterialTheme.colorScheme.errorContainer - } else { - MaterialTheme.colorScheme.error - }, - shape = CircleShape - ) - .size(Dimens.notificationStatusIconSize) - .constrainAs(status) { - top.linkTo(textTitle.top) - start.linkTo(parent.start) - bottom.linkTo(textTitle.bottom) - } - ) - Text( - text = title.uppercase(), - modifier = - Modifier.constrainAs(textTitle) { - top.linkTo(parent.top) - start.linkTo(status.end) - bottom.linkTo(anchor = textMessage.top) - end.linkTo(icon.start) - width = Dimension.fillToConstraints - } - .padding(start = Dimens.smallPadding), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold - ) - message?.let { - Text( - text = message, - modifier = - Modifier.constrainAs(textMessage) { - top.linkTo(textTitle.bottom) - start.linkTo(textTitle.start) - bottom.linkTo(parent.bottom) - end.linkTo(icon.start) - width = Dimension.fillToConstraints - } - .padding(start = Dimens.smallPadding), - style = MaterialTheme.typography.labelMedium - ) - } - onClick?.let { - Image( - painter = painterResource(id = R.drawable.icon_extlink), - contentDescription = null, - modifier = - Modifier.constrainAs(icon) { - top.linkTo(parent.top) - end.linkTo(parent.end) - bottom.linkTo(parent.bottom) - } - .padding(all = Dimens.notificationEndIconPadding) - ) - } - } -} - -@Composable -private fun genericBlockingMessage() = - NotificationBannerData( - title = stringResource(id = R.string.blocking_internet), - statusLevel = StatusLevel.Error - ) - -@Composable -private fun errorMessage(error: ErrorState) = - error.getErrorNotificationResources(LocalContext.current).run { - NotificationBannerData( - title = stringResource(id = titleResourceId), - message = optionalMessageArgument?.let { stringResource(id = messageResourceId, it) } - ?: stringResource(id = messageResourceId), - statusLevel = StatusLevel.Error - ) - } - -@Composable -private fun accountExpiryNotification(expiry: DateTime, onClickShowAccount: (() -> Unit)?) = - NotificationBannerData( - title = stringResource(id = R.string.account_credit_expires_soon), - message = LocalContext.current.resources.getExpiryQuantityString(expiry), - statusLevel = StatusLevel.Error, - onClick = onClickShowAccount - ) - -@Composable -private fun versionInfoNotification(versionInfo: VersionInfo, onClickUpdateVersion: (() -> Unit)?) = - when { - versionInfo.upgradeVersion != null && versionInfo.isSupported -> - NotificationBannerData( - title = stringResource(id = R.string.update_available), - message = - stringResource( - id = R.string.update_available_description, - versionInfo.upgradeVersion - ), - statusLevel = StatusLevel.Warning, - onClick = onClickUpdateVersion - ) - else -> - NotificationBannerData( - title = stringResource(id = R.string.unsupported_version), - message = stringResource(id = R.string.unsupported_version_description), - statusLevel = StatusLevel.Error, - onClick = onClickUpdateVersion - ) - } - -private data class NotificationBannerData( - val title: String, - val message: String? = null, - val statusLevel: StatusLevel, - val onClick: (() -> Unit)? = null -) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt new file mode 100644 index 0000000000..6078e4b392 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt @@ -0,0 +1,199 @@ +package net.mullvad.mullvadvpn.compose.component.notificationbanner + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import net.mullvad.mullvadvpn.compose.component.MullvadTopBar +import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER +import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION +import net.mullvad.mullvadvpn.compose.util.rememberPrevious +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.ui.notification.StatusLevel +import net.mullvad.talpid.tunnel.ErrorState +import net.mullvad.talpid.tunnel.ErrorStateCause +import org.joda.time.DateTime + +@Preview +@Composable +private fun PreviewNotificationBanner() { + AppTheme { + Column( + Modifier.background(color = MaterialTheme.colorScheme.surface), + ) { + val bannerDataList = + listOf( + InAppNotification.UnsupportedVersion( + versionInfo = + VersionInfo( + currentVersion = null, + upgradeVersion = null, + isOutdated = true, + isSupported = false + ), + ), + InAppNotification.AccountExpiry(expiry = DateTime.now()), + InAppNotification.TunnelStateBlocked, + InAppNotification.NewDevice("Courageous Turtle"), + InAppNotification.TunnelStateError( + error = ErrorState(ErrorStateCause.SetFirewallPolicyError, true) + ) + ) + .map { it.toNotificationData({}, {}, {}) } + + bannerDataList.forEach { + MullvadTopBar( + containerColor = MaterialTheme.colorScheme.primary, + onSettingsClicked = {}, + onAccountClicked = {}, + iconTintColor = MaterialTheme.colorScheme.primary + ) + Notification(it) + Spacer(modifier = Modifier.size(16.dp)) + } + } + } +} + +@Composable +fun NotificationBanner( + notification: InAppNotification?, + onClickUpdateVersion: () -> Unit, + onClickShowAccount: () -> Unit, + onClickDismissNewDevice: () -> Unit +) { + // Fix for animating to invisible state + val previous = rememberPrevious(current = notification, shouldUpdate = { _, _ -> true }) + AnimatedVisibility( + visible = notification != null, + enter = slideInVertically(initialOffsetY = { -it }), + exit = slideOutVertically(targetOffsetY = { -it }), + modifier = Modifier.animateContentSize() + ) { + val visibleNotification = notification ?: previous + if (visibleNotification != null) + Notification( + visibleNotification.toNotificationData( + onClickUpdateVersion, + onClickShowAccount, + onClickDismissNewDevice + ) + ) + } +} + +@Composable +private fun Notification(notificationBannerData: NotificationData) { + val (title, message, statusLevel, action) = notificationBannerData + ConstraintLayout( + modifier = + Modifier.fillMaxWidth() + .background(color = MaterialTheme.colorScheme.background) + .padding( + start = Dimens.notificationBannerStartPadding, + end = Dimens.notificationBannerEndPadding, + top = Dimens.smallPadding, + bottom = Dimens.smallPadding + ) + .animateContentSize() + .testTag(NOTIFICATION_BANNER) + ) { + val (status, textTitle, textMessage, actionIcon) = createRefs() + Box( + modifier = + Modifier.background( + color = + when (statusLevel) { + StatusLevel.Error -> MaterialTheme.colorScheme.error + StatusLevel.Warning -> MaterialTheme.colorScheme.errorContainer + StatusLevel.Info -> MaterialTheme.colorScheme.surface + }, + shape = CircleShape + ) + .size(Dimens.notificationStatusIconSize) + .constrainAs(status) { + top.linkTo(textTitle.top) + start.linkTo(parent.start) + bottom.linkTo(textTitle.bottom) + } + ) + Text( + text = title.uppercase(), + modifier = + Modifier.constrainAs(textTitle) { + top.linkTo(parent.top) + start.linkTo(status.end) + bottom.linkTo(anchor = textMessage.top) + end.linkTo(actionIcon.start) + width = Dimension.fillToConstraints + } + .padding(start = Dimens.smallPadding), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + ) + message?.let { + Text( + text = message, + modifier = + Modifier.constrainAs(textMessage) { + top.linkTo(textTitle.bottom) + start.linkTo(textTitle.start) + bottom.linkTo(parent.bottom) + if (action != null) { + end.linkTo(actionIcon.start) + } else { + end.linkTo(parent.end) + } + width = Dimension.fillToConstraints + } + .padding(start = Dimens.smallPadding), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaDescription), + style = MaterialTheme.typography.labelMedium + ) + } + action?.let { + IconButton( + modifier = + Modifier.constrainAs(actionIcon) { + top.linkTo(parent.top) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + } + .testTag(NOTIFICATION_BANNER_ACTION) + .padding(all = Dimens.notificationEndIconPadding), + onClick = it.onClick + ) { + Icon( + painter = painterResource(id = it.icon), + contentDescription = null, + tint = Color.Unspecified + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt new file mode 100644 index 0000000000..3fbf0ad095 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt @@ -0,0 +1,121 @@ +package net.mullvad.mullvadvpn.compose.component.notificationbanner + +import androidx.annotation.DrawableRes +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.core.text.HtmlCompat +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.extensions.getExpiryQuantityString +import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD +import net.mullvad.mullvadvpn.lib.common.util.getErrorNotificationResources +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.ui.notification.StatusLevel +import net.mullvad.talpid.tunnel.ErrorState + +data class NotificationData( + val title: String, + val message: AnnotatedString? = null, + val statusLevel: StatusLevel, + val action: NotificationAction? = null +) { + constructor( + title: String, + message: String?, + statusLevel: StatusLevel, + action: NotificationAction? + ) : this(title, message?.let { AnnotatedString(it) }, statusLevel, action) +} + +data class NotificationAction( + @DrawableRes val icon: Int, + val onClick: (() -> Unit), +) + +@Composable +fun InAppNotification.toNotificationData( + onClickUpdateVersion: () -> Unit, + onClickShowAccount: () -> Unit, + onDismissNewDevice: () -> Unit +) = + when (this) { + is InAppNotification.NewDevice -> + NotificationData( + title = stringResource(id = R.string.new_device_notification_title), + message = + HtmlCompat.fromHtml( + stringResource( + id = R.string.new_device_notification_message, + deviceName + ), + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + .toAnnotatedString( + boldSpanStyle = + SpanStyle( + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold + ), + ), + statusLevel = StatusLevel.Info, + action = NotificationAction(R.drawable.icon_close, onDismissNewDevice) + ) + is InAppNotification.AccountExpiry -> + NotificationData( + title = stringResource(id = R.string.account_credit_expires_soon), + message = LocalContext.current.resources.getExpiryQuantityString(expiry), + statusLevel = StatusLevel.Error, + action = + if (IS_PLAY_BUILD) null + else + NotificationAction( + R.drawable.icon_extlink, + onClickShowAccount, + ), + ) + InAppNotification.TunnelStateBlocked -> + NotificationData( + title = stringResource(id = R.string.blocking_internet), + statusLevel = StatusLevel.Error + ) + is InAppNotification.TunnelStateError -> errorMessageBannerData(error) + is InAppNotification.UnsupportedVersion -> + NotificationData( + title = stringResource(id = R.string.unsupported_version), + message = stringResource(id = R.string.unsupported_version_description), + statusLevel = StatusLevel.Error, + action = + if (IS_PLAY_BUILD) null + else NotificationAction(R.drawable.icon_extlink, onClickUpdateVersion) + ) + is InAppNotification.UpdateAvailable -> + NotificationData( + title = stringResource(id = R.string.update_available), + message = + stringResource( + id = R.string.update_available_description, + versionInfo.upgradeVersion ?: "" // TODO Verify + ), + statusLevel = StatusLevel.Warning, + action = + if (IS_PLAY_BUILD) null + else NotificationAction(R.drawable.icon_extlink, onClickUpdateVersion) + ) + } + +@Composable +private fun errorMessageBannerData(error: ErrorState) = + error.getErrorNotificationResources(LocalContext.current).run { + NotificationData( + title = stringResource(id = titleResourceId), + message = optionalMessageArgument?.let { stringResource(id = messageResourceId, it) } + ?: stringResource(id = messageResourceId), + statusLevel = StatusLevel.Error, + action = null + ) + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt index e57e9be563..b88f0a86ba 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.DialogProperties import androidx.core.text.HtmlCompat @@ -62,7 +63,7 @@ fun InfoDialog(message: String, additionalInfo: String? = null, onDismiss: () -> Spacer(modifier = Modifier.height(Dimens.verticalSpace)) val htmlFormattedString = HtmlCompat.fromHtml(additionalInfo, HtmlCompat.FROM_HTML_MODE_COMPACT) - val annotated = htmlFormattedString.toAnnotatedString() + val annotated = htmlFormattedString.toAnnotatedString(FontWeight.Bold) // fromHtml may add a trailing newline when using HTML tags, so we remove it val trimmed = annotated.substring(0, annotated.trimEnd().length) Text( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt index d5cdaf0f88..6c294e6207 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt @@ -24,3 +24,20 @@ fun Spanned.toAnnotatedString(boldFontWeight: FontWeight = FontWeight.Bold): Ann } } } + +fun Spanned.toAnnotatedString( + boldSpanStyle: SpanStyle = SpanStyle(fontWeight = FontWeight.ExtraBold) +): AnnotatedString = buildAnnotatedString { + val spanned = this@toAnnotatedString + append(spanned.toString()) + getSpans(0, spanned.length, Any::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + when (span) { + is StyleSpan -> + when (span.style) { + Typeface.BOLD -> addStyle(boldSpanStyle, start, end) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index a0beecb655..b2a6bc9b9e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -35,9 +35,9 @@ import net.mullvad.mullvadvpn.compose.button.ConnectionButton import net.mullvad.mullvadvpn.compose.button.SwitchLocationButton import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText import net.mullvad.mullvadvpn.compose.component.LocationInfo -import net.mullvad.mullvadvpn.compose.component.Notification import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.component.notificationbanner.NotificationBanner import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG @@ -83,7 +83,8 @@ fun ConnectScreen( onManageAccountClick: () -> Unit = {}, onOpenOutOfTimeScreen: () -> Unit = {}, onSettingsClick: () -> Unit = {}, - onAccountClick: () -> Unit = {} + onAccountClick: () -> Unit = {}, + onDismissNewDeviceClick: () -> Unit = {} ) { val context = LocalContext.current @@ -160,10 +161,11 @@ fun ConnectScreen( .padding(bottom = Dimens.screenVerticalMargin) .testTag(SCROLLABLE_COLUMN_TEST_TAG) ) { - Notification( - connectNotificationState = uiState.connectNotificationState, + NotificationBanner( + notification = uiState.inAppNotification, onClickUpdateVersion = onUpdateVersionClick, - onClickShowAccount = onManageAccountClick + onClickShowAccount = onManageAccountClick, + onClickDismissNewDevice = onDismissNewDeviceClick, ) Spacer(modifier = Modifier.weight(1f)) if ( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectNotificationState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectNotificationState.kt index 71ba71e54c..8439680500 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectNotificationState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectNotificationState.kt @@ -1,17 +1 @@ package net.mullvad.mullvadvpn.compose.state - -import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.talpid.tunnel.ErrorState -import org.joda.time.DateTime - -sealed interface ConnectNotificationState { - data object ShowTunnelStateNotificationBlocked : ConnectNotificationState - - data class ShowTunnelStateNotificationError(val error: ErrorState) : ConnectNotificationState - - data class ShowVersionInfoNotification(val versionInfo: VersionInfo) : ConnectNotificationState - - data class ShowAccountExpiryNotification(val expiry: DateTime) : ConnectNotificationState - - data object HideNotification : ConnectNotificationState -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt index 93b9df5b7a..6ab4839bd1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt @@ -3,6 +3,7 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.model.GeoIpLocation import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.talpid.net.TransportProtocol data class ConnectUiState( @@ -13,7 +14,7 @@ data class ConnectUiState( val inAddress: Triple<String, Int, TransportProtocol>?, val outAddress: String, val showLocation: Boolean, - val connectNotificationState: ConnectNotificationState, + val inAppNotification: InAppNotification?, val isTunnelInfoExpanded: Boolean, val deviceName: String?, val daysLeftUntilExpiry: Int? @@ -29,7 +30,7 @@ data class ConnectUiState( outAddress = "", showLocation = false, isTunnelInfoExpanded = false, - connectNotificationState = ConnectNotificationState.HideNotification, + inAppNotification = null, deviceName = null, daysLeftUntilExpiry = null ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt index 3cf06f201b..dea9e12a3d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt @@ -24,5 +24,6 @@ const val LOCATION_INFO_TEST_TAG = "location_info_test_tag" // ConnectScreen - Notification banner const val NOTIFICATION_BANNER = "notification_banner" +const val NOTIFICATION_BANNER_ACTION = "notification_banner_action" const val LOGIN_TITLE_TEST_TAG = "login_title_test_tag" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt index dff48b6228..7f7e4acf45 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt @@ -1,3 +1,4 @@ package net.mullvad.mullvadvpn.constant const val ACCOUNT_EXPIRY_POLL_INTERVAL: Long = 15 /* s */ * 1000 /* ms */ +const val ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS = 3 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 1d4421b063..398e27820e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -5,6 +5,7 @@ import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Messenger import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.applist.ApplicationsIconManager import net.mullvad.mullvadvpn.applist.ApplicationsProvider @@ -13,11 +14,16 @@ import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.ui.serviceconnection.MessageHandler import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling +import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase +import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase +import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase +import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase import net.mullvad.mullvadvpn.util.ChangelogDataProvider import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.viewmodel.AccountViewModel @@ -78,6 +84,13 @@ val uiModule = module { single { SettingsRepository(get()) } single { MullvadProblemReport(get()) } + single { AccountExpiryNotificationUseCase(get()) } + single { TunnelStateNotificationUseCase(get()) } + single { VersionNotificationUseCase(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS) } + single { NewDeviceNotificationUseCase(get()) } + + single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } + single<IChangelogDataProvider> { ChangelogDataProvider(get()) } // View models @@ -85,12 +98,10 @@ val uiModule = module { viewModel { ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG) } - viewModel { - ConnectViewModel(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS, get(), get()) - } + viewModel { ConnectViewModel(get(), get(), get(), get(), get()) } viewModel { DeviceListViewModel(get(), get()) } viewModel { DeviceRevokedViewModel(get(), get()) } - viewModel { LoginViewModel(get(), get()) } + viewModel { LoginViewModel(get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get()) } viewModel { SelectLocationViewModel(get()) } viewModel { SettingsViewModel(get(), get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt new file mode 100644 index 0000000000..0751d0b1f7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt @@ -0,0 +1,85 @@ +package net.mullvad.mullvadvpn.repository + +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase +import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase +import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase +import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase +import net.mullvad.talpid.tunnel.ErrorState +import org.joda.time.DateTime + +enum class StatusLevel { + Error, + Warning, + Info, +} + +sealed class InAppNotification { + val uuid: UUID = UUID.randomUUID() + abstract val statusLevel: StatusLevel + abstract val priority: Long + + data class TunnelStateError(val error: ErrorState) : InAppNotification() { + override val statusLevel = StatusLevel.Error + override val priority: Long = 1001 + } + + data object TunnelStateBlocked : InAppNotification() { + override val statusLevel = StatusLevel.Error + override val priority: Long = 1000 + } + + data class UnsupportedVersion(val versionInfo: VersionInfo) : InAppNotification() { + override val statusLevel = StatusLevel.Error + override val priority: Long = 999 + } + + data class AccountExpiry(val expiry: DateTime) : InAppNotification() { + override val statusLevel = StatusLevel.Warning + override val priority: Long = 1001 + } + + data class NewDevice(val deviceName: String) : InAppNotification() { + override val statusLevel = StatusLevel.Info + override val priority: Long = 1001 + } + + data class UpdateAvailable(val versionInfo: VersionInfo) : InAppNotification() { + override val statusLevel = StatusLevel.Info + override val priority: Long = 1000 + } +} + +class InAppNotificationController( + accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase, + newDeviceNotificationUseCase: NewDeviceNotificationUseCase, + versionNotificationUseCase: VersionNotificationUseCase, + tunnelStateNotificationUseCase: TunnelStateNotificationUseCase, + scope: CoroutineScope, +) { + + val notifications = + combine( + tunnelStateNotificationUseCase.notifications(), + versionNotificationUseCase.notifications(), + accountExpiryNotificationUseCase.notifications(), + newDeviceNotificationUseCase.notifications(), + ) { a, b, c, d -> + a + b + c + d + } + .map { + it.sortedWith( + compareBy( + { notification -> notification.statusLevel.ordinal }, + { notification -> -notification.priority } + ) + ) + } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt index b83ce973c1..532787ff4f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt @@ -48,7 +48,8 @@ class ConnectFragment : BaseFragment() { onManageAccountClick = connectViewModel::onManageAccountClick, onOpenOutOfTimeScreen = ::openOutOfTimeScreen, onSettingsClick = ::openSettingsView, - onAccountClick = ::openAccountView + onAccountClick = ::openAccountView, + onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt index 6960fd656b..acac4ae7f6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt @@ -3,4 +3,5 @@ package net.mullvad.mullvadvpn.ui.notification enum class StatusLevel { Error, Warning, + Info, } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt new file mode 100644 index 0000000000..a4961bafe7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS +import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.repository.InAppNotification +import org.joda.time.DateTime + +class AccountExpiryNotificationUseCase( + private val accountRepository: AccountRepository, +) { + fun notifications(): Flow<List<InAppNotification>> = + accountRepository.accountExpiryState + .map(::accountExpiryNotification) + .map(::listOfNotNull) + .distinctUntilChanged() + + private fun accountExpiryNotification(accountExpiry: AccountExpiry) = + if (accountExpiry.isCloseToExpiring()) { + InAppNotification.AccountExpiry(accountExpiry.date() ?: DateTime.now()) + } else null + + private fun AccountExpiry.isCloseToExpiring(): Boolean { + val threeDaysFromNow = + DateTime.now().plusDays(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS) + return this.date()?.isBefore(threeDaysFromNow) == true + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt new file mode 100644 index 0000000000..628cc555ec --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.repository.InAppNotification + +class NewDeviceNotificationUseCase(private val deviceRepository: DeviceRepository) { + private val _mutableShowNewDeviceNotification = MutableStateFlow(false) + + fun notifications() = + combine( + deviceRepository.deviceState.map { it.deviceName() }.distinctUntilChanged(), + _mutableShowNewDeviceNotification + ) { deviceName, newDeviceCreated -> + if (newDeviceCreated && deviceName != null) { + InAppNotification.NewDevice(deviceName) + } else null + } + .map(::listOfNotNull) + .distinctUntilChanged() + + fun newDeviceCreated() { + _mutableShowNewDeviceNotification.value = true + } + + fun clearNewDeviceCreatedNotification() { + _mutableShowNewDeviceNotification.value = false + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt new file mode 100644 index 0000000000..f228bd7dbe --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt @@ -0,0 +1,47 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier +import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +class TunnelStateNotificationUseCase( + private val serviceConnectionManager: ServiceConnectionManager, +) { + fun notifications(): Flow<List<InAppNotification>> = + serviceConnectionManager.connectionState + .flatMapReadyConnectionOrDefault(flowOf(emptyList())) { + it.container.connectionProxy + .tunnelUiStateFlow() + .distinctUntilChanged() + .map(::tunnelStateNotification) + .map(::listOfNotNull) + } + .distinctUntilChanged() + + private fun tunnelStateNotification(tunnelUiState: TunnelState): InAppNotification? = + when (tunnelUiState) { + is TunnelState.Connecting -> InAppNotification.TunnelStateBlocked + is TunnelState.Disconnecting -> { + if ( + tunnelUiState.actionAfterDisconnect == ActionAfterDisconnect.Block || + tunnelUiState.actionAfterDisconnect == ActionAfterDisconnect.Reconnect + ) { + InAppNotification.TunnelStateBlocked + } else null + } + is TunnelState.Error -> InAppNotification.TunnelStateError(tunnelUiState.errorState) + is TunnelState.Connected, + TunnelState.Disconnected -> null + } + + private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> = + callbackFlowFromNotifier(this.onUiStateChange) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt new file mode 100644 index 0000000000..28496c4639 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt @@ -0,0 +1,48 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.util.appVersionCallbackFlow +import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault + +class VersionNotificationUseCase( + private val serviceConnectionManager: ServiceConnectionManager, + private val isVersionInfoNotificationEnabled: Boolean, +) { + + fun notifications() = + serviceConnectionManager.connectionState + .flatMapReadyConnectionOrDefault(flowOf(emptyList())) { + it.container.appVersionInfoCache.appVersionCallbackFlow().map { versionInfo -> + listOfNotNull( + unsupportedVersionNotification(versionInfo), + updateAvailableNotification(versionInfo) + ) + } + } + .distinctUntilChanged() + + private fun updateAvailableNotification(versionInfo: VersionInfo): InAppNotification? { + if (!isVersionInfoNotificationEnabled) { + return null + } + + return if (versionInfo.isOutdated) { + InAppNotification.UpdateAvailable(versionInfo) + } else null + } + + private fun unsupportedVersionNotification(versionInfo: VersionInfo): InAppNotification? { + if (!isVersionInfoNotificationEnabled) { + return null + } + + return if (!versionInfo.isSupported) { + InAppNotification.UnsupportedVersion(versionInfo) + } else null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index 01ba71ff86..8a4f087d64 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.state.ConnectNotificationState import net.mullvad.mullvadvpn.compose.state.ConnectUiState -import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository -import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.LocationInfoCache import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener @@ -35,7 +33,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy -import net.mullvad.mullvadvpn.util.appVersionCallbackFlow +import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import net.mullvad.mullvadvpn.util.combine import net.mullvad.mullvadvpn.util.daysFromNow @@ -43,14 +41,14 @@ import net.mullvad.mullvadvpn.util.toInAddress import net.mullvad.mullvadvpn.util.toOutAddress import net.mullvad.talpid.tunnel.ActionAfterDisconnect import net.mullvad.talpid.tunnel.ErrorStateCause -import org.joda.time.DateTime @OptIn(FlowPreview::class) class ConnectViewModel( private val serviceConnectionManager: ServiceConnectionManager, - private val isVersionInfoNotificationEnabled: Boolean, accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, + private val inAppNotificationController: InAppNotificationController, + private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase ) : ViewModel() { private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1) val uiSideEffect = _uiSideEffect.asSharedFlow() @@ -74,7 +72,7 @@ class ConnectViewModel( combine( serviceConnection.locationInfoCache.locationCallbackFlow(), serviceConnection.relayListListener.relayListCallbackFlow(), - serviceConnection.appVersionInfoCache.appVersionCallbackFlow(), + inAppNotificationController.notifications, serviceConnection.connectionProxy.tunnelUiStateFlow(), serviceConnection.connectionProxy.tunnelRealStateFlow(), accountRepository.accountExpiryState, @@ -83,7 +81,7 @@ class ConnectViewModel( ) { location, relayLocation, - versionInfo, + notifications, tunnelUiState, tunnelRealState, accountExpiry, @@ -125,12 +123,7 @@ class ConnectViewModel( is TunnelState.Connected -> false is TunnelState.Error -> true }, - connectNotificationState = - evaluateNotificationState( - tunnelUiState = tunnelUiState, - versionInfo = versionInfo, - accountExpiry = accountExpiry - ), + inAppNotification = notifications.firstOrNull(), deviceName = deviceName, daysLeftUntilExpiry = accountExpiry.date()?.daysFromNow() ) @@ -155,36 +148,6 @@ class ConnectViewModel( private fun ConnectionProxy.tunnelRealStateFlow(): Flow<TunnelState> = callbackFlowFromNotifier(this.onStateChange) - private fun evaluateNotificationState( - tunnelUiState: TunnelState, - versionInfo: VersionInfo?, - accountExpiry: AccountExpiry - ): ConnectNotificationState = - when { - tunnelUiState is TunnelState.Connecting -> - ConnectNotificationState.ShowTunnelStateNotificationBlocked - tunnelUiState is TunnelState.Disconnecting && - (tunnelUiState.actionAfterDisconnect == ActionAfterDisconnect.Block || - tunnelUiState.actionAfterDisconnect == ActionAfterDisconnect.Reconnect) -> - ConnectNotificationState.ShowTunnelStateNotificationBlocked - tunnelUiState is TunnelState.Error -> - ConnectNotificationState.ShowTunnelStateNotificationError(tunnelUiState.errorState) - isVersionInfoNotificationEnabled && - versionInfo != null && - (versionInfo.isOutdated || !versionInfo.isSupported) -> - ConnectNotificationState.ShowVersionInfoNotification(versionInfo) - accountExpiry.isCloseToExpiring() -> - ConnectNotificationState.ShowAccountExpiryNotification( - accountExpiry.date() ?: DateTime.now() - ) - else -> ConnectNotificationState.HideNotification - } - - private fun AccountExpiry.isCloseToExpiring(): Boolean { - val threeDaysFromNow = DateTime.now().plusDays(3) - return this.date()?.isBefore(threeDaysFromNow) == true - } - private fun TunnelState.isTunnelErrorStateDueToExpiredAccount(): Boolean { return ((this as? TunnelState.Error)?.errorState?.cause as? ErrorStateCause.AuthFailed) ?.isCausedByExpiredAccount() @@ -221,6 +184,10 @@ class ConnectViewModel( } } + fun dismissNewDeviceNotification() { + newDeviceNotificationUseCase.clearNewDeviceCreatedNotification() + } + sealed interface UiSideEffect { data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt index 953e59f388..b31478ce1a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt @@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.model.AccountToken import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L @@ -38,6 +39,7 @@ sealed interface LoginUiSideEffect { class LoginViewModel( private val accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, + private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { private val _loginState = MutableStateFlow(LoginUiState.INITIAL.loginState) @@ -85,6 +87,7 @@ class LoginViewModel( delay(1000) _uiSideEffect.emit(LoginUiSideEffect.NavigateToConnect) } + newDeviceNotificationUseCase.newDeviceCreated() Success } LoginResult.InvalidAccount -> Idle(LoginError.InvalidCredentials) diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt new file mode 100644 index 0000000000..30b54cea11 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt @@ -0,0 +1,102 @@ +package net.mullvad.mullvadvpn + +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlin.test.assertEquals +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.repository.InAppNotificationController +import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase +import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase +import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase +import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase +import net.mullvad.talpid.tunnel.ErrorState +import org.joda.time.DateTime +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class InAppNotificationControllerTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + private lateinit var inAppNotificationController: InAppNotificationController + private val accountExpiryNotifications = MutableStateFlow(emptyList<InAppNotification>()) + private val newDeviceNotifications = MutableStateFlow(emptyList<InAppNotification.NewDevice>()) + private val versionNotifications = MutableStateFlow(emptyList<InAppNotification>()) + private val tunnelStateNotifications = MutableStateFlow(emptyList<InAppNotification>()) + + private lateinit var job: Job + + @Before + fun setup() { + MockKAnnotations.init(this) + + val accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase = mockk() + val newDeviceNotificationUseCase: NewDeviceNotificationUseCase = mockk() + val versionNotificationUseCase: VersionNotificationUseCase = mockk() + val tunnelStateNotificationUseCase: TunnelStateNotificationUseCase = mockk() + every { accountExpiryNotificationUseCase.notifications() } returns + accountExpiryNotifications + every { newDeviceNotificationUseCase.notifications() } returns newDeviceNotifications + every { versionNotificationUseCase.notifications() } returns versionNotifications + every { tunnelStateNotificationUseCase.notifications() } returns tunnelStateNotifications + job = Job() + + inAppNotificationController = + InAppNotificationController( + accountExpiryNotificationUseCase, + newDeviceNotificationUseCase, + versionNotificationUseCase, + tunnelStateNotificationUseCase, + CoroutineScope(job + testCoroutineRule.testDispatcher) + ) + } + + @After + fun teardown() { + job.cancel() + unmockkAll() + } + + @Test + fun `ensure all notifications have the right priority`() = runTest { + val newDevice = InAppNotification.NewDevice("") + newDeviceNotifications.value = listOf(newDevice) + + val errorState: ErrorState = mockk() + val tunnelStateBlocked = InAppNotification.TunnelStateBlocked + val tunnelStateError = InAppNotification.TunnelStateError(errorState) + tunnelStateNotifications.value = listOf(tunnelStateBlocked, tunnelStateError) + + val unsupportedVersion = InAppNotification.UnsupportedVersion(mockk()) + val updateAvailable = InAppNotification.UpdateAvailable(mockk()) + versionNotifications.value = listOf(unsupportedVersion, updateAvailable) + + val accountExpiry = InAppNotification.AccountExpiry(DateTime.now()) + accountExpiryNotifications.value = listOf(accountExpiry) + + inAppNotificationController.notifications.test { + val notifications = awaitItem() + + assertEquals( + listOf( + tunnelStateError, + tunnelStateBlocked, + unsupportedVersion, + accountExpiry, + newDevice, + updateAvailable, + ), + notifications + ) + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt new file mode 100644 index 0000000000..5341708d3b --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt @@ -0,0 +1,75 @@ +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.repository.InAppNotification +import org.joda.time.DateTime +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class AccountExpiryNotificationUseCaseTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + private val accountExpiry = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) + private lateinit var accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase + + @Before + fun setup() { + MockKAnnotations.init(this) + + val accountRepository = mockk<AccountRepository>() + every { accountRepository.accountExpiryState } returns accountExpiry + + accountExpiryNotificationUseCase = AccountExpiryNotificationUseCase(accountRepository) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `ensure notifications are empty by default`() = runTest { + // Arrange, Act, Assert + accountExpiryNotificationUseCase.notifications().test { + assertTrue { awaitItem().isEmpty() } + } + } + + @Test + fun `ensure account expiry within 3 days generates notification`() = runTest { + // Arrange, Act, Assert + accountExpiryNotificationUseCase.notifications().test { + assertTrue { awaitItem().isEmpty() } + val closeToExpiry = AccountExpiry.Available(DateTime.now().plusDays(2)) + accountExpiry.value = closeToExpiry + + assertEquals( + listOf(InAppNotification.AccountExpiry(closeToExpiry.expiryDateTime)), + awaitItem() + ) + } + } + + @Test + fun `ensure an expire of 4 days in the future does not produce a notification`() = runTest { + // Arrange, Act, Assert + accountExpiryNotificationUseCase.notifications().test { + assertTrue { awaitItem().isEmpty() } + accountExpiry.value = AccountExpiry.Available(DateTime.now().plusDays(4)) + expectNoEvents() + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt new file mode 100644 index 0000000000..bd375d729a --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt @@ -0,0 +1,81 @@ +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.model.AccountAndDevice +import net.mullvad.mullvadvpn.model.Device +import net.mullvad.mullvadvpn.model.DeviceState +import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.repository.InAppNotification +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class NewDeviceUseNotificationCaseTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + private val deviceName = "Frank Zebra" + private val deviceState = + MutableStateFlow<DeviceState>( + DeviceState.LoggedIn( + accountAndDevice = AccountAndDevice("", Device("", deviceName, byteArrayOf(), "")) + ) + ) + private lateinit var newDeviceNotificationUseCase: NewDeviceNotificationUseCase + + @Before + fun setup() { + MockKAnnotations.init(this) + + val mockDeviceRepository: DeviceRepository = mockk() + every { mockDeviceRepository.deviceState } returns deviceState + newDeviceNotificationUseCase = + NewDeviceNotificationUseCase(deviceRepository = mockDeviceRepository) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `ensure empty by default`() = runTest { + // Arrange, Act, Assert + newDeviceNotificationUseCase.notifications().test { assertTrue { awaitItem().isEmpty() } } + } + + @Test + fun `ensure NewDevice notification is created and contains device name`() = runTest { + newDeviceNotificationUseCase.notifications().test { + // Arrange, Act + awaitItem() + newDeviceNotificationUseCase.newDeviceCreated() + + // Assert + assertEquals(awaitItem(), listOf(InAppNotification.NewDevice(deviceName))) + } + } + + @Test + fun `ensure NewDevice notification is cleared`() = runTest { + newDeviceNotificationUseCase.notifications().test { + // Arrange, Act + awaitItem() + newDeviceNotificationUseCase.newDeviceCreated() + awaitItem() + newDeviceNotificationUseCase.clearNewDeviceCreatedNotification() + + // Assert + assertEquals(awaitItem(), emptyList()) + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt new file mode 100644 index 0000000000..1b89c92be7 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt @@ -0,0 +1,94 @@ +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import net.mullvad.talpid.tunnel.ErrorState +import net.mullvad.talpid.util.EventNotifier +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class TunnelStateNotificationUseCaseTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + private val mockServiceConnectionManager: ServiceConnectionManager = mockk() + private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() + private val mockConnectionProxy: ConnectionProxy = mockk() + + private val serviceConnectionState = + MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) + private lateinit var tunnelStateNotificationUseCase: TunnelStateNotificationUseCase + + private val eventNotifierTunnelUiState = EventNotifier<TunnelState>(TunnelState.Disconnected) + + @Before + fun setup() { + MockKAnnotations.init(this) + every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState + + every { mockServiceConnectionManager.connectionState } returns serviceConnectionState + every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy + + tunnelStateNotificationUseCase = + TunnelStateNotificationUseCase(serviceConnectionManager = mockServiceConnectionManager) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `ensure notifications are empty by default`() = runTest { + // Arrange, Act, Assert + tunnelStateNotificationUseCase.notifications().test { assertTrue { awaitItem().isEmpty() } } + } + + @Test + fun `ensure TunnelState with error will produce TunnelStateError notification`() = runTest { + tunnelStateNotificationUseCase.notifications().test { + // Arrange, Act + assertEquals(emptyList(), awaitItem()) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + val errorState: ErrorState = mockk() + eventNotifierTunnelUiState.notify(TunnelState.Error(errorState)) + + // Assert + assertEquals(listOf(InAppNotification.TunnelStateError(errorState)), awaitItem()) + } + } + + @Test + fun `ensure disconnecting TunnelState with blocking will produce TunnelStateBlocked notification`() = + runTest { + tunnelStateNotificationUseCase.notifications().test { + // Arrange, Act + assertEquals(emptyList(), awaitItem()) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + eventNotifierTunnelUiState.notify( + TunnelState.Disconnecting(ActionAfterDisconnect.Block) + ) + + // Assert + assertEquals(listOf(InAppNotification.TunnelStateBlocked), awaitItem()) + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt new file mode 100644 index 0000000000..5aba70c938 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt @@ -0,0 +1,114 @@ +package net.mullvad.mullvadvpn.usecase + +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.util.appVersionCallbackFlow +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class VersionNotificationUseCaseTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + private val mockServiceConnectionManager: ServiceConnectionManager = mockk() + private lateinit var mockAppVersionInfoCache: AppVersionInfoCache + private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() + + private val serviceConnectionState = + MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) + private val versionInfo = + MutableStateFlow( + VersionInfo( + currentVersion = null, + upgradeVersion = null, + isOutdated = false, + isSupported = true + ) + ) + private lateinit var versionNotificationUseCase: VersionNotificationUseCase + + @Before + fun setup() { + MockKAnnotations.init(this) + mockkStatic(CACHE_EXTENSION_CLASS) + mockAppVersionInfoCache = + mockk<AppVersionInfoCache>().apply { + every { appVersionCallbackFlow() } returns versionInfo + } + + every { mockServiceConnectionManager.connectionState } returns serviceConnectionState + every { mockServiceConnectionContainer.appVersionInfoCache } returns mockAppVersionInfoCache + every { mockAppVersionInfoCache.onUpdate = any() } answers {} + + versionNotificationUseCase = + VersionNotificationUseCase( + serviceConnectionManager = mockServiceConnectionManager, + isVersionInfoNotificationEnabled = true + ) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `ensure notifications are empty by default`() = runTest { + // Arrange, Act, Assert + versionNotificationUseCase.notifications().test { assertTrue { awaitItem().isEmpty() } } + } + + @Test + fun `ensure UpdateAvailable notification is created`() = runTest { + versionNotificationUseCase.notifications().test { + // Arrange, Act + val upgradeVersionInfo = + VersionInfo("1.0", "1.1", isOutdated = true, isSupported = true) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + awaitItem() + versionInfo.value = upgradeVersionInfo + + // Assert + assertEquals(awaitItem(), listOf(InAppNotification.UpdateAvailable(upgradeVersionInfo))) + } + } + + @Test + fun `ensure UnsupportedVersion notification is created`() = runTest { + versionNotificationUseCase.notifications().test { + // Arrange, Act + val upgradeVersionInfo = VersionInfo("1.0", "", isOutdated = false, isSupported = false) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + awaitItem() + versionInfo.value = upgradeVersionInfo + + // Assert + assertEquals( + awaitItem(), + listOf(InAppNotification.UnsupportedVersion(upgradeVersionInfo)) + ) + } + } + + companion object { + private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt" + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index bddaee353e..5839e575c1 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt @@ -11,12 +11,12 @@ import io.mockk.unmockkAll import io.mockk.verify import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertTrue import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.ConnectNotificationState import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.model.AccountExpiry @@ -27,6 +27,8 @@ import net.mullvad.mullvadvpn.relaylist.RelayCountry import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache @@ -42,8 +44,6 @@ import net.mullvad.mullvadvpn.util.appVersionCallbackFlow import net.mullvad.talpid.tunnel.ErrorState import net.mullvad.talpid.tunnel.ErrorStateCause import net.mullvad.talpid.util.EventNotifier -import org.joda.time.DateTime -import org.joda.time.ReadableInstant import org.junit.After import org.junit.Before import org.junit.Rule @@ -68,6 +68,7 @@ class ConnectViewModelTest { ) private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) private val deviceState = MutableStateFlow<DeviceState>(DeviceState.Initial) + private val notifications = MutableStateFlow<List<InAppNotification>>(emptyList()) // Service connections private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() @@ -83,6 +84,9 @@ class ConnectViewModelTest { // Device Repository private val mockDeviceRepository: DeviceRepository = mockk() + // In App Notifications + private val mockInAppNotificationController: InAppNotificationController = mockk() + // Captures private val locationSlot = slot<((GeoIpLocation?) -> Unit)>() private val relaySlot = slot<(List<RelayCountry>, RelayItem?) -> Unit>() @@ -111,6 +115,8 @@ class ConnectViewModelTest { every { mockDeviceRepository.deviceState } returns deviceState + every { mockInAppNotificationController.notifications } returns notifications + every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState @@ -126,7 +132,8 @@ class ConnectViewModelTest { serviceConnectionManager = mockServiceConnectionManager, accountRepository = mockAccountRepository, deviceRepository = mockDeviceRepository, - isVersionInfoNotificationEnabled = true + inAppNotificationController = mockInAppNotificationController, + newDeviceNotificationUseCase = mockk() ) } @@ -144,8 +151,6 @@ class ConnectViewModelTest { @Test fun testTunnelInfoExpandedUpdate() = runTest(testCoroutineRule.testDispatcher) { - val expectedResult = true - viewModel.uiState.test { assertEquals(ConnectUiState.INITIAL, awaitItem()) serviceConnectionState.value = @@ -154,7 +159,7 @@ class ConnectViewModelTest { relaySlot.captured.invoke(mockk(), mockk()) viewModel.toggleTunnelInfoExpansion() val result = awaitItem() - assertEquals(expectedResult, result.isTunnelInfoExpanded) + assertTrue(result.isTunnelInfoExpanded) } } @@ -288,34 +293,14 @@ class ConnectViewModelTest { } @Test - fun testBlockingNotificationState() = - runTest(testCoroutineRule.testDispatcher) { - // Arrange - val expectedConnectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationBlocked - val tunnelUiState = TunnelState.Connecting(null, null) - - // Act, Assert - viewModel.uiState.test { - assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockLocation) - relaySlot.captured.invoke(mockk(), mockk()) - eventNotifierTunnelUiState.notify(tunnelUiState) - val result = awaitItem() - assertEquals(expectedConnectNotificationState, result.connectNotificationState) - } - } - - @Test fun testErrorNotificationState() = runTest(testCoroutineRule.testDispatcher) { // Arrange val mockErrorState: ErrorState = mockk() val expectedConnectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationError(mockErrorState) + InAppNotification.TunnelStateError(mockErrorState) val tunnelUiState = TunnelState.Error(mockErrorState) + notifications.value = listOf(expectedConnectNotificationState) // Act, Assert viewModel.uiState.test { @@ -326,53 +311,7 @@ class ConnectViewModelTest { relaySlot.captured.invoke(mockk(), mockk()) eventNotifierTunnelUiState.notify(tunnelUiState) val result = awaitItem() - assertEquals(expectedConnectNotificationState, result.connectNotificationState) - } - } - - @Test - fun testVersionInfoNotificationState() = - runTest(testCoroutineRule.testDispatcher) { - // Arrange - val mockVersionInfo: VersionInfo = mockk() - val expectedConnectNotificationState = - ConnectNotificationState.ShowVersionInfoNotification(mockVersionInfo) - every { mockVersionInfo.isOutdated } returns true - - // Act, Assert - viewModel.uiState.test { - assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockLocation) - relaySlot.captured.invoke(mockk(), mockk()) - versionInfo.value = mockVersionInfo - val result = awaitItem() - assertEquals(expectedConnectNotificationState, result.connectNotificationState) - } - } - - @Test - fun testAccountExpiryNotificationState() = - runTest(testCoroutineRule.testDispatcher) { - // Arrange - val mockDateTime: DateTime = mockk() - val expectedConnectNotificationState = - ConnectNotificationState.ShowAccountExpiryNotification(mockDateTime) - every { mockDateTime.isBefore(any<ReadableInstant>()) } returns true - every { mockDateTime.toInstant().millis } returns 0 - - // Act, Assert - viewModel.uiState.test { - assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockLocation) - relaySlot.captured.invoke(mockk(), mockk()) - accountExpiryState.value = AccountExpiry.Available(mockDateTime) - - val result = awaitItem() - assertEquals(expectedConnectNotificationState, result.connectNotificationState) + assertEquals(expectedConnectNotificationState, result.inAppNotification) } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt index 744989a922..2ada5bf767 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt @@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.model.DeviceListEvent import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -34,6 +35,7 @@ class LoginViewModelTest { @MockK private lateinit var mockedAccountRepository: AccountRepository @MockK private lateinit var mockedDeviceRepository: DeviceRepository + @MockK private lateinit var mockedNewDeviceNotificationUseCase: NewDeviceNotificationUseCase private lateinit var loginViewModel: LoginViewModel private val accountHistoryTestEvents = MutableStateFlow<AccountHistory>(AccountHistory.Missing) @@ -44,11 +46,13 @@ class LoginViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) every { mockedAccountRepository.accountHistory } returns accountHistoryTestEvents + every { mockedNewDeviceNotificationUseCase.newDeviceCreated() } returns Unit loginViewModel = LoginViewModel( mockedAccountRepository, mockedDeviceRepository, + mockedNewDeviceNotificationUseCase, UnconfinedTestDispatcher() ) } |
