diff --git a/CHANGELOG.md b/CHANGELOG.md index ad5ade0bee41..cf2d40fd85d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Line wrap the file at 100 chars. Th - Migrate Report Problem view to compose. - Migrate View Logs view to compose. - Migrate voucher dialog to compose. +- Add "New Device" in app notification & rework notification system #### Linux - Don't block forwarding of traffic when the split tunnel mark (ct mark) is set. diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index d6ef5d3311b1..56894addeaa3 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().asSharedFlow() ) @@ -123,8 +122,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationBlocked + inAppNotification = InAppNotification.TunnelStateBlocked ), uiSideEffect = MutableSharedFlow().asSharedFlow() ) @@ -158,7 +156,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow() ) @@ -191,7 +189,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow() ) @@ -225,7 +223,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow() ) @@ -259,7 +257,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().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().asSharedFlow() ) @@ -409,8 +406,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationBlocked + inAppNotification = InAppNotification.TunnelStateBlocked ), uiSideEffect = MutableSharedFlow().asSharedFlow() ) @@ -446,7 +442,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), onSwitchLocationClick = mockedClickHandler @@ -479,7 +475,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), onDisconnectClick = mockedClickHandler @@ -512,7 +508,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), onReconnectClick = mockedClickHandler @@ -544,7 +540,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), onConnectClick = mockedClickHandler @@ -576,7 +572,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), onCancelClick = mockedClickHandler @@ -609,7 +605,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), onToggleTunnelInfo = mockedClickHandler @@ -649,7 +645,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = true, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = ConnectNotificationState.HideNotification + inAppNotification = null ), uiSideEffect = MutableSharedFlow().asSharedFlow() ) @@ -688,8 +684,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + inAppNotification = InAppNotification.UpdateAvailable(versionInfo) ), uiSideEffect = MutableSharedFlow().asSharedFlow() ) @@ -726,8 +721,7 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + inAppNotification = InAppNotification.UnsupportedVersion(versionInfo) ), uiSideEffect = MutableSharedFlow().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().asSharedFlow() ) @@ -801,15 +794,14 @@ class ConnectScreenTest { isTunnelInfoExpanded = false, deviceName = "", daysLeftUntilExpiry = null, - connectNotificationState = - ConnectNotificationState.ShowVersionInfoNotification(versionInfo) + inAppNotification = InAppNotification.UnsupportedVersion(versionInfo) ), uiSideEffect = MutableSharedFlow().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().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 0f7fa7411719..000000000000 --- 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 000000000000..6078e4b39251 --- /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 000000000000..3fbf0ad095dd --- /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 e57e9be563c5..b88f0a86ba99 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 d5cdaf0f882a..6c294e620795 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 a0beecb65572..b2a6bc9b9e3a 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 71ba71e54ce1..84396805004c 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 93b9df5b7a6e..6ab4839bd159 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?, 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 3cf06f201bc3..dea9e12a3d82 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 dff48b6228f8..7f7e4acf456f 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 1d4421b063b2..398e27820e82 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 { 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 000000000000..0751d0b1f784 --- /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 b83ce973c176..532787ff4f44 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 6960fd656bcf..acac4ae7f603 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 000000000000..a4961bafe7d0 --- /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> = + 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 000000000000..628cc555ec66 --- /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 000000000000..f228bd7dbecc --- /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> = + 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 = + 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 000000000000..28496c46398e --- /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 01ba71ff861a..8a4f087d64b0 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(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 = 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 953e59f388d0..b31478ce1a95 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 000000000000..30b54cea114f --- /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()) + private val newDeviceNotifications = MutableStateFlow(emptyList()) + private val versionNotifications = MutableStateFlow(emptyList()) + private val tunnelStateNotifications = MutableStateFlow(emptyList()) + + 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 000000000000..5341708d3bde --- /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.Missing) + private lateinit var accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase + + @Before + fun setup() { + MockKAnnotations.init(this) + + val accountRepository = mockk() + 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 000000000000..bd375d729a5c --- /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.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 000000000000..1b89c92be732 --- /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.Disconnected) + private lateinit var tunnelStateNotificationUseCase: TunnelStateNotificationUseCase + + private val eventNotifierTunnelUiState = EventNotifier(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 000000000000..5aba70c9384b --- /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.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().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 bddaee353ea9..5839e575c19e 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.Missing) private val deviceState = MutableStateFlow(DeviceState.Initial) + private val notifications = MutableStateFlow>(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, 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) } } @@ -287,35 +292,15 @@ class ConnectViewModelTest { verify { mockConnectionProxy.disconnect() } } - @Test - fun testBlockingNotificationState() = - runTest(testCoroutineRule.testDispatcher) { - // Arrange - val expectedConnectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationBlocked - val tunnelUiState = TunnelState.Connecting(null, null) - - // Act, Assert - viewModel.uiState.test { - assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - locationSlot.captured.invoke(mockLocation) - relaySlot.captured.invoke(mockk(), mockk()) - eventNotifierTunnelUiState.notify(tunnelUiState) - val result = awaitItem() - assertEquals(expectedConnectNotificationState, result.connectNotificationState) - } - } - @Test fun testErrorNotificationState() = runTest(testCoroutineRule.testDispatcher) { // Arrange val mockErrorState: ErrorState = mockk() val expectedConnectNotificationState = - ConnectNotificationState.ShowTunnelStateNotificationError(mockErrorState) + 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()) } 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 744989a9220c..2ada5bf767b7 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.Missing) @@ -44,11 +46,13 @@ class LoginViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) every { mockedAccountRepository.accountHistory } returns accountHistoryTestEvents + every { mockedNewDeviceNotificationUseCase.newDeviceCreated() } returns Unit loginViewModel = LoginViewModel( mockedAccountRepository, mockedDeviceRepository, + mockedNewDeviceNotificationUseCase, UnconfinedTestDispatcher() ) } diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt index f009f4857bec..bbdd2a56a57e 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorNotificationMessage.kt @@ -1,21 +1,7 @@ package net.mullvad.mullvadvpn.lib.common.util -import android.content.res.Resources - data class ErrorNotificationMessage( val titleResourceId: Int, val messageResourceId: Int, val optionalMessageArgument: String? = null -) { - fun getTitleText(resources: Resources): String { - return resources.getString(titleResourceId) - } - - fun getMessageText(resources: Resources): String { - return if (optionalMessageArgument != null) { - resources.getString(messageResourceId, optionalMessageArgument) - } else { - resources.getString(messageResourceId) - } - } -} +) diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml index b98455f7d92b..64a7243b0349 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -119,6 +119,8 @@ Log ud af mindst én ved at fjerne den fra listen nedenfor. Du kan finde det tilsvarende enhedsnavn under enhedens kontoindstillinger. For mange enheder Mullvad-kontonummer + Velkommen! Denne enhed hedder nu <b>%1$s</b>. Se info-knappen i Konto for at flere oplysninger. + NY ENHED OPRETTET Ingen servere matcher dine indstillinger. Prøv at ændre server eller andre indstillinger. Gyldig WireGuard-nøgle mangler. Administrer nøgler under Avancerede indstillinger. DU LÆKKER MÅSKE NETVÆRKSTRAFIK diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml index 19120efa7b45..0f1bbdade07d 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -119,6 +119,8 @@ Bitte melden Sie sich von mindestens einem Gerät ab, indem Sie es aus der Liste unten entfernen. Sie finden den entsprechenden Gerätenamen unter den Kontoeinstellungen des Geräts. Zu viele Geräte Mullvad-Kontonummer + Dieses Gerät heißt jetzt <b>%1$s</b>. Weitere Details finden Sie über die Info-Schaltfläche in Ihrem Konto. + NEUES GERÄT ERSTELLT Kein Server entspricht Ihren Einstellungen. Versuchen Sie, den Server oder andere Einstellungen zu ändern. Gültiger WireGuard-Schlüssel fehlt. Sie können Ihre Schlüssel in den erweiterten Einstellungen verwalten. MÖGLICHERWEISE IST IHR NETZWERKVERKEHR UNSICHER diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml index e5c5f7d6574f..a1122c7cf68f 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -119,6 +119,8 @@ Cierre la sesión como mínimo en un dispositivo (para hacerlo, quítelo de la lista siguiente). Consulte el nombre del dispositivo en la configuración de la cuenta del dispositivo. Demasiados dispositivos Número de cuenta de Mullvad + Hola, este dispositivo se llama ahora <b>%1$s</b>. Para más información, consulte el botón de información en la Cuenta. + NUEVO DISPOSITIVO CREADO Ningún servidor coincide con su configuración, pruebe con otro servidor u otra configuración. Falta una clave de WireGuard válida. Para administrar las claves, vaya a Configuración avanzada. PUEDE QUE SE ESTÉ FILTRANDO EL TRÁFICO DE RED diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml index 379d8dd4bbdd..ac68c162843a 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -119,6 +119,8 @@ Kirjaudu ulos vähintään yhdestä luettelon laitteesta poistamalla se. Löydät vastaavan laitteen nimen laitteen tiliasetuksista. Liikaa laitteita Mullvad-tilin numero + Tervetuloa! Tämän laitteen nimi on nyt <b>%1$s</b>. Katso lisätietoja tilin infopainikkeesta. + UUSI LAITE LUOTIIN Mikään palvelin ei vastaa asetuksiasi. Kokeile vaihtaa palvelinta tai muuttaa muita asetuksia. Käypä WireGuard-avain puuttuu. Voit hallinnoida avaimia lisäasetuksissa. VERKKOLIIKENTEESI SAATTAA VUOTAA diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml index 9da5482c92c6..3ca0a3dc4983 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -119,6 +119,8 @@ Merci de vous déconnecter d\'au moins un appareil en le supprimant de la liste ci-dessous. Vous trouverez le nom de l\'appareil correspondant dans les paramètres du compte de l\'appareil. Trop d\'appareils Numéro de compte Mullvad + Bienvenue, cet appareil s\'appelle désormais <b>%1$s</b>. Pour plus d\'informations, consultez le bouton d\'information sous Compte. + NOUVEL APPAREIL CRÉÉ Aucun serveur ne correspond à vos paramètres, essayez de modifier les paramètres du serveur ou d\'autres paramètres. Une clé WireGuard valide manque. Gérez les clés dans les paramètres avancés. VOUS POURRIEZ AVOIR DES FUITES DE TRAFIC RÉSEAU diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml index e91aaecdb9bb..6362a1708605 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -119,6 +119,8 @@ Disconnettiti da almeno un dispositivo rimuovendolo dall\'elenco seguente. Puoi trovare il nome del dispositivo corrispondente nelle impostazioni dell\'account del dispositivo. Troppi dispositivi Numero di account Mullvad + Benvenuto, questo dispositivo ora si chiama <b>%1$s</b>. Per maggiori dettagli, premi il pulsante delle informazioni in Account. + NUOVO DISPOSITIVO CREATO Nessun server corrispondente alle tue impostazioni, prova a cambiare server o impostazioni. Manca una chiave WireGuard valida. Gestisci le chiavi da Impostazioni avanzate. POSSIBILI PERDITE NEL TRAFFICO DI RETE diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml index 3112ec2b1c8c..061357c5150e 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -119,6 +119,8 @@ 以下のリストから少なくとも1つを削除してログアウトしてください。対応するデバイス名はデバイスのアカウント設定で確認できます。 デバイスが多すぎます Mullvadアカウント番号 + ようこそ。このデバイスの名前は<b>%1$s</b>です。詳細はアカウントの情報ボタンで確認してください。 + 新しいデバイスが作成されました 設定に一致するサーバーはありません。サーバーまたは他の設定を変更してみてください。 有効なWireGuard鍵が見つかりません。詳細設定で鍵を管理してください。 ネットワーク通信が漏洩している可能性があります diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml index b53596691113..e3e4b7b1ecc7 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -119,6 +119,8 @@ 하나 이상의 항목을 아래 목록에서 제거하여 로그아웃하세요. 장치의 계정 설정에서 해당 장치 이름을 찾을 수 있습니다. 장치가 너무 많음 Mullvad 계정 번호 + 환영합니다! 이제 이 장치의 이름은 <b>%1$s</b>입니다. 자세한 내용을 보려면 계정의 정보 버튼을 누르세요. + 새 장치가 생성됨 설정과 일치하는 서버가 없습니다. 서버 또는 기타 설정을 변경해 보세요. 유효한 WireGuard 키가 없습니다. 고급 설정에서 키를 관리하세요. 네트워크 트래픽이 유출될 수 있습니다. diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml index 6a0f2ba37708..b15634975619 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -119,6 +119,8 @@ အောက်ပါစာရင်းမှ အနည်းဆုံး တစ်ခုကို ဖယ်ရှားခြင်းဖြင့် ၎င်းမှ ထွက်ပါ။ စက်၏ အကောင့်ဆက်တင်အောက်တွင် သက်ဆိုင်သော စက်အမည်ကို သင် ရှာနိုင်သည်။ စက်များလွန်းနေသည် Mullvad အကောင့်နံပါတ် + ကြိုဆိုပါသည်၊ ယခုမှစ၍ ဤစက်ကို <b>%1$s</b> ဟု ခေါ်ဆိုပါမည်။ နောက်ထပ်အသေးစိတ်တို့အတွက် အကောင့်တွင် အချက်အလက် ခလုတ်ကို နှိပ်၍ ကြည့်နိုင်သည်။ + စက်အသစ် ဖန်တီးထားသည် သင့်ဆက်တင်နှင့် ကိုက်ညီသော ဆာဗာများ မရှိပါ၊ ဆာဗာ သို့မဟုတ် အခြားဆက်တင်တို့ကို ပြောင်းလဲရန် ကြိုးစားကြည့်ပါ။ အကျုံးဝင်သည့် WireGuard ကီး မရှိပါ။ အဆင့်မြင့်ဆက်တင် အောက်တွင် ကီးများကို စီမံခန့်ခွဲပါ။ ကွန်ရက် ကူးလူးမှု ပေါက်ကြားနေနိုင်ပါသည် diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml index 68726083062c..385b3bdd9311 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -119,6 +119,8 @@ Logg ut av minst én ved å fjerne den fra listen nedenfor. Du finner det tilsvarende enhetsnavnet under enhetens kontoinnstillinger. For mange enheter Mullvad-kontonummer + Velkommen. Denne enheten har fått navnet <b>%1$s</b>. For å finne ut mer kan du bruke informasjonsknappen under Konto. + NY ENHET OPPRETTET Ingen servere passer til innstillingene dine. Prøv å endre server eller andre innstillinger. Det mangler en gyldig WireGuard-nøkkel. Du kan behandle nøklene under avanserte innstillinger. DET KAN VÆRE EN NETTVERKSLEKKASJE HOS DEG diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml index 005f1c690763..db496c3822d0 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -119,6 +119,8 @@ Meld u bij minstens één apparaat af door het te verwijderen uit de onderstaande lijst. U kunt de bijbehorende apparaatnaam vinden in de accountinstellingen van het apparaat. Te veel apparaten Mullvad-accountnummer + Welkom, dit apparaat heet nu <b>%1$s</b>. Zie voor meer informatie de infoknop in Account. + NIEUW APPARAAT GEMAAKT Er zijn geen servers die overeenkomen met uw instellingen. Probeer een andere server of andere instellingen. Geldige WireGuard-sleutel ontbreekt. Beheer sleutels onder Geavanceerde instellingen. U LEKT MOGELIJK NETWERKVERKEER diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml index 98b69a66a869..0e64f904ab4a 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -119,6 +119,8 @@ Wyloguj się z co najmniej jednego urządzenia, usuwając je z poniższej listy. Odpowiednią nazwę urządzenia można znaleźć w ustawieniach konta urządzenia. Zbyt wiele urządzeń Numer konta Mullvad + Witaj, to urządzenie nazywa się teraz <b>%1$s</b>. Więcej szczegółów znajdziesz, korzystając z przycisku Informacje na koncie. + UTWORZONO NOWE URZĄDZENIE Żaden serwer nie odpowiada ustawieniom. Spróbuj zmienić serwer lub inne ustawienia. Brak prawidłowego klucza WireGuard. Zarządzaj kluczami w Ustawieniach zaawansowanych. TWÓJ RUCH SIECIOWY MOŻE WYCIEKAĆ diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml index 5dd4fd61ea28..ce3fb68f31af 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -119,6 +119,8 @@ Desligue-se de pelo menos um dos dispositivos removendo-o da lista abaixo. Pode encontrar o nome do dispositivo correspondente nas definições de Conta do dispositivo. Demasiados dispositivos Número de conta Mullvad + Bem-vindo, este dispositivo é agora chamado <b>%1$s</b>. Para mais detalhes consulte o botão de informação na Conta. + NOVO DISPOSITIVO CRIADO Nenhum servidor corresponde às suas definições. Tente alterar o servidor ou outras definições. Chave WireGuard válida em falta. Faça a gestão das chaves em Definições Avançadas. PODERÁ ESTAR A PERDER TRÁFEGO DE REDE diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml index 7b9acc9195c1..14414945e50b 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -119,6 +119,8 @@ Выйдите из учетной записи хотя бы на одном из устройств, удалив его из списка ниже. Имя устройства указано в настройках учетной записи. Слишком много устройств Номер учетной записи Mullvad + Добро пожаловать, теперь это устройство называется <b>%1$s</b>. Для получения более подробной нажмите на кнопку «Информация» в учетной записи. + СОЗДАНО НОВОЕ УСТРОЙСТВО Нет серверов, соответствующих вашим настройкам. Попробуйте изменить сервер или задайте другие настройки. Не найден действительный ключ WireGuard. Управлять ключами можно в дополнительных настройках. ВОЗМОЖНА УТЕЧКА СЕТЕВОГО ТРАФИКА diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml index d8183b2435ce..216828a1c137 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -119,6 +119,8 @@ Logga ut på minst en enhet genom att ta bort den från listan nedan. Du hittar motsvarande enhetsnamn i enhetens kontoinställningar. För många enheter Mullvad-kontonummer + Välkommen! Den här enheten heter nu <b>%1$s</b>. Använd informationsknappen i Konto för mer information. + NY ENHET HAR SKAPATS Inga servrar matchar dina inställningar. Försök att byta server eller ändra inställningarna. Giltig WireGuard-nyckel saknas. Hantera nycklar i Avancerade inställningar. DU KANSKE HAR LÄCKAGE I NÄTVERKSTRAFIKEN diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml index 7afc8a7b440c..d02d60bc85f7 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -119,6 +119,8 @@ โปรดลงชื่อออกจากระบบบนอุปกรณ์อย่างน้อยหนึ่งเครื่อง เพื่อนำอุปกรณ์ออกจากรายการด้านล่าง คุณสามารถดูชื่ออุปกรณ์ที่เกี่ยวข้องได้ ภายใต้การตั้งค่าบัญชีของอุปกรณ์ มีอุปกรณ์มากเกินไป หมายเลขบัญชี Mullvad + ยินดีต้อนรับ ขณะนี้อุปกรณ์นี้จะมีชื่อว่า <b>%1$s</b> สำหรับข้อมูลเพิ่มเติม โปรดกดปุ่มข้อมูลในบัญชี + สร้างอุปกรณ์ใหม่แล้ว ไม่มีเซิร์ฟเวอร์ที่ตรงกับการตั้งค่าของคุณ โปรดลองเปลี่ยนเซิร์ฟเวอร์ หรือการตั้งค่าอื่นๆ คีย์ WireGuard ที่ใช้ได้ขาดหายไป จัดการคีย์ภายใต้การตั้งค่าขั้นสูง คุณอาจมีการรับส่งข้อมูลทางเครือข่ายที่รั่วไหลอยู่ diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml index 908f9ce2d989..7d9ddf4f763f 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -119,6 +119,8 @@ Lütfen aşağıdaki listeden en az bir cihazı kaldırarak çıkış yapın. İlgili cihaz adını cihazın Hesap ayarları altında bulabilirsiniz. Cihaz sayısı çok fazla Mullvad hesap numarası + Hoş geldiniz, bu cihaz artık <b>%1$s</b> olarak adlandırılıyor. Daha fazla ayrıntı için Hesaptaki bilgi düğmesine bakın. + YENİ CİHAZ OLUŞTURULDU Ayarlarınızla eşleşen sunucu yok. Sunucuyu veya diğer ayarları değiştirmeyi deneyin. Geçerli WireGuard anahtarı eksik. Gelişmiş ayarlardan anahtarları yönetin. AĞ TRAFİĞİNİZDE SIZINTI OLABİLİR diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml index 667ffbcde9f5..251754caa5f2 100644 --- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml @@ -119,6 +119,8 @@ 请通过从以下列表中移除的方式退出至少一个帐户。您可以在设备的帐户设置下找到相应设备名称。 设备过多 Mullvad 帐号 + 欢迎,此设备现在名为 <b>%1$s</b>。有关详情,请点击“帐户”中的信息按钮。 + 已创建新设备 没有与您的设置匹配的服务器,请尝试更改服务器或其他设置。 缺少有效的 WireGuard 密钥。在“高级”设置下管理密钥。 您的网络流量可能在泄露 diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml index 5378b92550a3..322f4fa512ec 100644 --- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml @@ -119,6 +119,8 @@ 請從底下清單至少移除一個裝置來將其登出。您可以在裝置的「帳戶」設定下找到相應裝置名稱。 裝置過多 Mullvad 帳號 + 歡迎,此裝置現在稱為 <b>%1$s</b>。如需詳細資訊,請點按「帳戶」中的資訊按鈕。 + 已建立新裝置 沒有與您的設定相符的伺服器,請嘗試變更伺服器或其他設定。 缺少有效的 WireGuard 金鑰。在「進階」設定下管理金鑰。 您的網路流量可能正在洩露 diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index c9c837d38d64..033d3b946393 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -158,6 +158,8 @@ VPN permission error Always-on VPN might be enabled for another app + NEW DEVICE CREATED + %s. For more details see the info button in Account.]]> Agree and continue Privacy To make sure that you have the most secure version and to inform you of any issues with the current version that is running, the app performs version checks automatically. This sends the app version and Android system version to Mullvad servers. Mullvad keeps counters on number of used app versions and Android versions. The data is never stored or used in any way that can identify you.\n\nIf the split tunneling feature is used, then the app queries your system for a list of all installed applications. This list is only retrieved in the split tunneling view. The list of installed applications is never sent from the device. 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 ec2c2ff18edf..3bb59368f30e 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,