summaryrefslogtreecommitdiffhomepage
path: root/android/app
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2025-01-08 09:41:28 +0100
committerDavid Göransson <david.goransson@mullvad.net>2025-01-09 10:54:46 +0100
commit8fb29be52bf65d9b412d97b8a66171bf5e1f7085 (patch)
treec8c1944e1dc4013a20b0ba6b40a1cd181843511c /android/app
parente3ea59f53a33eb1202b67e514f721403b10fe2a5 (diff)
downloadmullvadvpn-8fb29be52bf65d9b412d97b8a66171bf5e1f7085.tar.xz
mullvadvpn-8fb29be52bf65d9b412d97b8a66171bf5e1f7085.zip
Update changelog presentation
Diffstat (limited to 'android/app')
-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
23 files changed, 568 insertions, 215 deletions
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,