diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreenTest.kt similarity index 71% rename from android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialogTest.kt rename to android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreenTest.kt index b811209d1ce3..4494dadeb983 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreenTest.kt @@ -1,8 +1,7 @@ -package net.mullvad.mullvadvpn.compose.dialog +package net.mullvad.mullvadvpn.compose.screen import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import de.mannodermaus.junit5.compose.ComposeContext import io.mockk.MockKAnnotations import io.mockk.impl.annotations.MockK @@ -15,7 +14,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @OptIn(ExperimentalTestApi::class) -class ChangelogDialogTest { +class ChangelogScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() @MockK lateinit var mockedViewModel: AppInfoViewModel @@ -25,29 +24,28 @@ class ChangelogDialogTest { MockKAnnotations.init(this) } - private fun ComposeContext.initDialog(state: ChangelogUiState, onDismiss: () -> Unit = {}) { - setContentWithTheme { ChangelogDialog(state = state, onDismiss = onDismiss) } + private fun ComposeContext.initScreen(state: ChangelogUiState, onBackClick: () -> Unit = {}) { + setContentWithTheme { ChangelogScreen(state = state, onBackClick = onBackClick) } } @Test fun testShowChangeLogWhenNeeded() = composeExtension.use { // Arrange - initDialog( + initScreen( state = ChangelogUiState(changes = listOf(CHANGELOG_ITEM), version = CHANGELOG_VERSION), - onDismiss = {}, + onBackClick = {}, ) + // Check changelog version shown + onNodeWithText(CHANGELOG_VERSION).assertExists() + // Check changelog content showed within dialog onNodeWithText(CHANGELOG_ITEM).assertExists() - - // perform click on Got It button to check if dismiss occur - onNodeWithText(CHANGELOG_BUTTON_TEXT).performClick() } companion object { - private const val CHANGELOG_BUTTON_TEXT = "Got it!" private const val CHANGELOG_ITEM = "Changelog item" private const val CHANGELOG_VERSION = "1234.5" } 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 be998a37289e..65aac622bef0 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 @@ -17,6 +17,7 @@ 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.CONNECT_CARD_HEADER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION +import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_TEXT_ACTION import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.TOP_BAR_ACCOUNT_BUTTON @@ -53,6 +54,7 @@ class ConnectScreenTest { unmockkAll() } + @Suppress("LongParameterList") private fun ComposeContext.initScreen( state: ConnectUiState = ConnectUiState.INITIAL, onDisconnectClick: () -> Unit = {}, @@ -65,6 +67,8 @@ class ConnectScreenTest { onSettingsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, onDismissNewDeviceClick: () -> Unit = {}, + onChangelogClick: () -> Unit = {}, + onDismissChangelogClick: () -> Unit = {}, ) { setContentWithTheme { ConnectScreen( @@ -79,6 +83,8 @@ class ConnectScreenTest { onSettingsClick = onSettingsClick, onAccountClick = onAccountClick, onDismissNewDeviceClick = onDismissNewDeviceClick, + onChangelogClick = onChangelogClick, + onDismissChangelogClick = onDismissChangelogClick, ) } } @@ -631,6 +637,34 @@ class ConnectScreenTest { } } + @Test + fun testOnNewChangelogMessageClick() { + composeExtension.use { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + initScreen( + onChangelogClick = mockedClickHandler, + state = + ConnectUiState( + location = null, + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(null, null, emptyList()), + showLocation = false, + deviceName = "", + daysLeftUntilExpiry = null, + inAppNotification = InAppNotification.NewVersionChangelog, + isPlayBuild = false, + ), + ) + + // Act + onNodeWithTag(NOTIFICATION_BANNER_TEXT_ACTION).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + } + @Test fun testOpenAccountView() { composeExtension.use { 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 index bc1aaeb641d3..90bdbca1bee6 100644 --- 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 @@ -5,21 +5,25 @@ import androidx.compose.animation.animateContentSize import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable 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.layout.wrapContentWidth 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.toUpperCase import androidx.compose.ui.tooling.preview.Preview @@ -29,6 +33,7 @@ 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.test.NOTIFICATION_BANNER_TEXT_ACTION import net.mullvad.mullvadvpn.compose.util.rememberPrevious import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.lib.model.ErrorStateCause @@ -56,8 +61,9 @@ private fun PreviewNotificationBanner() { InAppNotification.TunnelStateError( error = ErrorState(ErrorStateCause.FirewallPolicyError.Generic, true) ), + InAppNotification.NewVersionChangelog, ) - .map { it.toNotificationData(false, {}, {}, {}) } + .map { it.toNotificationData(false, {}, {}, {}, {}, {}) } bannerDataList.forEach { MullvadTopBar( @@ -80,6 +86,8 @@ fun NotificationBanner( isPlayBuild: Boolean, openAppListing: () -> Unit, onClickShowAccount: () -> Unit, + onClickShowChangelog: () -> Unit, + onClickDismissChangelog: () -> Unit, onClickDismissNewDevice: () -> Unit, ) { // Fix for animating to invisible state @@ -97,6 +105,8 @@ fun NotificationBanner( isPlayBuild = isPlayBuild, openAppListing, onClickShowAccount, + onClickShowChangelog, + onClickDismissChangelog, onClickDismissNewDevice, ) ) @@ -153,21 +163,38 @@ private fun Notification(notificationBannerData: NotificationData) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - message?.let { + message?.let { message -> Text( - text = message, + text = message.text, modifier = Modifier.constrainAs(textMessage) { top.linkTo(textTitle.bottom) start.linkTo(textTitle.start) if (action != null) { end.linkTo(actionIcon.start) + bottom.linkTo(actionIcon.bottom) } else { end.linkTo(parent.end) + bottom.linkTo(parent.bottom) } width = Dimension.fillToConstraints + height = Dimension.fillToConstraints } - .padding(start = Dimens.smallPadding, top = Dimens.tinyPadding), + .padding(start = Dimens.smallPadding, top = Dimens.tinyPadding) + .wrapContentWidth(Alignment.Start) + .let { + if (message is NotificationMessage.ClickableText) { + it.clickable( + onClickLabel = message.contentDescription, + role = Role.Button, + ) { + message.onClick() + } + .testTag(NOTIFICATION_BANNER_TEXT_ACTION) + } else { + it + } + }, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, ) 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 index e6f9d3ea6930..58798978bc7c 100644 --- 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 @@ -10,7 +10,10 @@ 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.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.core.text.HtmlCompat import java.net.InetAddress import net.mullvad.mullvadvpn.R @@ -25,7 +28,7 @@ import net.mullvad.mullvadvpn.ui.notification.StatusLevel data class NotificationData( val title: AnnotatedString, - val message: AnnotatedString? = null, + val message: NotificationMessage? = null, val statusLevel: StatusLevel, val action: NotificationAction? = null, ) { @@ -34,7 +37,31 @@ data class NotificationData( message: String? = null, statusLevel: StatusLevel, action: NotificationAction? = null, - ) : this(AnnotatedString(title), message?.let { AnnotatedString(it) }, statusLevel, action) + ) : this( + AnnotatedString(title), + message?.let { NotificationMessage.Text(AnnotatedString(it)) }, + statusLevel, + action, + ) + + constructor( + title: String, + message: NotificationMessage, + statusLevel: StatusLevel, + action: NotificationAction? = null, + ) : this(AnnotatedString(title), message, statusLevel, action) +} + +sealed interface NotificationMessage { + val text: AnnotatedString + + data class Text(override val text: AnnotatedString) : NotificationMessage + + data class ClickableText( + override val text: AnnotatedString, + val onClick: () -> Unit, + val contentDescription: String, + ) : NotificationMessage } data class NotificationAction( @@ -48,7 +75,9 @@ fun InAppNotification.toNotificationData( isPlayBuild: Boolean, openAppListing: () -> Unit, onClickShowAccount: () -> Unit, - onDismissNewDevice: () -> Unit, + onClickShowChangelog: () -> Unit, + onClickDismissChangelog: () -> Unit, + onClickDismissNewDevice: () -> Unit, ) = when (this) { is InAppNotification.NewDevice -> @@ -56,13 +85,15 @@ fun InAppNotification.toNotificationData( title = AnnotatedString(stringResource(id = R.string.new_device_notification_title)), message = - stringResource(id = R.string.new_device_notification_message, deviceName) - .formatWithHtml(), + NotificationMessage.Text( + stringResource(id = R.string.new_device_notification_message, deviceName) + .formatWithHtml() + ), statusLevel = StatusLevel.Info, action = NotificationAction( Icons.Default.Clear, - onDismissNewDevice, + onClickDismissNewDevice, stringResource(id = R.string.dismiss), ), ) @@ -98,13 +129,40 @@ fun InAppNotification.toNotificationData( stringResource(id = R.string.open_url), ), ) + is InAppNotification.NewVersionChangelog -> + NotificationData( + title = stringResource(id = R.string.new_changelog_notification_title), + message = + NotificationMessage.ClickableText( + text = + buildAnnotatedString { + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + append( + stringResource( + id = R.string.new_changelog_notification_message + ) + ) + } + }, + onClick = onClickShowChangelog, + contentDescription = + stringResource(id = R.string.new_changelog_notification_message), + ), + statusLevel = StatusLevel.Info, + action = + NotificationAction( + Icons.Default.Clear, + onClickDismissChangelog, + stringResource(id = R.string.dismiss), + ), + ) } @Composable private fun errorMessageBannerData(error: ErrorState) = NotificationData( title = error.title().formatWithHtml(), - message = error.message().formatWithHtml(), + message = NotificationMessage.Text(error.message().formatWithHtml()), statusLevel = StatusLevel.Error, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt new file mode 100644 index 000000000000..06cc387e02b9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.viewmodel.AppInfoUiState + +class AppInfoUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + AppInfoUiState( + version = VersionInfo(currentVersion = "2024.9", isSupported = true), + changes = listOf("More betterer app"), + isPlayBuild = true, + ), + AppInfoUiState( + version = VersionInfo(currentVersion = "2024.9", isSupported = false), + changes = listOf("More betterer app"), + isPlayBuild = true, + ), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt index e65dc2c8d887..31d838a67eb9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.Info import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -20,6 +19,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed import com.ramcosta.composedestinations.annotation.Destination @@ -31,14 +32,27 @@ import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell import net.mullvad.mullvadvpn.compose.cell.TwoRowCell import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.preview.AppInfoUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.viewmodel.AppInfoSideEffect import net.mullvad.mullvadvpn.viewmodel.AppInfoUiState import net.mullvad.mullvadvpn.viewmodel.AppInfoViewModel import org.koin.androidx.compose.koinViewModel +@OptIn(ExperimentalMaterial3Api::class) +@Preview("Initial|Unsupported") +@Composable +private fun PreviewAppInfoScreen( + @PreviewParameter(AppInfoUiStatePreviewParameterProvider::class) state: AppInfoUiState +) { + AppTheme { + AppInfo(state = state, onBackClick = {}, navigateToChangelog = {}, openAppListing = {}) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Destination(style = SlideInFromRightTransition::class) @Composable @@ -87,9 +101,9 @@ fun AppInfoContent( openAppListing: () -> Unit, ) { Column(modifier = Modifier.padding(bottom = Dimens.smallPadding).animateContentSize()) { - AppVersionRow(state, openAppListing) - ChangelogRow(navigateToChangelog) + HorizontalDivider(color = Color.Transparent) + AppVersionRow(state, openAppListing) } } @@ -133,8 +147,6 @@ private fun AppVersionRow(state: AppInfoUiState, openAppListing: () -> Unit) { bottom = Dimens.mediumPadding, ), ) - } else { - HorizontalDivider(color = Color.Transparent) } } } @@ -144,12 +156,5 @@ private fun ChangelogRow(navigateToChangelog: () -> Unit) { NavigationComposeCell( title = stringResource(R.string.changelog_title), onClick = navigateToChangelog, - bodyView = { - Icon( - imageVector = Icons.Default.Info, - contentDescription = stringResource(R.string.changelog_title), - tint = MaterialTheme.colorScheme.onPrimary, - ) - }, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreen.kt similarity index 59% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreen.kt index e5afe6d9bc96..41ff202f65b9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreen.kt @@ -1,14 +1,10 @@ -package net.mullvad.mullvadvpn.compose.dialog +package net.mullvad.mullvadvpn.compose.screen -import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,59 +16,47 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.viewmodel.ChangelogUiState import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import org.koin.androidx.compose.koinViewModel -@Destination(style = DestinationStyle.Dialog::class) +@Destination(style = SlideInFromRightTransition::class) @Composable fun Changelog(navController: NavController) { val viewModel = koinViewModel() val uiState = viewModel.uiState.collectAsStateWithLifecycle() - ChangelogDialog(uiState.value, onDismiss = { navController.navigateUp() }) + ChangelogScreen(uiState.value, onBackClick = { navController.navigateUp() }) + + viewModel.dismissChangelogNotification() } @Composable -fun ChangelogDialog(state: ChangelogUiState, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { +fun ChangelogScreen(state: ChangelogUiState, onBackClick: () -> Unit) { + + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.changelog_title), + navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) }, + ) { modifier -> + Column( + modifier = modifier.padding(horizontal = Dimens.mediumPadding), + verticalArrangement = Arrangement.spacedBy(Dimens.mediumPadding), + ) { Text( text = state.version, - style = MaterialTheme.typography.headlineLarge, + style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), ) - }, - text = { - val scrollState: ScrollState = rememberScrollState() - Column( - modifier = Modifier.fillMaxWidth().verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(Dimens.smallPadding), - ) { - Text( - text = stringResource(R.string.changes_dialog_subtitle), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth(), - ) - state.changes.forEach { changeItem -> ChangeListItem(text = changeItem) } - } - }, - confirmButton = { - PrimaryButton(text = stringResource(R.string.got_it), onClick = onDismiss) - }, - containerColor = MaterialTheme.colorScheme.surface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - ) + state.changes.forEach { changeItem -> ChangeListItem(text = changeItem) } + } + } } @Composable @@ -82,14 +66,14 @@ private fun ChangeListItem(text: String) { Text( text = "•", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(Dimens.buttonSpacing), textAlign = TextAlign.Center, ) Text( text = text, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -99,9 +83,9 @@ private fun ChangeListItem(text: String) { @Composable private fun PreviewChangelogDialogWithSingleShortItem() { AppTheme { - ChangelogDialog( + ChangelogScreen( ChangelogUiState(changes = listOf("Item 1"), version = "1111.1"), - onDismiss = {}, + onBackClick = {}, ) } } @@ -115,12 +99,12 @@ private fun PreviewChangelogDialogWithTwoLongItems() { "in multiple lines in the changelog dialog." AppTheme { - ChangelogDialog( + ChangelogScreen( ChangelogUiState( changes = listOf(longPreviewText, longPreviewText), version = "1111.1", ), - onDismiss = {}, + onBackClick = {}, ) } } @@ -129,7 +113,7 @@ private fun PreviewChangelogDialogWithTwoLongItems() { @Composable private fun PreviewChangelogDialogWithTenShortItems() { AppTheme { - ChangelogDialog( + ChangelogScreen( ChangelogUiState( changes = listOf( @@ -146,7 +130,7 @@ private fun PreviewChangelogDialogWithTenShortItems() { ), version = "1111.1", ), - onDismiss = {}, + onBackClick = {}, ) } } 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 73c5eb2413b2..4ec34ca67dde 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 @@ -54,6 +54,7 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.AccountDestination +import com.ramcosta.composedestinations.generated.destinations.ChangelogDestination import com.ramcosta.composedestinations.generated.destinations.DeviceRevokedDestination import com.ramcosta.composedestinations.generated.destinations.OutOfTimeDestination import com.ramcosta.composedestinations.generated.destinations.SelectLocationDestination @@ -139,10 +140,13 @@ private fun PreviewAccountScreen( {}, {}, {}, + {}, + {}, ) } } +@Suppress("LongMethod") @Destination(style = HomeTransition::class) @Composable fun Connect( @@ -234,6 +238,8 @@ fun Connect( onSwitchLocationClick = dropUnlessResumed { navigator.navigate(SelectLocationDestination) }, onOpenAppListing = connectViewModel::openAppListing, onManageAccountClick = connectViewModel::onManageAccountClick, + onChangelogClick = dropUnlessResumed { navigator.navigate(ChangelogDestination) }, + onDismissChangelogClick = connectViewModel::dismissNewChangelogNotification, onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) }, onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) }, onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, @@ -251,6 +257,8 @@ fun ConnectScreen( onSwitchLocationClick: () -> Unit, onOpenAppListing: () -> Unit, onManageAccountClick: () -> Unit, + onChangelogClick: () -> Unit, + onDismissChangelogClick: () -> Unit, onSettingsClick: () -> Unit, onAccountClick: () -> Unit, onDismissNewDeviceClick: () -> Unit, @@ -298,6 +306,8 @@ fun ConnectScreen( isPlayBuild = state.isPlayBuild, openAppListing = onOpenAppListing, onClickShowAccount = onManageAccountClick, + onClickShowChangelog = onChangelogClick, + onClickDismissChangelog = onDismissChangelogClick, onClickDismissNewDevice = onDismissNewDeviceClick, ) ConnectionCard( 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 eef89c5ea2b6..bdc85b1d6f93 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 @@ -46,6 +46,7 @@ const val LOCATION_INFO_CONNECTION_OUT_TEST_TAG = "location_info_connection_out_ // ConnectScreen - Notification banner const val NOTIFICATION_BANNER = "notification_banner" const val NOTIFICATION_BANNER_ACTION = "notification_banner_action" +const val NOTIFICATION_BANNER_TEXT_ACTION = "notification_banner_text_action" // PlayPayment const val PLAY_PAYMENT_INFO_ICON_TEST_TAG = "play_payment_info_icon_test_tag" 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 650ee67eaa1b..2ec3d4ac0ab3 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 @@ -43,6 +43,7 @@ import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.InternetAvailableUseCase import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase +import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase @@ -127,7 +128,7 @@ val uiModule = module { single { androidContext().contentResolver } single { ChangelogRepository(get()) } - single { UserPreferencesRepository(get()) } + single { UserPreferencesRepository(get(), get()) } single { SettingsRepository(get()) } single { MullvadProblemReport(get()) } single { RelayOverridesRepository(get()) } @@ -151,6 +152,7 @@ val uiModule = module { single { TunnelStateNotificationUseCase(get()) } single { VersionNotificationUseCase(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS) } single { NewDeviceNotificationUseCase(get(), get()) } + single { NewChangelogNotificationUseCase(get(), get(), get()) } single { OutOfTimeUseCase(get(), get(), MainScope()) } single { InternetAvailableUseCase(get()) } single { SystemVpnSettingsAvailableUseCase(androidContext()) } @@ -165,7 +167,7 @@ val uiModule = module { single { SelectedLocationUseCase(get(), get()) } single { FilterChipUseCase(get(), get(), get(), get()) } - single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } + single { InAppNotificationController(get(), get(), get(), get(), get(), MainScope()) } single { ChangelogDataProvider(get()) } @@ -203,6 +205,7 @@ val uiModule = module { get(), get(), get(), + get(), IS_PLAY_BUILD, get(named(SELF_PACKAGE_NAME)), ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt index 5267f5227147..8ade9848d21f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt @@ -1,5 +1,7 @@ package net.mullvad.mullvadvpn.repository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.util.trimAll @@ -7,6 +9,17 @@ private const val NEWLINE_CHAR = '\n' private const val BULLET_POINT_CHAR = '-' class ChangelogRepository(private val dataProvider: IChangelogDataProvider) { + private val _showNewChangelogNotification = MutableStateFlow(false) + + val showNewChangelogNotification: StateFlow = _showNewChangelogNotification + + fun setShowNewChangelogNotification() { + _showNewChangelogNotification.value = true + } + + fun setDismissNewChangelogNotification() { + _showNewChangelogNotification.value = false + } fun getLastVersionChanges(): List = // Prepend with a new line char so each entry consists of NEWLINE_CHAR + BULLET_POINT_CHAR 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 index 1608e3689ea9..0fcee60bede0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase +import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase @@ -47,11 +48,17 @@ sealed class InAppNotification { override val statusLevel = StatusLevel.Info override val priority: Long = 1001 } + + data object NewVersionChangelog : InAppNotification() { + override val statusLevel = StatusLevel.Info + override val priority: Long = 1001 + } } class InAppNotificationController( accountExpiryInAppNotificationUseCase: AccountExpiryInAppNotificationUseCase, newDeviceNotificationUseCase: NewDeviceNotificationUseCase, + newChangelogNotificationUseCase: NewChangelogNotificationUseCase, versionNotificationUseCase: VersionNotificationUseCase, tunnelStateNotificationUseCase: TunnelStateNotificationUseCase, scope: CoroutineScope, @@ -63,8 +70,9 @@ class InAppNotificationController( versionNotificationUseCase(), accountExpiryInAppNotificationUseCase(), newDeviceNotificationUseCase(), - ) { a, b, c, d -> - a + b + c + d + newChangelogNotificationUseCase(), + ) { a, b, c, d, e -> + a + b + c + d + e } .map { it.sortedWith( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt index f3e6a72b64cf..1400c6c90007 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt @@ -6,13 +6,17 @@ import java.io.IOException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first +import net.mullvad.mullvadvpn.lib.model.BuildVersion -class UserPreferencesRepository(private val userPreferences: DataStore) { +class UserPreferencesRepository( + private val userPreferencesStore: DataStore, + private val buildVersion: BuildVersion, +) { // Note: this should not be made into a StateFlow. See: // https://developer.android.com/reference/kotlin/androidx/datastore/core/DataStore#data() val preferencesFlow: Flow = - userPreferences.data.catch { exception -> + userPreferencesStore.data.catch { exception -> // dataStore.data throws an IOException when an error is encountered when reading data if (exception is IOException) { Logger.e("Error reading user preferences file, falling back to default.", exception) @@ -25,8 +29,17 @@ class UserPreferencesRepository(private val userPreferences: DataStore + userPreferencesStore.updateData { prefs -> prefs.toBuilder().setIsPrivacyDisclosureAccepted(true).build() } } + + suspend fun setHasDisplayedChangelogNotification() { + userPreferencesStore.updateData { prefs -> + prefs + .toBuilder() + .setVersionCodeForLatestShownChangelogNotification(buildVersion.code) + .build() + } + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt new file mode 100644 index 000000000000..e8198bd2558d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.lib.model.BuildVersion +import net.mullvad.mullvadvpn.repository.ChangelogRepository +import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.repository.UserPreferencesRepository + +class NewChangelogNotificationUseCase( + private val userPreferencesRepository: UserPreferencesRepository, + private val changelogRepository: ChangelogRepository, + private val buildVersion: BuildVersion, +) { + operator fun invoke() = + combine( + userPreferencesRepository.preferencesFlow, + changelogRepository.showNewChangelogNotification, + ) { preferences, showChangelog -> + if ( + buildVersion.code > preferences.versionCodeForLatestShownChangelogNotification + ) { + changelogRepository.setShowNewChangelogNotification() + userPreferencesRepository.setHasDisplayedChangelogNotification() + } + if (showChangelog) { + InAppNotification.NewVersionChangelog + } else null + } + .map(::listOfNotNull) + .distinctUntilChanged() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt index 571d5da3e32f..e634d8ec5061 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt @@ -8,12 +8,18 @@ import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.lib.model.BuildVersion import net.mullvad.mullvadvpn.repository.ChangelogRepository -class ChangelogViewModel(changelogRepository: ChangelogRepository, buildVersion: BuildVersion) : - ViewModel() { +class ChangelogViewModel( + private val changelogRepository: ChangelogRepository, + buildVersion: BuildVersion, +) : ViewModel() { val uiState: StateFlow = MutableStateFlow( ChangelogUiState(buildVersion.name, changelogRepository.getLastVersionChanges()) ) + + fun dismissChangelogNotification() { + changelogRepository.setDismissNewChangelogNotification() + } } @Parcelize data class ChangelogUiState(val version: String, val changes: List) : Parcelable 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 42838d75d625..285ed1f005f3 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 @@ -27,6 +27,7 @@ import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.repository.NewDeviceRepository import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase @@ -41,6 +42,7 @@ import net.mullvad.mullvadvpn.util.isSuccess class ConnectViewModel( private val accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, + private val changelogRepository: ChangelogRepository, inAppNotificationController: InAppNotificationController, private val newDeviceRepository: NewDeviceRepository, selectedLocationTitleUseCase: SelectedLocationTitleUseCase, @@ -185,6 +187,10 @@ class ConnectViewModel( newDeviceRepository.clearNewDeviceCreatedNotification() } + fun dismissNewChangelogNotification() { + changelogRepository.setDismissNewChangelogNotification() + } + private fun outOfTimeEffect() = outOfTimeUseCase.isOutOfTime.filter { it == true }.map { UiSideEffect.OutOfTime } diff --git a/android/app/src/main/proto/user_prefs.proto b/android/app/src/main/proto/user_prefs.proto index 3a7e79285ff5..434c41c2492d 100644 --- a/android/app/src/main/proto/user_prefs.proto +++ b/android/app/src/main/proto/user_prefs.proto @@ -3,4 +3,7 @@ syntax = "proto3"; option java_package = "net.mullvad.mullvadvpn.repository"; option java_multiple_files = true; -message UserPreferences { bool is_privacy_disclosure_accepted = 1; } +message UserPreferences { + bool is_privacy_disclosure_accepted = 1; + int32 version_code_for_latest_shown_changelog_notification = 2; +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt index 74b599da976f..c8b27f2e6f8b 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt @@ -17,6 +17,7 @@ import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase +import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase @@ -33,6 +34,8 @@ class InAppNotificationControllerTest { private lateinit var inAppNotificationController: InAppNotificationController private val accountExpiryNotifications = MutableStateFlow(emptyList()) private val newDeviceNotifications = MutableStateFlow(emptyList()) + private val newVersionChangelogNotifications = + MutableStateFlow(emptyList()) private val versionNotifications = MutableStateFlow(emptyList()) private val tunnelStateNotifications = MutableStateFlow(emptyList()) @@ -44,10 +47,13 @@ class InAppNotificationControllerTest { val accountExpiryInAppNotificationUseCase: AccountExpiryInAppNotificationUseCase = mockk() val newDeviceNotificationUseCase: NewDeviceNotificationUseCase = mockk() + val newVersionChangelogUseCase: NewChangelogNotificationUseCase = mockk() val versionNotificationUseCase: VersionNotificationUseCase = mockk() val tunnelStateNotificationUseCase: TunnelStateNotificationUseCase = mockk() every { accountExpiryInAppNotificationUseCase.invoke() } returns accountExpiryNotifications every { newDeviceNotificationUseCase.invoke() } returns newDeviceNotifications + every { newVersionChangelogUseCase.invoke() } returns newVersionChangelogNotifications + every { versionNotificationUseCase.invoke() } returns versionNotifications every { versionNotificationUseCase.invoke() } returns versionNotifications every { tunnelStateNotificationUseCase.invoke() } returns tunnelStateNotifications job = Job() @@ -56,6 +62,7 @@ class InAppNotificationControllerTest { InAppNotificationController( accountExpiryInAppNotificationUseCase, newDeviceNotificationUseCase, + newVersionChangelogUseCase, versionNotificationUseCase, tunnelStateNotificationUseCase, CoroutineScope(job + UnconfinedTestDispatcher()), @@ -73,6 +80,9 @@ class InAppNotificationControllerTest { val newDevice = InAppNotification.NewDevice("") newDeviceNotifications.value = listOf(newDevice) + val newVersionChangelog = InAppNotification.NewVersionChangelog + newVersionChangelogNotifications.value = listOf(newVersionChangelog) + val errorState: ErrorState = mockk() val tunnelStateBlocked = InAppNotification.TunnelStateBlocked val tunnelStateError = InAppNotification.TunnelStateError(errorState) @@ -94,6 +104,7 @@ class InAppNotificationControllerTest { unsupportedVersion, accountExpiry, newDevice, + newVersionChangelog, ), notifications, ) 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 0d61b3e300f8..15070ea695d9 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 @@ -30,6 +30,7 @@ import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager @@ -65,6 +66,9 @@ class ConnectViewModelTest { // Device Repository private val mockDeviceRepository: DeviceRepository = mockk() + // Changelog Repository + private val mockChangelogRepository: ChangelogRepository = mockk() + // In App Notifications private val mockInAppNotificationController: InAppNotificationController = mockk() @@ -113,6 +117,7 @@ class ConnectViewModelTest { ConnectViewModel( accountRepository = mockAccountRepository, deviceRepository = mockDeviceRepository, + changelogRepository = mockChangelogRepository, inAppNotificationController = mockInAppNotificationController, newDeviceRepository = mockk(), outOfTimeUseCase = outOfTimeUseCase, diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index bc3512501c98..7156a3b57f9a 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -175,6 +175,8 @@ %s. For more details see the info button in Account.]]> + NEW VERSION INSTALLED + Click here to see what\'s new. 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. @@ -387,7 +389,7 @@ Copy Share… App info - Changelog + What\'s new Version Attention: If \"Block connections without VPN\" is enabled, \"Local network sharing\" will not work. With the “Encrypted DNS proxy” method, the app will communicate with our Mullvad API through a proxy address. It does this by retrieving an address from a DNS over HTTPS (DoH) server and then using that to reach our API servers.