diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreenTest.kt similarity index 69% rename from android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialogTest.kt rename to android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreenTest.kt index b811209d1ce3..968f16eb2916 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreenTest.kt @@ -1,8 +1,7 @@ -package net.mullvad.mullvadvpn.compose.dialog +package net.mullvad.mullvadvpn.compose.screen import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import de.mannodermaus.junit5.compose.ComposeContext import io.mockk.MockKAnnotations import io.mockk.impl.annotations.MockK @@ -15,7 +14,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @OptIn(ExperimentalTestApi::class) -class ChangelogDialogTest { +class ChangelogScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() @MockK lateinit var mockedViewModel: AppInfoViewModel @@ -25,29 +24,38 @@ class ChangelogDialogTest { MockKAnnotations.init(this) } - private fun ComposeContext.initDialog(state: ChangelogUiState, onDismiss: () -> Unit = {}) { - setContentWithTheme { ChangelogDialog(state = state, onDismiss = onDismiss) } + private fun ComposeContext.initScreen( + state: ChangelogUiState, + onSeeFullChangelog: () -> Unit = {}, + onBackClick: () -> Unit = {}, + ) { + setContentWithTheme { + ChangelogScreen( + state = state, + onSeeFullChangelog = onSeeFullChangelog, + onBackClick = onBackClick, + ) + } } @Test fun testShowChangeLogWhenNeeded() = composeExtension.use { // Arrange - initDialog( + initScreen( state = ChangelogUiState(changes = listOf(CHANGELOG_ITEM), version = CHANGELOG_VERSION), - onDismiss = {}, + onBackClick = {}, ) + // Check changelog version shown + onNodeWithText(CHANGELOG_VERSION).assertExists() + // Check changelog content showed within dialog onNodeWithText(CHANGELOG_ITEM).assertExists() - - // perform click on Got It button to check if dismiss occur - onNodeWithText(CHANGELOG_BUTTON_TEXT).performClick() } companion object { - private const val CHANGELOG_BUTTON_TEXT = "Got it!" private const val CHANGELOG_ITEM = "Changelog item" private const val CHANGELOG_VERSION = "1234.5" } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index 59357d052742..0cdc8b8fe7f9 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -17,6 +17,7 @@ import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.CONNECT_CARD_HEADER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION +import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_TEXT_ACTION import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.TOP_BAR_ACCOUNT_BUTTON @@ -53,6 +54,7 @@ class ConnectScreenTest { unmockkAll() } + @Suppress("LongParameterList") private fun ComposeContext.initScreen( state: ConnectUiState = ConnectUiState.INITIAL, onDisconnectClick: () -> Unit = {}, @@ -65,6 +67,8 @@ class ConnectScreenTest { onSettingsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, onDismissNewDeviceClick: () -> Unit = {}, + onChangelogClick: () -> Unit = {}, + onDismissChangelogClick: () -> Unit = {}, ) { setContentWithTheme { ConnectScreen( @@ -79,6 +83,8 @@ class ConnectScreenTest { onSettingsClick = onSettingsClick, onAccountClick = onAccountClick, onDismissNewDeviceClick = onDismissNewDeviceClick, + onChangelogClick = onChangelogClick, + onDismissChangelogClick = onDismissChangelogClick, ) } } @@ -631,6 +637,34 @@ class ConnectScreenTest { } } + @Test + fun testOnNewChangelogMessageClick() { + composeExtension.use { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + initScreen( + onChangelogClick = mockedClickHandler, + state = + ConnectUiState( + location = null, + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(null, null, emptyList()), + showLocation = false, + deviceName = "", + daysLeftUntilExpiry = null, + inAppNotification = InAppNotification.NewVersionChangelog, + isPlayBuild = false, + ), + ) + + // Act + onNodeWithTag(NOTIFICATION_BANNER_TEXT_ACTION).performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + } + @Test fun testOpenAccountView() { composeExtension.use { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt index bc1aaeb641d3..90bdbca1bee6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt @@ -5,21 +5,25 @@ import androidx.compose.animation.animateContentSize import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.toUpperCase import androidx.compose.ui.tooling.preview.Preview @@ -29,6 +33,7 @@ import androidx.constraintlayout.compose.Dimension import net.mullvad.mullvadvpn.compose.component.MullvadTopBar import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION +import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_TEXT_ACTION import net.mullvad.mullvadvpn.compose.util.rememberPrevious import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.lib.model.ErrorStateCause @@ -56,8 +61,9 @@ private fun PreviewNotificationBanner() { InAppNotification.TunnelStateError( error = ErrorState(ErrorStateCause.FirewallPolicyError.Generic, true) ), + InAppNotification.NewVersionChangelog, ) - .map { it.toNotificationData(false, {}, {}, {}) } + .map { it.toNotificationData(false, {}, {}, {}, {}, {}) } bannerDataList.forEach { MullvadTopBar( @@ -80,6 +86,8 @@ fun NotificationBanner( isPlayBuild: Boolean, openAppListing: () -> Unit, onClickShowAccount: () -> Unit, + onClickShowChangelog: () -> Unit, + onClickDismissChangelog: () -> Unit, onClickDismissNewDevice: () -> Unit, ) { // Fix for animating to invisible state @@ -97,6 +105,8 @@ fun NotificationBanner( isPlayBuild = isPlayBuild, openAppListing, onClickShowAccount, + onClickShowChangelog, + onClickDismissChangelog, onClickDismissNewDevice, ) ) @@ -153,21 +163,38 @@ private fun Notification(notificationBannerData: NotificationData) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - message?.let { + message?.let { message -> Text( - text = message, + text = message.text, modifier = Modifier.constrainAs(textMessage) { top.linkTo(textTitle.bottom) start.linkTo(textTitle.start) if (action != null) { end.linkTo(actionIcon.start) + bottom.linkTo(actionIcon.bottom) } else { end.linkTo(parent.end) + bottom.linkTo(parent.bottom) } width = Dimension.fillToConstraints + height = Dimension.fillToConstraints } - .padding(start = Dimens.smallPadding, top = Dimens.tinyPadding), + .padding(start = Dimens.smallPadding, top = Dimens.tinyPadding) + .wrapContentWidth(Alignment.Start) + .let { + if (message is NotificationMessage.ClickableText) { + it.clickable( + onClickLabel = message.contentDescription, + role = Role.Button, + ) { + message.onClick() + } + .testTag(NOTIFICATION_BANNER_TEXT_ACTION) + } else { + it + } + }, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt index e6f9d3ea6930..58798978bc7c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt @@ -10,7 +10,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.core.text.HtmlCompat import java.net.InetAddress import net.mullvad.mullvadvpn.R @@ -25,7 +28,7 @@ import net.mullvad.mullvadvpn.ui.notification.StatusLevel data class NotificationData( val title: AnnotatedString, - val message: AnnotatedString? = null, + val message: NotificationMessage? = null, val statusLevel: StatusLevel, val action: NotificationAction? = null, ) { @@ -34,7 +37,31 @@ data class NotificationData( message: String? = null, statusLevel: StatusLevel, action: NotificationAction? = null, - ) : this(AnnotatedString(title), message?.let { AnnotatedString(it) }, statusLevel, action) + ) : this( + AnnotatedString(title), + message?.let { NotificationMessage.Text(AnnotatedString(it)) }, + statusLevel, + action, + ) + + constructor( + title: String, + message: NotificationMessage, + statusLevel: StatusLevel, + action: NotificationAction? = null, + ) : this(AnnotatedString(title), message, statusLevel, action) +} + +sealed interface NotificationMessage { + val text: AnnotatedString + + data class Text(override val text: AnnotatedString) : NotificationMessage + + data class ClickableText( + override val text: AnnotatedString, + val onClick: () -> Unit, + val contentDescription: String, + ) : NotificationMessage } data class NotificationAction( @@ -48,7 +75,9 @@ fun InAppNotification.toNotificationData( isPlayBuild: Boolean, openAppListing: () -> Unit, onClickShowAccount: () -> Unit, - onDismissNewDevice: () -> Unit, + onClickShowChangelog: () -> Unit, + onClickDismissChangelog: () -> Unit, + onClickDismissNewDevice: () -> Unit, ) = when (this) { is InAppNotification.NewDevice -> @@ -56,13 +85,15 @@ fun InAppNotification.toNotificationData( title = AnnotatedString(stringResource(id = R.string.new_device_notification_title)), message = - stringResource(id = R.string.new_device_notification_message, deviceName) - .formatWithHtml(), + NotificationMessage.Text( + stringResource(id = R.string.new_device_notification_message, deviceName) + .formatWithHtml() + ), statusLevel = StatusLevel.Info, action = NotificationAction( Icons.Default.Clear, - onDismissNewDevice, + onClickDismissNewDevice, stringResource(id = R.string.dismiss), ), ) @@ -98,13 +129,40 @@ fun InAppNotification.toNotificationData( stringResource(id = R.string.open_url), ), ) + is InAppNotification.NewVersionChangelog -> + NotificationData( + title = stringResource(id = R.string.new_changelog_notification_title), + message = + NotificationMessage.ClickableText( + text = + buildAnnotatedString { + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + append( + stringResource( + id = R.string.new_changelog_notification_message + ) + ) + } + }, + onClick = onClickShowChangelog, + contentDescription = + stringResource(id = R.string.new_changelog_notification_message), + ), + statusLevel = StatusLevel.Info, + action = + NotificationAction( + Icons.Default.Clear, + onClickDismissChangelog, + stringResource(id = R.string.dismiss), + ), + ) } @Composable private fun errorMessageBannerData(error: ErrorState) = NotificationData( title = error.title().formatWithHtml(), - message = error.message().formatWithHtml(), + message = NotificationMessage.Text(error.message().formatWithHtml()), statusLevel = StatusLevel.Error, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt deleted file mode 100644 index e5afe6d9bc96..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt +++ /dev/null @@ -1,152 +0,0 @@ -package net.mullvad.mullvadvpn.compose.dialog - -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.spec.DestinationStyle -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.button.PrimaryButton -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.viewmodel.ChangelogUiState -import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel -import org.koin.androidx.compose.koinViewModel - -@Destination(style = DestinationStyle.Dialog::class) -@Composable -fun Changelog(navController: NavController) { - val viewModel = koinViewModel() - val uiState = viewModel.uiState.collectAsStateWithLifecycle() - - ChangelogDialog(uiState.value, onDismiss = { navController.navigateUp() }) -} - -@Composable -fun ChangelogDialog(state: ChangelogUiState, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = state.version, - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - }, - text = { - val scrollState: ScrollState = rememberScrollState() - Column( - modifier = Modifier.fillMaxWidth().verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(Dimens.smallPadding), - ) { - Text( - text = stringResource(R.string.changes_dialog_subtitle), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth(), - ) - - state.changes.forEach { changeItem -> ChangeListItem(text = changeItem) } - } - }, - confirmButton = { - PrimaryButton(text = stringResource(R.string.got_it), onClick = onDismiss) - }, - containerColor = MaterialTheme.colorScheme.surface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - ) -} - -@Composable -private fun ChangeListItem(text: String) { - Column { - Row { - Text( - text = "•", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.width(Dimens.buttonSpacing), - textAlign = TextAlign.Center, - ) - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -@Preview -@Composable -private fun PreviewChangelogDialogWithSingleShortItem() { - AppTheme { - ChangelogDialog( - ChangelogUiState(changes = listOf("Item 1"), version = "1111.1"), - onDismiss = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewChangelogDialogWithTwoLongItems() { - val longPreviewText = - "This is a sample changelog item of a Compose Preview visualization. " + - "The purpose of this specific sample text is to visualize a long text that will result " + - "in multiple lines in the changelog dialog." - - AppTheme { - ChangelogDialog( - ChangelogUiState( - changes = listOf(longPreviewText, longPreviewText), - version = "1111.1", - ), - onDismiss = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewChangelogDialogWithTenShortItems() { - AppTheme { - ChangelogDialog( - ChangelogUiState( - changes = - listOf( - "Item 1", - "Item 2", - "Item 3", - "Item 4", - "Item 5", - "Item 6", - "Item 7", - "Item 8", - "Item 9", - "Item 10", - ), - version = "1111.1", - ), - onDismiss = {}, - ) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt index a642dc72fe67..3e0ae8f8981f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt @@ -17,6 +17,12 @@ fun UriHandler.createOpenAccountPageHook(): (WebsiteAuthToken?) -> Unit { } } +@Composable +fun UriHandler.createOpenFullChangeLogHook(): () -> Unit { + val changelogUrl = stringResource(id = R.string.changelog_url) + return { safeOpenUri(changelogUrl) } +} + fun UriHandler.createUriHook(uri: String): () -> Unit = { safeOpenUri(uri) } private fun UriHandler.safeOpenUri(uri: String) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt new file mode 100644 index 000000000000..42d23a1d0399 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.viewmodel.AppInfoUiState + +class AppInfoUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + AppInfoUiState( + version = VersionInfo(currentVersion = "2024.9", isSupported = true), + isPlayBuild = true, + ), + AppInfoUiState( + version = VersionInfo(currentVersion = "2024.9", isSupported = false), + isPlayBuild = true, + ), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt index e65dc2c8d887..81f103d2c17a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.Info import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -20,6 +19,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed import com.ramcosta.composedestinations.annotation.Destination @@ -31,14 +32,27 @@ import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell import net.mullvad.mullvadvpn.compose.cell.TwoRowCell import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.preview.AppInfoUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.viewmodel.AppInfoSideEffect import net.mullvad.mullvadvpn.viewmodel.AppInfoUiState import net.mullvad.mullvadvpn.viewmodel.AppInfoViewModel import org.koin.androidx.compose.koinViewModel +@OptIn(ExperimentalMaterial3Api::class) +@Preview("Initial|Unsupported") +@Composable +private fun PreviewAppInfoScreen( + @PreviewParameter(AppInfoUiStatePreviewParameterProvider::class) state: AppInfoUiState +) { + AppTheme { + AppInfo(state = state, onBackClick = {}, navigateToChangelog = {}, openAppListing = {}) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Destination(style = SlideInFromRightTransition::class) @Composable @@ -57,7 +71,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 +102,9 @@ fun AppInfoContent( openAppListing: () -> Unit, ) { Column(modifier = Modifier.padding(bottom = Dimens.smallPadding).animateContentSize()) { - AppVersionRow(state, openAppListing) - ChangelogRow(navigateToChangelog) + HorizontalDivider() + AppVersionRow(state, openAppListing) } } @@ -133,8 +148,6 @@ private fun AppVersionRow(state: AppInfoUiState, openAppListing: () -> Unit) { bottom = Dimens.mediumPadding, ), ) - } else { - HorizontalDivider(color = Color.Transparent) } } } @@ -144,12 +157,5 @@ private fun ChangelogRow(navigateToChangelog: () -> Unit) { NavigationComposeCell( title = stringResource(R.string.changelog_title), onClick = navigateToChangelog, - bodyView = { - Icon( - imageVector = Icons.Default.Info, - contentDescription = stringResource(R.string.changelog_title), - tint = MaterialTheme.colorScheme.onPrimary, - ) - }, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreen.kt new file mode 100644 index 000000000000..53bf2113a6d1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogScreen.kt @@ -0,0 +1,211 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.extensions.createOpenFullChangeLogHook +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.viewmodel.ChangeLogSideEffect +import net.mullvad.mullvadvpn.viewmodel.ChangelogUiState +import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import org.koin.androidx.compose.koinViewModel + +@Destination( + style = SlideInFromRightTransition::class, + navArgs = ChangelogNavArgs::class, +) +@Composable +fun Changelog(navController: NavController) { + val viewModel = koinViewModel() + + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + val openAccountPage = LocalUriHandler.current.createOpenFullChangeLogHook() + CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { + when (it) { + is ChangeLogSideEffect.OpenFullChangelog -> openAccountPage() + } + } + LaunchedEffect(Unit) { viewModel.dismissChangelogNotification() } + + ChangelogScreen( + uiState.value, + onBackClick = navController::navigateUp, + onSeeFullChangelog = viewModel::onSeeFullChangelog, + ) +} + +data class ChangelogNavArgs(val isModal: Boolean = false) + +@Composable +fun ChangelogScreen( + state: ChangelogUiState, + onBackClick: () -> Unit, + onSeeFullChangelog: () -> Unit, +) { + + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.changelog_title), + navigationIcon = { + if (state.isModal) { + NavigateCloseIconButton(onBackClick) + } else { + NavigateBackIconButton(onNavigateBack = onBackClick) + } + }, + ) { modifier -> + Column(modifier = modifier.padding(horizontal = Dimens.mediumPadding)) { + val scrollState = rememberScrollState() + Column( + Modifier.weight(1f) + .fillMaxWidth() + .drawVerticalScrollbar( + scrollState, + MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaScrollbar), + ) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(Dimens.mediumPadding), + ) { + Text( + text = state.version, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + if (state.changes.isEmpty()) { + Text( + text = stringResource(R.string.changelog_empty), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + state.changes.forEach { changeItem -> ChangeListItem(text = changeItem) } + } + } + Box(modifier = Modifier.padding(Dimens.mediumPadding).fillMaxWidth()) { + PrimaryButton( + onClick = onSeeFullChangelog, + text = stringResource(R.string.see_full_changelog), + trailingIcon = { + Icon( + imageVector = Icons.AutoMirrored.Default.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + ) + } + } + } +} + +@Composable +private fun ChangeListItem(text: String) { + Column { + Row { + Text( + text = "•", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(Dimens.buttonSpacing), + textAlign = TextAlign.Center, + ) + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview +@Composable +private fun PreviewChangelogDialogWithSingleShortItem() { + AppTheme { + ChangelogScreen( + ChangelogUiState(changes = listOf("Item 1"), version = "1111.1"), + onBackClick = {}, + onSeeFullChangelog = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewChangelogDialogWithTwoLongItems() { + val longPreviewText = + "This is a sample changelog item of a Compose Preview visualization. " + + "The purpose of this specific sample text is to visualize a long text that will result " + + "in multiple lines in the changelog dialog." + + AppTheme { + ChangelogScreen( + ChangelogUiState( + changes = listOf(longPreviewText, longPreviewText), + version = "1111.1", + ), + onBackClick = {}, + onSeeFullChangelog = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewChangelogDialogWithTenShortItems() { + AppTheme { + ChangelogScreen( + ChangelogUiState( + changes = + listOf( + "Item 1", + "Item 2", + "Item 3", + "Item 4", + "Item 5", + "Item 6", + "Item 7", + "Item 8", + "Item 9", + "Item 10", + ), + version = "1111.1", + ), + onBackClick = {}, + onSeeFullChangelog = {}, + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 0ce574d8c1a4..7c4bdbd3b3d3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -57,6 +57,7 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.AccountDestination +import com.ramcosta.composedestinations.generated.destinations.ChangelogDestination import com.ramcosta.composedestinations.generated.destinations.DeviceRevokedDestination import com.ramcosta.composedestinations.generated.destinations.OutOfTimeDestination import com.ramcosta.composedestinations.generated.destinations.SelectLocationDestination @@ -142,10 +143,13 @@ private fun PreviewAccountScreen( {}, {}, {}, + {}, + {}, ) } } +@Suppress("LongMethod") @Destination(style = HomeTransition::class) @Composable fun Connect( @@ -237,6 +241,9 @@ fun Connect( onSwitchLocationClick = dropUnlessResumed { navigator.navigate(SelectLocationDestination) }, onOpenAppListing = connectViewModel::openAppListing, onManageAccountClick = connectViewModel::onManageAccountClick, + onChangelogClick = + dropUnlessResumed { navigator.navigate(ChangelogDestination(ChangelogNavArgs(true))) }, + onDismissChangelogClick = connectViewModel::dismissNewChangelogNotification, onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) }, onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) }, onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, @@ -254,6 +261,8 @@ fun ConnectScreen( onSwitchLocationClick: () -> Unit, onOpenAppListing: () -> Unit, onManageAccountClick: () -> Unit, + onChangelogClick: () -> Unit, + onDismissChangelogClick: () -> Unit, onSettingsClick: () -> Unit, onAccountClick: () -> Unit, onDismissNewDeviceClick: () -> Unit, @@ -309,6 +318,8 @@ fun ConnectScreen( isPlayBuild = state.isPlayBuild, openAppListing = onOpenAppListing, onClickShowAccount = onManageAccountClick, + onClickShowChangelog = onChangelogClick, + onClickDismissChangelog = onDismissChangelogClick, onClickDismissNewDevice = onDismissNewDeviceClick, ) ConnectionCard( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt index eef89c5ea2b6..bdc85b1d6f93 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt @@ -46,6 +46,7 @@ const val LOCATION_INFO_CONNECTION_OUT_TEST_TAG = "location_info_connection_out_ // ConnectScreen - Notification banner const val NOTIFICATION_BANNER = "notification_banner" const val NOTIFICATION_BANNER_ACTION = "notification_banner_action" +const val NOTIFICATION_BANNER_TEXT_ACTION = "notification_banner_text_action" // PlayPayment const val PLAY_PAYMENT_INFO_ICON_TEST_TAG = "play_payment_info_icon_test_tag" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index bc236cc7928b..a56541ee1a24 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -43,6 +43,7 @@ import net.mullvad.mullvadvpn.usecase.FilterChipUseCase import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.InternetAvailableUseCase import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase +import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase @@ -127,8 +128,8 @@ val uiModule = module { single { androidContext().assets } single { androidContext().contentResolver } - single { ChangelogRepository(get()) } - single { UserPreferencesRepository(get()) } + single { ChangelogRepository(get(), get(), get()) } + single { UserPreferencesRepository(get(), get()) } single { SettingsRepository(get()) } single { MullvadProblemReport(get(), get().apiEndpointOverride, get()) } single { RelayOverridesRepository(get()) } @@ -152,6 +153,7 @@ val uiModule = module { single { TunnelStateNotificationUseCase(get()) } single { VersionNotificationUseCase(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS) } single { NewDeviceNotificationUseCase(get(), get()) } + single { NewChangelogNotificationUseCase(get()) } single { OutOfTimeUseCase(get(), get(), MainScope()) } single { InternetAvailableUseCase(get()) } single { SystemVpnSettingsAvailableUseCase(androidContext()) } @@ -166,7 +168,7 @@ val uiModule = module { single { SelectedLocationUseCase(get(), get()) } single { FilterChipUseCase(get(), get(), get(), get()) } - single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } + single { InAppNotificationController(get(), get(), get(), get(), get(), MainScope()) } single { ChangelogDataProvider(get()) } @@ -188,7 +190,7 @@ val uiModule = module { // View models viewModel { AccountViewModel(get(), get(), get(), IS_PLAY_BUILD) } - viewModel { ChangelogViewModel(get(), get()) } + viewModel { ChangelogViewModel(get(), get(), get()) } viewModel { AppInfoViewModel(get(), get(), get(), IS_PLAY_BUILD, get(named(SELF_PACKAGE_NAME))) } @@ -204,6 +206,7 @@ val uiModule = module { get(), get(), get(), + get(), IS_PLAY_BUILD, get(named(SELF_PACKAGE_NAME)), ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt index 5267f5227147..0e392e85cf8d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt @@ -1,12 +1,40 @@ package net.mullvad.mullvadvpn.repository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.model.BuildVersion import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.util.trimAll private const val NEWLINE_CHAR = '\n' private const val BULLET_POINT_CHAR = '-' -class ChangelogRepository(private val dataProvider: IChangelogDataProvider) { +class ChangelogRepository( + private val dataProvider: IChangelogDataProvider, + private val userPreferencesRepository: UserPreferencesRepository, + private val buildVersion: BuildVersion, + dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + val hasUnreadChangelog: StateFlow = + userPreferencesRepository.preferencesFlow + .map { + getLastVersionChanges().isNotEmpty() && + buildVersion.code > it.versionCodeForLatestShownChangelogNotification + } + .stateIn( + CoroutineScope(dispatcher), + started = SharingStarted.Eagerly, + initialValue = false, + ) + + suspend fun setDismissNewChangelogNotification() { + userPreferencesRepository.setHasDisplayedChangelogNotification() + } fun getLastVersionChanges(): List = // Prepend with a new line char so each entry consists of NEWLINE_CHAR + BULLET_POINT_CHAR diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt index 1608e3689ea9..0fcee60bede0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase +import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase @@ -47,11 +48,17 @@ sealed class InAppNotification { override val statusLevel = StatusLevel.Info override val priority: Long = 1001 } + + data object NewVersionChangelog : InAppNotification() { + override val statusLevel = StatusLevel.Info + override val priority: Long = 1001 + } } class InAppNotificationController( accountExpiryInAppNotificationUseCase: AccountExpiryInAppNotificationUseCase, newDeviceNotificationUseCase: NewDeviceNotificationUseCase, + newChangelogNotificationUseCase: NewChangelogNotificationUseCase, versionNotificationUseCase: VersionNotificationUseCase, tunnelStateNotificationUseCase: TunnelStateNotificationUseCase, scope: CoroutineScope, @@ -63,8 +70,9 @@ class InAppNotificationController( versionNotificationUseCase(), accountExpiryInAppNotificationUseCase(), newDeviceNotificationUseCase(), - ) { a, b, c, d -> - a + b + c + d + newChangelogNotificationUseCase(), + ) { a, b, c, d, e -> + a + b + c + d + e } .map { it.sortedWith( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt new file mode 100644 index 000000000000..157de67013c6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.repository.ChangelogRepository +import net.mullvad.mullvadvpn.repository.InAppNotification + +class NewChangelogNotificationUseCase(private val changelogRepository: ChangelogRepository) { + operator fun invoke() = + changelogRepository.hasUnreadChangelog + .map { + buildList { + if (it) { + add(InAppNotification.NewVersionChangelog) + } + } + } + .distinctUntilChanged() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt index 662cbdc4a1df..8e5ec24a149e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt @@ -34,16 +34,12 @@ class AppInfoViewModel( flowOf(changelogRepository.getLastVersionChanges()), flowOf(isPlayBuild), ) { versionInfo, changes, isPlayBuild -> - AppInfoUiState(versionInfo, changes, isPlayBuild) + AppInfoUiState(versionInfo, isPlayBuild) } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - AppInfoUiState( - appVersionInfoRepository.versionInfo.value, - changelogRepository.getLastVersionChanges(), - true, - ), + AppInfoUiState(appVersionInfoRepository.versionInfo.value, true), ) fun openAppListing() = @@ -58,11 +54,7 @@ class AppInfoViewModel( } } -data class AppInfoUiState( - val version: VersionInfo, - val changes: List, - val isPlayBuild: Boolean, -) +data class AppInfoUiState(val version: VersionInfo, val isPlayBuild: Boolean) sealed interface AppInfoSideEffect { data class OpenUri(val uri: Uri) : AppInfoSideEffect diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt index 571d5da3e32f..0feb6ecbd364 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt @@ -1,19 +1,51 @@ package net.mullvad.mullvadvpn.viewmodel import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.destinations.ChangelogDestination +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.lib.model.BuildVersion import net.mullvad.mullvadvpn.repository.ChangelogRepository -class ChangelogViewModel(changelogRepository: ChangelogRepository, buildVersion: BuildVersion) : - ViewModel() { +class ChangelogViewModel( + private val changelogRepository: ChangelogRepository, + savedStateHandle: SavedStateHandle, + buildVersion: BuildVersion, +) : ViewModel() { + private val navArgs = ChangelogDestination.argsFrom(savedStateHandle) + private val _uiSideEffect = Channel() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + val uiState: StateFlow = MutableStateFlow( - ChangelogUiState(buildVersion.name, changelogRepository.getLastVersionChanges()) + ChangelogUiState( + navArgs.isModal, + buildVersion.name, + changelogRepository.getLastVersionChanges(), + ) ) + + fun dismissChangelogNotification() = + viewModelScope.launch { changelogRepository.setDismissNewChangelogNotification() } + + fun onSeeFullChangelog() = + viewModelScope.launch { _uiSideEffect.send(ChangeLogSideEffect.OpenFullChangelog) } +} + +sealed interface ChangeLogSideEffect { + object OpenFullChangelog : ChangeLogSideEffect } -@Parcelize data class ChangelogUiState(val version: String, val changes: List) : Parcelable +@Parcelize +data class ChangelogUiState( + val isModal: Boolean = false, + val version: String, + val changes: List, +) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index 5fb08bcc4855..0ddbd7d72479 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -26,6 +26,7 @@ import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.repository.NewDeviceRepository import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase @@ -41,6 +42,7 @@ import net.mullvad.mullvadvpn.util.withPrev class ConnectViewModel( private val accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, + private val changelogRepository: ChangelogRepository, inAppNotificationController: InAppNotificationController, private val newDeviceRepository: NewDeviceRepository, selectedLocationTitleUseCase: SelectedLocationTitleUseCase, @@ -192,6 +194,9 @@ class ConnectViewModel( newDeviceRepository.clearNewDeviceCreatedNotification() } + fun dismissNewChangelogNotification() = + viewModelScope.launch { changelogRepository.setDismissNewChangelogNotification() } + private fun outOfTimeEffect() = outOfTimeUseCase.isOutOfTime.filter { it == true }.map { UiSideEffect.OutOfTime } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt index 74b599da976f..c8b27f2e6f8b 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt @@ -17,6 +17,7 @@ import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase +import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase @@ -33,6 +34,8 @@ class InAppNotificationControllerTest { private lateinit var inAppNotificationController: InAppNotificationController private val accountExpiryNotifications = MutableStateFlow(emptyList()) private val newDeviceNotifications = MutableStateFlow(emptyList()) + private val newVersionChangelogNotifications = + MutableStateFlow(emptyList()) private val versionNotifications = MutableStateFlow(emptyList()) private val tunnelStateNotifications = MutableStateFlow(emptyList()) @@ -44,10 +47,13 @@ class InAppNotificationControllerTest { val accountExpiryInAppNotificationUseCase: AccountExpiryInAppNotificationUseCase = mockk() val newDeviceNotificationUseCase: NewDeviceNotificationUseCase = mockk() + val newVersionChangelogUseCase: NewChangelogNotificationUseCase = mockk() val versionNotificationUseCase: VersionNotificationUseCase = mockk() val tunnelStateNotificationUseCase: TunnelStateNotificationUseCase = mockk() every { accountExpiryInAppNotificationUseCase.invoke() } returns accountExpiryNotifications every { newDeviceNotificationUseCase.invoke() } returns newDeviceNotifications + every { newVersionChangelogUseCase.invoke() } returns newVersionChangelogNotifications + every { versionNotificationUseCase.invoke() } returns versionNotifications every { versionNotificationUseCase.invoke() } returns versionNotifications every { tunnelStateNotificationUseCase.invoke() } returns tunnelStateNotifications job = Job() @@ -56,6 +62,7 @@ class InAppNotificationControllerTest { InAppNotificationController( accountExpiryInAppNotificationUseCase, newDeviceNotificationUseCase, + newVersionChangelogUseCase, versionNotificationUseCase, tunnelStateNotificationUseCase, CoroutineScope(job + UnconfinedTestDispatcher()), @@ -73,6 +80,9 @@ class InAppNotificationControllerTest { val newDevice = InAppNotification.NewDevice("") newDeviceNotifications.value = listOf(newDevice) + val newVersionChangelog = InAppNotification.NewVersionChangelog + newVersionChangelogNotifications.value = listOf(newVersionChangelog) + val errorState: ErrorState = mockk() val tunnelStateBlocked = InAppNotification.TunnelStateBlocked val tunnelStateError = InAppNotification.TunnelStateError(errorState) @@ -94,6 +104,7 @@ class InAppNotificationControllerTest { unsupportedVersion, accountExpiry, newDevice, + newVersionChangelog, ), notifications, ) diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepositoryTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepositoryTest.kt index 1524549e5741..4d608b7231ec 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepositoryTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepositoryTest.kt @@ -2,15 +2,24 @@ package net.mullvad.mullvadvpn.repository import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.util.IChangelogDataProvider import org.junit.jupiter.api.Test +@ExperimentalCoroutinesApi class ChangelogRepositoryTest { private val mockDataProvider: IChangelogDataProvider = mockk() - private val changelogRepository = ChangelogRepository(dataProvider = mockDataProvider) + private val changelogRepository = + ChangelogRepository( + mockDataProvider, + mockk(relaxed = true), + mockk(), + UnconfinedTestDispatcher(), + ) @Test fun `when given a changelog text should return a list of correctly formatted strings`() { diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index 1dab9a456535..1206af7152a3 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt @@ -30,6 +30,7 @@ import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager @@ -65,6 +66,9 @@ class ConnectViewModelTest { // Device Repository private val mockDeviceRepository: DeviceRepository = mockk() + // Changelog Repository + private val mockChangelogRepository: ChangelogRepository = mockk() + // In App Notifications private val mockInAppNotificationController: InAppNotificationController = mockk() @@ -113,6 +117,7 @@ class ConnectViewModelTest { ConnectViewModel( accountRepository = mockAccountRepository, deviceRepository = mockDeviceRepository, + changelogRepository = mockChangelogRepository, inAppNotificationController = mockInAppNotificationController, newDeviceRepository = mockk(), outOfTimeUseCase = outOfTimeUseCase,