summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2025-01-09 10:55:29 +0100
committerDavid Göransson <david.goransson@mullvad.net>2025-01-09 10:55:29 +0100
commitaf606551507a95671264979c8a5ca537b33e8a3c (patch)
tree4cf45c753e523389bca114f24fbefe576cc53427 /android
parentdce74bb3a8c983cfc55f46f989e93f762b772e36 (diff)
parent412040218f3d965aca121a47bd5ca954dd9916e5 (diff)
downloadmullvadvpn-af606551507a95671264979c8a5ca537b33e8a3c.tar.xz
mullvadvpn-af606551507a95671264979c8a5ca537b33e8a3c.zip
Merge branch 'update-changelog-presentation-droid-1634'
Diffstat (limited to 'android')
-rw-r--r--android/BuildInstructions.md7
-rw-r--r--android/app/build.gradle.kts6
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreenTest.kt (renamed from android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialogTest.kt)30
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt34
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt35
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt72
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt152
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt33
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreen.kt211
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt40
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt5
-rw-r--r--android/app/src/main/proto/user_prefs.proto5
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt11
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepositoryTest.kt11
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt5
-rw-r--r--android/lib/resource/src/main/res/values-da/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-de/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-es/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-fi/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-fr/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-it/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-ja/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-ko/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-my/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-nb/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-nl/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-pl/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-pt/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-ru/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-sv/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-th/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-tr/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-zh-rCN/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values-zh-rTW/strings.xml2
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml7
-rw-r--r--android/lib/resource/src/main/res/values/strings_non_translatable.xml1
46 files changed, 574 insertions, 268 deletions
diff --git a/android/BuildInstructions.md b/android/BuildInstructions.md
index e89d1b3102..cfff1b3d73 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 dd1444302c..024a5fba7f 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
index b811209d1c..968f16eb29 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 59357d0527..0cdc8b8fe7 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,
)
}
}
@@ -632,6 +638,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 {
// Arrange
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 bc1aaeb641..90bdbca1be 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 e6f9d3ea69..58798978bc 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 e5afe6d9bc..0000000000
--- 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<RootGraph>(style = DestinationStyle.Dialog::class)
-@Composable
-fun Changelog(navController: NavController) {
- val viewModel = koinViewModel<ChangelogViewModel>()
- 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 a642dc72fe..3e0ae8f898 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 0000000000..42d23a1d03
--- /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<AppInfoUiState> {
+ override val values: Sequence<AppInfoUiState> =
+ 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 e65dc2c8d8..3c57587637 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,8 +31,10 @@ 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
@@ -40,6 +42,17 @@ 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<RootGraph>(style = SlideInFromRightTransition::class)
@Composable
fun AppInfo(navigator: DestinationsNavigator) {
@@ -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 0000000000..53bf2113a6
--- /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<RootGraph>(
+ style = SlideInFromRightTransition::class,
+ navArgs = ChangelogNavArgs::class,
+)
+@Composable
+fun Changelog(navController: NavController) {
+ val viewModel = koinViewModel<ChangelogViewModel>()
+
+ 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 0ce574d8c1..7c4bdbd3b3 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<RootGraph>(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 eef89c5ea2..bdc85b1d6f 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 bc236cc792..a56541ee1a 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<DaemonConfig>().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<IChangelogDataProvider> { 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 5267f52271..171eb116b1 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<Boolean> =
+ 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<String> =
// 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 1608e3689e..0fcee60bed 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 f3e6a72b64..8a6dfd59a6 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<UserPreferences>) {
+class UserPreferencesRepository(
+ private val userPreferencesStore: DataStore<UserPreferences>,
+ 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> =
- 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<UserPrefe
suspend fun preferences(): UserPreferences = preferencesFlow.first()
suspend fun setPrivacyDisclosureAccepted() {
- userPreferences.updateData { prefs ->
+ 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 0000000000..157de67013
--- /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 662cbdc4a1..8e5ec24a14 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<String>,
- 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 571d5da3e3..0feb6ecbd3 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<ChangeLogSideEffect>()
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
val uiState: StateFlow<ChangelogUiState> =
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<String>) : Parcelable
+@Parcelize
+data class ChangelogUiState(
+ val isModal: Boolean = false,
+ val version: String,
+ val changes: List<String>,
+) : 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 5fb08bcc48..0ddbd7d724 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 3a7e79285f..6f9661970f 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 74b599da97..c8b27f2e6f 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<InAppNotification>())
private val newDeviceNotifications = MutableStateFlow(emptyList<InAppNotification.NewDevice>())
+ private val newVersionChangelogNotifications =
+ MutableStateFlow(emptyList<InAppNotification.NewVersionChangelog>())
private val versionNotifications = MutableStateFlow(emptyList<InAppNotification>())
private val tunnelStateNotifications = MutableStateFlow(emptyList<InAppNotification>())
@@ -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 1524549e57..4d608b7231 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 1dab9a4565..1206af7152 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 13aaf95f08..33adf30dc7 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 @@
<string name="blocking_internet_device_offline">Blokering af internet (enhed offline)</string>
<string name="buy_credit">Køb kredit</string>
<string name="cancel">Annuller</string>
- <string name="changelog_title">Ændringslog</string>
- <string name="changes_dialog_subtitle">Ændringer i denne version:</string>
<string name="cipher">Chiffer</string>
<string name="clear_input">Ryd input</string>
<string name="close">Luk</string>
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 24001a5e1e..0de987fd67 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 @@
<string name="blocking_internet_device_offline">Internet sperren (Gerät offline)</string>
<string name="buy_credit">Guthaben erwerben</string>
<string name="cancel">Abbrechen</string>
- <string name="changelog_title">Changelog</string>
- <string name="changes_dialog_subtitle">Änderungen in dieser Version:</string>
<string name="cipher">Chiffre</string>
<string name="clear_input">Eingabe löschen</string>
<string name="close">Schließen</string>
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 c181585b8e..591c4669bb 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 @@
<string name="blocking_internet_device_offline">Bloqueo de Internet (dispositivo sin conexión)</string>
<string name="buy_credit">Comprar créditos</string>
<string name="cancel">Cancelar</string>
- <string name="changelog_title">Registro de cambios</string>
- <string name="changes_dialog_subtitle">Cambios en esta versión:</string>
<string name="cipher">Cifrado</string>
<string name="clear_input">Borrar entrada</string>
<string name="close">Cerrar</string>
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 e61ba4dbb4..80507a0613 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 @@
<string name="blocking_internet_device_offline">Internetyhteys on estetty (laite on offline-tilassa)</string>
<string name="buy_credit">Osta käyttöaikaa</string>
<string name="cancel">Peruuta</string>
- <string name="changelog_title">Muutosloki</string>
- <string name="changes_dialog_subtitle">Muutokset tässä versiossa:</string>
<string name="cipher">Salaus</string>
<string name="clear_input">Tyhjennä syöte</string>
<string name="close">Sulje</string>
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 57c276a287..cd36838c4a 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 @@
<string name="blocking_internet_device_offline">Internet bloqué (appareil hors ligne)</string>
<string name="buy_credit">Acheter des crédits</string>
<string name="cancel">Annuler</string>
- <string name="changelog_title">Journal des modifications</string>
- <string name="changes_dialog_subtitle">Modifications dans cette version :</string>
<string name="cipher">Chiffre</string>
<string name="clear_input">Effacer la saisie</string>
<string name="close">Fermer</string>
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 85d399b3f7..ab13bbfdbb 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 @@
<string name="blocking_internet_device_offline">Blocco di Internet (dispositivo offline)</string>
<string name="buy_credit">Acquista credito</string>
<string name="cancel">Annulla</string>
- <string name="changelog_title">Changelog</string>
- <string name="changes_dialog_subtitle">Modifiche in questa versione:</string>
<string name="cipher">Codice</string>
<string name="clear_input">Cancella inserimento</string>
<string name="close">Chiudi</string>
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 e3922b12e9..9685400528 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 @@
<string name="blocking_internet_device_offline">インターネットをブロック中 (デバイスがオフライン)</string>
<string name="buy_credit">クレジットを購入</string>
<string name="cancel">キャンセル</string>
- <string name="changelog_title">変更履歴</string>
- <string name="changes_dialog_subtitle">このバージョンでの変更内容:</string>
<string name="cipher">暗号化</string>
<string name="clear_input">入力をクリア</string>
<string name="close">閉じる</string>
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 09f899d083..04bad20602 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 @@
<string name="blocking_internet_device_offline">인터넷 차단(장치 오프라인)</string>
<string name="buy_credit">크레딧 구매</string>
<string name="cancel">취소</string>
- <string name="changelog_title">변경 로그</string>
- <string name="changes_dialog_subtitle">이 버전의 변경 사항:</string>
<string name="cipher">암호</string>
<string name="clear_input">입력 지우기</string>
<string name="close">닫기</string>
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 789af576cf..544f44cb20 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 @@
<string name="blocking_internet_device_offline">အင်တာနက် ပိတ်ဆို့နေဆဲ (စက် အော့ဖ်လိုင်း)</string>
<string name="buy_credit">ခရက်ဒစ် ဝယ်ရန်</string>
<string name="cancel">မလုပ်တော့ပါ</string>
- <string name="changelog_title">ပြောင်းလဲမှုမှတ်တမ်း</string>
- <string name="changes_dialog_subtitle">ဤဗားရှင်းတွင် ပြောင်းလဲမှုများ-</string>
<string name="cipher">ဝှက်စာ</string>
<string name="clear_input">ထည့်သွင်းမှုကို ရှင်းရန်</string>
<string name="close">ပိတ်ရန်</string>
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 e842f655a6..4436bd166a 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 @@
<string name="blocking_internet_device_offline">Blokkerer internett (enhet frakoblet)</string>
<string name="buy_credit">Kjøp kreditt</string>
<string name="cancel">Avbryt</string>
- <string name="changelog_title">Changelog</string>
- <string name="changes_dialog_subtitle">Endringer i denne versjonen:</string>
<string name="cipher">Chiffer</string>
<string name="clear_input">Fjern inndata</string>
<string name="close">Lukk</string>
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 a7a5732eda..5d0d05f1df 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 @@
<string name="blocking_internet_device_offline">Internet wordt geblokkeerd (apparaat offline)</string>
<string name="buy_credit">Krediet kopen</string>
<string name="cancel">Annuleren</string>
- <string name="changelog_title">Changelog</string>
- <string name="changes_dialog_subtitle">Wijzigingen in deze versie:</string>
<string name="cipher">Versleuteling</string>
<string name="clear_input">Invoer wissen</string>
<string name="close">Sluiten</string>
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 80fa36e5bd..19d95b1c9a 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 @@
<string name="blocking_internet_device_offline">Blokowanie Internetu (urządzenie rozłączone)</string>
<string name="buy_credit">Kup doładowanie</string>
<string name="cancel">Anuluj</string>
- <string name="changelog_title">Dziennik zmian</string>
- <string name="changes_dialog_subtitle">Zmiany w tej wersji:</string>
<string name="cipher">Szyfrowanie</string>
<string name="clear_input">Wyczyść dane wejściowe</string>
<string name="close">Zamknij</string>
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 d1c940560c..00dcd663d4 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 @@
<string name="blocking_internet_device_offline">Bloqueio de Internet (dispositivo offline)</string>
<string name="buy_credit">Comprar crédito</string>
<string name="cancel">Cancelar</string>
- <string name="changelog_title">Registo de alterações</string>
- <string name="changes_dialog_subtitle">Alterações nesta versão:</string>
<string name="cipher">Cifra</string>
<string name="clear_input">Limpar entrada</string>
<string name="close">Fechar</string>
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 b93e21062d..0df1d79a3f 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 @@
<string name="blocking_internet_device_offline">Блокируется доступ в Интернет (устройство офлайн)</string>
<string name="buy_credit">Пополнить баланс</string>
<string name="cancel">Отмена</string>
- <string name="changelog_title">История изменений</string>
- <string name="changes_dialog_subtitle">Изменения в этой версии:</string>
<string name="cipher">Шифр</string>
<string name="clear_input">Очистить поле ввода</string>
<string name="close">Закрыть</string>
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 215df1e8e1..3890a8f2aa 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 @@
<string name="blocking_internet_device_offline">Blockerar internet (enheten är offline)</string>
<string name="buy_credit">Köp kredit</string>
<string name="cancel">Avbryt</string>
- <string name="changelog_title">Ändringslogg</string>
- <string name="changes_dialog_subtitle">Ändringar i den här versionen:</string>
<string name="cipher">Chiffrering</string>
<string name="clear_input">Rensa inmatning</string>
<string name="close">Stäng</string>
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 7aafd7be05..ad5e98ede7 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 @@
<string name="blocking_internet_device_offline">กำลังบล็อกอินเทอร์เน็ต (อุปกรณ์ออฟไลน์)</string>
<string name="buy_credit">ซื้อเครดิต</string>
<string name="cancel">ยกเลิก</string>
- <string name="changelog_title">บันทึกการเปลี่ยนแปลง</string>
- <string name="changes_dialog_subtitle">การเปลี่ยนแปลงในเวอร์ชันนี้:</string>
<string name="cipher">เข้ารหัส</string>
<string name="clear_input">ล้างข้อมูลอินพุต</string>
<string name="close">ปิด</string>
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 b076839b10..38b157f0b1 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 @@
<string name="blocking_internet_device_offline">İnternet bağlantısı engelleniyor (cihaz çevrimdışı)</string>
<string name="buy_credit">Kredi satın alın</string>
<string name="cancel">İptal et</string>
- <string name="changelog_title">Değişiklik günlüğü</string>
- <string name="changes_dialog_subtitle">Bu sürümdeki değişiklikler:</string>
<string name="cipher">Şifre</string>
<string name="clear_input">Girişi temizle</string>
<string name="close">Kapat</string>
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 5d253f81ba..744c1cac33 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 @@
<string name="blocking_internet_device_offline">正在阻止网络(设备离线)</string>
<string name="buy_credit">购买额度</string>
<string name="cancel">取消</string>
- <string name="changelog_title">变更日志</string>
- <string name="changes_dialog_subtitle">此版本中的变更:</string>
<string name="cipher">加密方式</string>
<string name="clear_input">清除输入</string>
<string name="close">关闭</string>
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 15b40db2ed..68ccea2405 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 @@
<string name="blocking_internet_device_offline">封鎖網際網路 (裝置離線)</string>
<string name="buy_credit">購買點數</string>
<string name="cancel">取消</string>
- <string name="changelog_title">變更日誌</string>
- <string name="changes_dialog_subtitle">此版本中的變更:</string>
<string name="cipher">加密方式</string>
<string name="clear_input">清除輸入</string>
<string name="close">關閉</string>
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 49b5481a7a..1352648630 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 @@
<string name="copy_account_number">Copy account number</string>
<string name="hide_account_number">Hide account number</string>
<string name="failed_to_remove_device">Failed to remove device</string>
- <string name="changes_dialog_subtitle">Changes in this version:</string>
<string name="always_on_vpn_error_notification_title">Always-on VPN assigned to %s</string>
<string name="always_on_vpn_error_notification_content">
<![CDATA[Unable to start tunnel connection. Please disable Always-on VPN for <b>%s</b> before using Mullvad VPN.]]>
@@ -175,6 +174,8 @@
<string name="new_device_notification_message">
<![CDATA[Welcome, this device is now called <b>%s</b>. For more details see the info button in Account.]]>
</string>
+ <string name="new_changelog_notification_title">NEW VERSION INSTALLED</string>
+ <string name="new_changelog_notification_message">Click here to see what’s new.</string>
<string name="agree_and_continue">Agree and continue</string>
<string name="privacy_disclaimer_title">Privacy</string>
<string name="privacy_disclaimer_body_first_paragraph">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.</string>
@@ -387,7 +388,7 @@
<string name="copy">Copy</string>
<string name="share">Share…</string>
<string name="app_info">App info</string>
- <string name="changelog_title">Changelog</string>
+ <string name="changelog_title">What’s new</string>
<string name="version">Version</string>
<string name="local_network_sharing_info_block_connections_warning">Attention: If \"Block connections without VPN\" is enabled, \"Local network sharing\" will not work.</string>
<string name="encrypted_dns_proxy_info_message_part1">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.</string>
@@ -417,4 +418,6 @@
<string name="open_feature_settings">Open %1$s settings</string>
<string name="search">Search</string>
<string name="obfuscation_info_shadowsocks_batteryusage">Attention: Shadowsocks can increase battery consumption depending on data usage, such as streaming a video.</string>
+ <string name="see_full_changelog">See full changelog</string>
+ <string name="changelog_empty">No changelog was added for this version</string>
</resources>
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 837740aa47..f04cabd78e 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 @@
<string name="market_uri" translatable="false">market://details?id=%s</string>
<string name="faqs_and_guides_url" translatable="false">https://mullvad.net/help/tag/mullvad-app/</string>
<string name="privacy_policy_url" translatable="false">https://mullvad.net/help/privacy-policy/</string>
+ <string name="changelog_url" translatable="false">https://github.com/mullvad/mullvadvpn-app/blob/main/android/CHANGELOG.md</string>
<string name="lockdown_url" translatable="false">https://mullvad.net/l/android-lockdown</string>
<string name="split_tunneling" translatable="false">Split tunneling</string>
<string name="wireguard" translatable="false">WireGuard</string>