diff --git a/android/BuildInstructions.md b/android/BuildInstructions.md index e89d1b310244..cfff1b3d73af 100644 --- a/android/BuildInstructions.md +++ b/android/BuildInstructions.md @@ -219,13 +219,6 @@ rm ./gradle/verification-metadata.xml ## Gradle properties Some gradle properties can be set to simplify development. These are listed below. -### Always show changelog -For development purposes, `ALWAYS_SHOW_CHANGELOG` can be set in `local.properties` to always show -the changelog dialog on each app start. For example: -``` -ALWAYS_SHOW_CHANGELOG=true -``` - ### Override version code and version name To avoid or override the rust based version generation, the `OVERRIDE_VERSION_CODE` and `OVERRIDE_VERSION_NAME` properties can be set in `local.properties`. For example: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dd1444302cb4..024a5fba7f96 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -184,12 +184,6 @@ android { } applicationVariants.configureEach { - val alwaysShowChangelog = - gradleLocalProperties(rootProject.projectDir, providers) - .getProperty("ALWAYS_SHOW_CHANGELOG") ?: "false" - - buildConfigField("boolean", "ALWAYS_SHOW_CHANGELOG", alwaysShowChangelog) - val enableInAppVersionNotifications = gradleLocalProperties(rootProject.projectDir, providers) .getProperty("ENABLE_IN_APP_VERSION_NOTIFICATIONS") ?: "true" 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 69% 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..968f16eb2916 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,38 @@ 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, + onSeeFullChangelog: () -> Unit = {}, + onBackClick: () -> Unit = {}, + ) { + setContentWithTheme { + ChangelogScreen( + state = state, + onSeeFullChangelog = onSeeFullChangelog, + 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 59357d052742..0cdc8b8fe7f9 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/dialog/ChangelogDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt deleted file mode 100644 index e5afe6d9bc96..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt +++ /dev/null @@ -1,152 +0,0 @@ -package net.mullvad.mullvadvpn.compose.dialog - -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.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 -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -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.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) -@Composable -fun Changelog(navController: NavController) { - val viewModel = koinViewModel() - val uiState = viewModel.uiState.collectAsStateWithLifecycle() - - ChangelogDialog(uiState.value, onDismiss = { navController.navigateUp() }) -} - -@Composable -fun ChangelogDialog(state: ChangelogUiState, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = state.version, - style = MaterialTheme.typography.headlineLarge, - 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, - ) -} - -@Composable -private fun ChangeListItem(text: String) { - Column { - Row { - Text( - text = "•", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.width(Dimens.buttonSpacing), - textAlign = TextAlign.Center, - ) - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -@Preview -@Composable -private fun PreviewChangelogDialogWithSingleShortItem() { - AppTheme { - ChangelogDialog( - ChangelogUiState(changes = listOf("Item 1"), version = "1111.1"), - onDismiss = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewChangelogDialogWithTwoLongItems() { - val longPreviewText = - "This is a sample changelog item of a Compose Preview visualization. " + - "The purpose of this specific sample text is to visualize a long text that will result " + - "in multiple lines in the changelog dialog." - - AppTheme { - ChangelogDialog( - ChangelogUiState( - changes = listOf(longPreviewText, longPreviewText), - version = "1111.1", - ), - onDismiss = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewChangelogDialogWithTenShortItems() { - AppTheme { - ChangelogDialog( - ChangelogUiState( - changes = - listOf( - "Item 1", - "Item 2", - "Item 3", - "Item 4", - "Item 5", - "Item 6", - "Item 7", - "Item 8", - "Item 9", - "Item 10", - ), - version = "1111.1", - ), - onDismiss = {}, - ) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt index a642dc72fe67..3e0ae8f8981f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt @@ -17,6 +17,12 @@ fun UriHandler.createOpenAccountPageHook(): (WebsiteAuthToken?) -> Unit { } } +@Composable +fun UriHandler.createOpenFullChangeLogHook(): () -> Unit { + val changelogUrl = stringResource(id = R.string.changelog_url) + return { safeOpenUri(changelogUrl) } +} + fun UriHandler.createUriHook(uri: String): () -> Unit = { safeOpenUri(uri) } private fun UriHandler.safeOpenUri(uri: String) { 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..42d23a1d0399 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt @@ -0,0 +1,19 @@ +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), + isPlayBuild = true, + ), + AppInfoUiState( + version = VersionInfo(currentVersion = "2024.9", isSupported = false), + 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..3c5758763736 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 @@ -17,9 +16,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment 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 +31,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 @@ -57,7 +70,8 @@ fun AppInfo(navigator: DestinationsNavigator) { AppInfo( state = state, onBackClick = dropUnlessResumed { navigator.navigateUp() }, - navigateToChangelog = dropUnlessResumed { navigator.navigate(ChangelogDestination) }, + navigateToChangelog = + dropUnlessResumed { navigator.navigate(ChangelogDestination(ChangelogNavArgs())) }, openAppListing = dropUnlessResumed { vm.openAppListing() }, ) } @@ -87,9 +101,9 @@ fun AppInfoContent( openAppListing: () -> Unit, ) { Column(modifier = Modifier.padding(bottom = Dimens.smallPadding).animateContentSize()) { - AppVersionRow(state, openAppListing) - ChangelogRow(navigateToChangelog) + HorizontalDivider() + 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/screen/ChangelogScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreen.kt new file mode 100644 index 000000000000..53bf2113a6d1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreen.kt @@ -0,0 +1,211 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +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.NavigateCloseIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.extensions.createOpenFullChangeLogHook +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.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.viewmodel.ChangeLogSideEffect +import net.mullvad.mullvadvpn.viewmodel.ChangelogUiState +import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import org.koin.androidx.compose.koinViewModel + +@Destination( + style = SlideInFromRightTransition::class, + navArgs = ChangelogNavArgs::class, +) +@Composable +fun Changelog(navController: NavController) { + val viewModel = koinViewModel() + + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + val openAccountPage = LocalUriHandler.current.createOpenFullChangeLogHook() + CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { + when (it) { + is ChangeLogSideEffect.OpenFullChangelog -> openAccountPage() + } + } + LaunchedEffect(Unit) { viewModel.dismissChangelogNotification() } + + ChangelogScreen( + uiState.value, + onBackClick = navController::navigateUp, + onSeeFullChangelog = viewModel::onSeeFullChangelog, + ) +} + +data class ChangelogNavArgs(val isModal: Boolean = false) + +@Composable +fun ChangelogScreen( + state: ChangelogUiState, + onBackClick: () -> Unit, + onSeeFullChangelog: () -> Unit, +) { + + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.changelog_title), + navigationIcon = { + if (state.isModal) { + NavigateCloseIconButton(onBackClick) + } else { + NavigateBackIconButton(onNavigateBack = onBackClick) + } + }, + ) { modifier -> + Column(modifier = modifier.padding(horizontal = Dimens.mediumPadding)) { + val scrollState = rememberScrollState() + Column( + Modifier.weight(1f) + .fillMaxWidth() + .drawVerticalScrollbar( + scrollState, + MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), + ) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(Dimens.mediumPadding), + ) { + Text( + text = state.version, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + if (state.changes.isEmpty()) { + Text( + text = stringResource(R.string.changelog_empty), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + state.changes.forEach { changeItem -> ChangeListItem(text = changeItem) } + } + } + Box(modifier = Modifier.padding(Dimens.mediumPadding).fillMaxWidth()) { + PrimaryButton( + onClick = onSeeFullChangelog, + text = stringResource(R.string.see_full_changelog), + trailingIcon = { + Icon( + imageVector = Icons.AutoMirrored.Default.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + ) + } + } + } +} + +@Composable +private fun ChangeListItem(text: String) { + Column { + Row { + Text( + text = "•", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(Dimens.buttonSpacing), + textAlign = TextAlign.Center, + ) + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview +@Composable +private fun PreviewChangelogDialogWithSingleShortItem() { + AppTheme { + ChangelogScreen( + ChangelogUiState(changes = listOf("Item 1"), version = "1111.1"), + onBackClick = {}, + onSeeFullChangelog = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewChangelogDialogWithTwoLongItems() { + val longPreviewText = + "This is a sample changelog item of a Compose Preview visualization. " + + "The purpose of this specific sample text is to visualize a long text that will result " + + "in multiple lines in the changelog dialog." + + AppTheme { + ChangelogScreen( + ChangelogUiState( + changes = listOf(longPreviewText, longPreviewText), + version = "1111.1", + ), + onBackClick = {}, + onSeeFullChangelog = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewChangelogDialogWithTenShortItems() { + AppTheme { + ChangelogScreen( + ChangelogUiState( + changes = + listOf( + "Item 1", + "Item 2", + "Item 3", + "Item 4", + "Item 5", + "Item 6", + "Item 7", + "Item 8", + "Item 9", + "Item 10", + ), + version = "1111.1", + ), + onBackClick = {}, + onSeeFullChangelog = {}, + ) + } +} 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 0ce574d8c1a4..7c4bdbd3b3d3 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 @@ -57,6 +57,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 @@ -142,10 +143,13 @@ private fun PreviewAccountScreen( {}, {}, {}, + {}, + {}, ) } } +@Suppress("LongMethod") @Destination(style = HomeTransition::class) @Composable fun Connect( @@ -237,6 +241,9 @@ fun Connect( onSwitchLocationClick = dropUnlessResumed { navigator.navigate(SelectLocationDestination) }, onOpenAppListing = connectViewModel::openAppListing, onManageAccountClick = connectViewModel::onManageAccountClick, + onChangelogClick = + dropUnlessResumed { navigator.navigate(ChangelogDestination(ChangelogNavArgs(true))) }, + onDismissChangelogClick = connectViewModel::dismissNewChangelogNotification, onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) }, onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) }, onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, @@ -254,6 +261,8 @@ fun ConnectScreen( onSwitchLocationClick: () -> Unit, onOpenAppListing: () -> Unit, onManageAccountClick: () -> Unit, + onChangelogClick: () -> Unit, + onDismissChangelogClick: () -> Unit, onSettingsClick: () -> Unit, onAccountClick: () -> Unit, onDismissNewDeviceClick: () -> Unit, @@ -309,6 +318,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 bc236cc7928b..a56541ee1a24 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,8 +128,8 @@ val uiModule = module { single { androidContext().assets } single { androidContext().contentResolver } - single { ChangelogRepository(get()) } - single { UserPreferencesRepository(get()) } + single { ChangelogRepository(get(), get(), get()) } + single { UserPreferencesRepository(get(), get()) } single { SettingsRepository(get()) } single { MullvadProblemReport(get(), get().apiEndpointOverride, get()) } single { RelayOverridesRepository(get()) } @@ -152,6 +153,7 @@ val uiModule = module { single { TunnelStateNotificationUseCase(get()) } single { VersionNotificationUseCase(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS) } single { NewDeviceNotificationUseCase(get(), get()) } + single { NewChangelogNotificationUseCase(get()) } single { OutOfTimeUseCase(get(), get(), MainScope()) } single { InternetAvailableUseCase(get()) } single { SystemVpnSettingsAvailableUseCase(androidContext()) } @@ -166,7 +168,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()) } @@ -188,7 +190,7 @@ val uiModule = module { // View models viewModel { AccountViewModel(get(), get(), get(), IS_PLAY_BUILD) } - viewModel { ChangelogViewModel(get(), get()) } + viewModel { ChangelogViewModel(get(), get(), get()) } viewModel { AppInfoViewModel(get(), get(), get(), IS_PLAY_BUILD, get(named(SELF_PACKAGE_NAME))) } @@ -204,6 +206,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..171eb116b1fb 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,12 +1,40 @@ package net.mullvad.mullvadvpn.repository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.model.BuildVersion import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.util.trimAll private const val NEWLINE_CHAR = '\n' private const val BULLET_POINT_CHAR = '-' -class ChangelogRepository(private val dataProvider: IChangelogDataProvider) { +class ChangelogRepository( + private val dataProvider: IChangelogDataProvider, + private val userPreferencesRepository: UserPreferencesRepository, + private val buildVersion: BuildVersion, + dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + val hasUnreadChangelog: StateFlow = + userPreferencesRepository.preferencesFlow + .map { + getLastVersionChanges().isNotEmpty() && + buildVersion.code > it.lastShownChangelogVersionCode + } + .stateIn( + CoroutineScope(dispatcher), + started = SharingStarted.Eagerly, + initialValue = false, + ) + + suspend fun setDismissNewChangelogNotification() { + userPreferencesRepository.setHasDisplayedChangelogNotification() + } 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..8a6dfd59a6c2 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,14 @@ class UserPreferencesRepository(private val userPreferences: DataStore + userPreferencesStore.updateData { prefs -> prefs.toBuilder().setIsPrivacyDisclosureAccepted(true).build() } } + + suspend fun setHasDisplayedChangelogNotification() { + userPreferencesStore.updateData { prefs -> + prefs.toBuilder().setLastShownChangelogVersionCode(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..157de67013c6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.repository.ChangelogRepository +import net.mullvad.mullvadvpn.repository.InAppNotification + +class NewChangelogNotificationUseCase(private val changelogRepository: ChangelogRepository) { + operator fun invoke() = + changelogRepository.hasUnreadChangelog + .map { + buildList { + if (it) { + add(InAppNotification.NewVersionChangelog) + } + } + } + .distinctUntilChanged() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt index 662cbdc4a1df..8e5ec24a149e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt @@ -34,16 +34,12 @@ class AppInfoViewModel( flowOf(changelogRepository.getLastVersionChanges()), flowOf(isPlayBuild), ) { versionInfo, changes, isPlayBuild -> - AppInfoUiState(versionInfo, changes, isPlayBuild) + AppInfoUiState(versionInfo, isPlayBuild) } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - AppInfoUiState( - appVersionInfoRepository.versionInfo.value, - changelogRepository.getLastVersionChanges(), - true, - ), + AppInfoUiState(appVersionInfoRepository.versionInfo.value, true), ) fun openAppListing() = @@ -58,11 +54,7 @@ class AppInfoViewModel( } } -data class AppInfoUiState( - val version: VersionInfo, - val changes: List, - val isPlayBuild: Boolean, -) +data class AppInfoUiState(val version: VersionInfo, val isPlayBuild: Boolean) sealed interface AppInfoSideEffect { data class OpenUri(val uri: Uri) : AppInfoSideEffect 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..0feb6ecbd364 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 @@ -1,19 +1,51 @@ package net.mullvad.mullvadvpn.viewmodel import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.destinations.ChangelogDestination +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch 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, + savedStateHandle: SavedStateHandle, + buildVersion: BuildVersion, +) : ViewModel() { + private val navArgs = ChangelogDestination.argsFrom(savedStateHandle) + private val _uiSideEffect = Channel() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + val uiState: StateFlow = MutableStateFlow( - ChangelogUiState(buildVersion.name, changelogRepository.getLastVersionChanges()) + ChangelogUiState( + navArgs.isModal, + buildVersion.name, + changelogRepository.getLastVersionChanges(), + ) ) + + fun dismissChangelogNotification() = + viewModelScope.launch { changelogRepository.setDismissNewChangelogNotification() } + + fun onSeeFullChangelog() = + viewModelScope.launch { _uiSideEffect.send(ChangeLogSideEffect.OpenFullChangelog) } +} + +sealed interface ChangeLogSideEffect { + object OpenFullChangelog : ChangeLogSideEffect } -@Parcelize data class ChangelogUiState(val version: String, val changes: List) : Parcelable +@Parcelize +data class ChangelogUiState( + val isModal: Boolean = false, + 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 5fb08bcc4855..0ddbd7d72479 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 @@ -26,6 +26,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.withPrev class ConnectViewModel( private val accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, + private val changelogRepository: ChangelogRepository, inAppNotificationController: InAppNotificationController, private val newDeviceRepository: NewDeviceRepository, selectedLocationTitleUseCase: SelectedLocationTitleUseCase, @@ -192,6 +194,9 @@ class ConnectViewModel( newDeviceRepository.clearNewDeviceCreatedNotification() } + fun dismissNewChangelogNotification() = + viewModelScope.launch { 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..6f9661970f58 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 last_shown_changelog_version_code = 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/repository/ChangelogRepositoryTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepositoryTest.kt index 1524549e5741..4d608b7231ec 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepositoryTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepositoryTest.kt @@ -2,15 +2,24 @@ package net.mullvad.mullvadvpn.repository import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.util.IChangelogDataProvider import org.junit.jupiter.api.Test +@ExperimentalCoroutinesApi class ChangelogRepositoryTest { private val mockDataProvider: IChangelogDataProvider = mockk() - private val changelogRepository = ChangelogRepository(dataProvider = mockDataProvider) + private val changelogRepository = + ChangelogRepository( + mockDataProvider, + mockk(relaxed = true), + mockk(), + UnconfinedTestDispatcher(), + ) @Test fun `when given a changelog text should return a list of correctly formatted strings`() { 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 1dab9a456535..1206af7152a3 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-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml index 13aaf95f087b..33adf30dc7d8 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -60,8 +60,6 @@ Blokering af internet (enhed offline) Køb kredit Annuller - Ændringslog - Ændringer i denne version: Chiffer Ryd input Luk 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 24001a5e1ee3..0de987fd6762 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -60,8 +60,6 @@ Internet sperren (Gerät offline) Guthaben erwerben Abbrechen - Changelog - Änderungen in dieser Version: Chiffre Eingabe löschen Schließen 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 c181585b8e65..591c4669bbfb 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -60,8 +60,6 @@ Bloqueo de Internet (dispositivo sin conexión) Comprar créditos Cancelar - Registro de cambios - Cambios en esta versión: Cifrado Borrar entrada Cerrar 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 e61ba4dbb4db..80507a061330 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -60,8 +60,6 @@ Internetyhteys on estetty (laite on offline-tilassa) Osta käyttöaikaa Peruuta - Muutosloki - Muutokset tässä versiossa: Salaus Tyhjennä syöte Sulje 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 57c276a287f2..cd36838c4a21 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -60,8 +60,6 @@ Internet bloqué (appareil hors ligne) Acheter des crédits Annuler - Journal des modifications - Modifications dans cette version : Chiffre Effacer la saisie Fermer 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 85d399b3f72b..ab13bbfdbbfd 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -60,8 +60,6 @@ Blocco di Internet (dispositivo offline) Acquista credito Annulla - Changelog - Modifiche in questa versione: Codice Cancella inserimento Chiudi 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 e3922b12e982..968540052881 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -60,8 +60,6 @@ インターネットをブロック中 (デバイスがオフライン) クレジットを購入 キャンセル - 変更履歴 - このバージョンでの変更内容: 暗号化 入力をクリア 閉じる 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 09f899d08365..04bad2060278 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -60,8 +60,6 @@ 인터넷 차단(장치 오프라인) 크레딧 구매 취소 - 변경 로그 - 이 버전의 변경 사항: 암호 입력 지우기 닫기 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 789af576cfaf..544f44cb20a5 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -60,8 +60,6 @@ အင်တာနက် ပိတ်ဆို့နေဆဲ (စက် အော့ဖ်လိုင်း) ခရက်ဒစ် ဝယ်ရန် မလုပ်တော့ပါ - ပြောင်းလဲမှုမှတ်တမ်း - ဤဗားရှင်းတွင် ပြောင်းလဲမှုများ- ဝှက်စာ ထည့်သွင်းမှုကို ရှင်းရန် ပိတ်ရန် 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 e842f655a61a..4436bd166a13 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -60,8 +60,6 @@ Blokkerer internett (enhet frakoblet) Kjøp kreditt Avbryt - Changelog - Endringer i denne versjonen: Chiffer Fjern inndata Lukk 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 a7a5732edacf..5d0d05f1df86 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -60,8 +60,6 @@ Internet wordt geblokkeerd (apparaat offline) Krediet kopen Annuleren - Changelog - Wijzigingen in deze versie: Versleuteling Invoer wissen Sluiten 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 80fa36e5bd13..19d95b1c9aa0 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -60,8 +60,6 @@ Blokowanie Internetu (urządzenie rozłączone) Kup doładowanie Anuluj - Dziennik zmian - Zmiany w tej wersji: Szyfrowanie Wyczyść dane wejściowe Zamknij 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 d1c940560cac..00dcd663d45d 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -60,8 +60,6 @@ Bloqueio de Internet (dispositivo offline) Comprar crédito Cancelar - Registo de alterações - Alterações nesta versão: Cifra Limpar entrada Fechar 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 b93e21062d8b..0df1d79a3f85 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -60,8 +60,6 @@ Блокируется доступ в Интернет (устройство офлайн) Пополнить баланс Отмена - История изменений - Изменения в этой версии: Шифр Очистить поле ввода Закрыть 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 215df1e8e1ec..3890a8f2aa53 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -60,8 +60,6 @@ Blockerar internet (enheten är offline) Köp kredit Avbryt - Ändringslogg - Ändringar i den här versionen: Chiffrering Rensa inmatning Stäng 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 7aafd7be05ce..ad5e98ede7b6 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -60,8 +60,6 @@ กำลังบล็อกอินเทอร์เน็ต (อุปกรณ์ออฟไลน์) ซื้อเครดิต ยกเลิก - บันทึกการเปลี่ยนแปลง - การเปลี่ยนแปลงในเวอร์ชันนี้: เข้ารหัส ล้างข้อมูลอินพุต ปิด 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 b076839b103d..38b157f0b17e 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -60,8 +60,6 @@ İnternet bağlantısı engelleniyor (cihaz çevrimdışı) Kredi satın alın İptal et - Değişiklik günlüğü - Bu sürümdeki değişiklikler: Şifre Girişi temizle Kapat 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 5d253f81bae5..744c1cac3303 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 @@ -60,8 +60,6 @@ 正在阻止网络(设备离线) 购买额度 取消 - 变更日志 - 此版本中的变更: 加密方式 清除输入 关闭 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 15b40db2edde..68ccea24054f 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 @@ -60,8 +60,6 @@ 封鎖網際網路 (裝置離線) 購買點數 取消 - 變更日誌 - 此版本中的變更: 加密方式 清除輸入 關閉 diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 49b5481a7ab3..135264863013 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -161,7 +161,6 @@ Copy account number Hide account number Failed to remove device - Changes in this version: Always-on VPN assigned to %s %s before using Mullvad VPN.]]> @@ -175,6 +174,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 +388,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. @@ -417,4 +418,6 @@ Open %1$s settings Search Attention: Shadowsocks can increase battery consumption depending on data usage, such as streaming a video. + See full changelog + No changelog was added for this version diff --git a/android/lib/resource/src/main/res/values/strings_non_translatable.xml b/android/lib/resource/src/main/res/values/strings_non_translatable.xml index 837740aa4711..f04cabd78e27 100644 --- a/android/lib/resource/src/main/res/values/strings_non_translatable.xml +++ b/android/lib/resource/src/main/res/values/strings_non_translatable.xml @@ -6,6 +6,7 @@ market://details?id=%s https://mullvad.net/help/tag/mullvad-app/ https://mullvad.net/help/privacy-policy/ + https://github.com/mullvad/mullvadvpn-app/blob/main/android/CHANGELOG.md https://mullvad.net/l/android-lockdown Split tunneling WireGuard diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index 2a50198bf099..0801445c5107 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -621,7 +621,7 @@ msgid "No updates or changes were made in this release for this platform." msgstr "" msgctxt "changelog-view" -msgid "What's new" +msgid "What’s new" msgstr "" #. The selected location label displayed on the main view, when a user selected a specific host to connect to. @@ -911,7 +911,7 @@ msgid "BLOCKING INTERNET" msgstr "" msgctxt "in-app-notifications" -msgid "Click here to see what's new." +msgid "Click here to see what’s new." msgstr "" #. The in-app banner displayed to the user when the app update is available. @@ -1574,7 +1574,7 @@ msgid "VPN settings" msgstr "" msgctxt "settings-view" -msgid "What's new" +msgid "What’s new" msgstr "" msgctxt "split-tunneling-view" @@ -2364,12 +2364,6 @@ msgstr "" msgid "Blocking..." msgstr "" -msgid "Changelog" -msgstr "" - -msgid "Changes in this version:" -msgstr "" - msgid "Changes to DNS related settings might not go into effect immediately due to cached results." msgstr "" @@ -2553,6 +2547,9 @@ msgstr "" msgid "New list" msgstr "" +msgid "No changelog was added for this version" +msgstr "" + msgid "No custom lists available" msgstr "" diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/ChangelogListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/ChangelogListItem.tsx index c05b0286b6b1..9c861008d10a 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/ChangelogListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/ChangelogListItem.tsx @@ -11,7 +11,7 @@ export function ChangelogListItem() { return ( - {messages.pgettext('settings-view', "What's new")} + {messages.pgettext('settings-view', 'What’s new')} ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/ChangelogView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/ChangelogView.tsx index 6bfe45b7a9b6..4a93908e6524 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/ChangelogView.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/ChangelogView.tsx @@ -51,13 +51,13 @@ export const ChangelogView = () => { - {messages.pgettext('changelog-view', "What's new")} + {messages.pgettext('changelog-view', 'What’s new')} - {messages.pgettext('changelog-view', "What's new")} + {messages.pgettext('changelog-view', 'What’s new')} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-version.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-version.ts index 73ccf615258e..49be1a271848 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-version.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-version.ts @@ -22,7 +22,7 @@ export class NewVersionNotificationProvider implements InAppNotificationProvider public getInAppNotification(): InAppNotification { const title = messages.pgettext('in-app-notifications', 'NEW VERSION INSTALLED'); - const subtitle = messages.pgettext('in-app-notifications', "Click here to see what's new."); + const subtitle = messages.pgettext('in-app-notifications', 'Click here to see what’s new.'); return { indicator: 'success', action: { type: 'close', close: this.context.close },