summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2024-10-18 09:39:17 +0200
committerDavid Göransson <david.goransson@mullvad.net>2024-10-18 09:39:17 +0200
commit8ffc8d3a6f2f2598566791a2bf87d05b316b1b18 (patch)
treef5af089ca00b821b8523502d88f19183db017e16 /android
parenta6b2db5e8ffefc4197edf7b092bf47662f365fb9 (diff)
parentb71e78a641788a0660cc4da5a4ae40b2b18014b4 (diff)
downloadmullvadvpn-8ffc8d3a6f2f2598566791a2bf87d05b316b1b18.tar.xz
mullvadvpn-8ffc8d3a6f2f2598566791a2bf87d05b316b1b18.zip
Merge branch 'move-changelog-to-settings-droid-1414'
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt20
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt2
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt28
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt155
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt22
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt79
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepository.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt69
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt39
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/ChangelogRepositoryTest.kt5
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt78
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt2
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml3
-rw-r--r--android/lib/resource/src/main/res/values/strings_non_translatable.xml1
29 files changed, 356 insertions, 271 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt
index 48161a4690..b8165f80fc 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt
@@ -4,16 +4,12 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import io.mockk.MockKAnnotations
-import io.mockk.Runs
-import io.mockk.every
import io.mockk.impl.annotations.MockK
-import io.mockk.just
-import io.mockk.verify
import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension
import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog
import net.mullvad.mullvadvpn.compose.setContentWithTheme
-import net.mullvad.mullvadvpn.viewmodel.Changelog
-import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
+import net.mullvad.mullvadvpn.viewmodel.AppInfoViewModel
+import net.mullvad.mullvadvpn.viewmodel.ChangelogUiState
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
@@ -22,7 +18,7 @@ import org.junit.jupiter.api.extension.RegisterExtension
class ChangelogDialogTest {
@JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension()
- @MockK lateinit var mockedViewModel: ChangelogViewModel
+ @MockK lateinit var mockedViewModel: AppInfoViewModel
@BeforeEach
fun setup() {
@@ -33,13 +29,10 @@ class ChangelogDialogTest {
fun testShowChangeLogWhenNeeded() =
composeExtension.use {
// Arrange
- // Arrange
- every { mockedViewModel.markChangelogAsRead() } just Runs
-
setContentWithTheme {
ChangelogDialog(
- Changelog(changes = listOf(CHANGELOG_ITEM), version = CHANGELOG_VERSION),
- onDismiss = { mockedViewModel.markChangelogAsRead() },
+ ChangelogUiState(changes = listOf(CHANGELOG_ITEM), version = CHANGELOG_VERSION),
+ onDismiss = {},
)
}
@@ -48,9 +41,6 @@ class ChangelogDialogTest {
// perform click on Got It button to check if dismiss occur
onNodeWithText(CHANGELOG_BUTTON_TEXT).performClick()
-
- // Assert
- verify { mockedViewModel.markChangelogAsRead() }
}
companion object {
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 f55e07c1cf..05862a5bc4 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
@@ -594,7 +594,7 @@ class ConnectScreenTest {
val versionInfo = VersionInfo(isSupported = false, currentVersion = "")
setContentWithTheme {
ConnectScreen(
- onUpdateVersionClick = mockedClickHandler,
+ onOpenAppListing = mockedClickHandler,
state =
ConnectUiState(
location = null,
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt
index f108efab8e..2509c7be8d 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt
@@ -39,7 +39,7 @@ class SettingsScreenTest {
// Assert
onNodeWithText("VPN settings").assertExists()
onNodeWithText("Split tunneling").assertExists()
- onNodeWithText("App version").assertExists()
+ onNodeWithText("App info").assertExists()
onNodeWithText("API access").assertExists()
}
@@ -62,7 +62,7 @@ class SettingsScreenTest {
// Assert
onNodeWithText("VPN settings").assertDoesNotExist()
onNodeWithText("Split tunneling").assertDoesNotExist()
- onNodeWithText("App version").assertExists()
+ onNodeWithText("App info").assertExists()
onNodeWithText("API access").assertExists()
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
index 0fd7674c89..ece54c9102 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
@@ -136,7 +136,7 @@ internal fun NavigationCellBody(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.wrapContentWidth().wrapContentHeight(),
) {
- Text(text = content, style = MaterialTheme.typography.labelMedium, color = textColor)
+ Text(text = content, style = MaterialTheme.typography.bodyMedium, color = textColor)
Spacer(modifier = Modifier.width(Dimens.sideMargin))
if (isExternalLink) {
DefaultExternalLinkView(content, tint = contentColor)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
index 4c86a33452..b0f2d63ace 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
@@ -32,8 +32,8 @@ fun TwoRowCell(
iconView: @Composable RowScope.() -> Unit = {},
onCellClicked: (() -> Unit)? = null,
titleColor: Color = MaterialTheme.colorScheme.onPrimary,
- subtitleColor: Color = MaterialTheme.colorScheme.onPrimary,
- titleStyle: TextStyle = MaterialTheme.typography.labelLarge,
+ subtitleColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
+ titleStyle: TextStyle = MaterialTheme.typography.titleMedium,
subtitleStyle: TextStyle = MaterialTheme.typography.labelLarge,
background: Color = MaterialTheme.colorScheme.primary,
endPadding: Dp = Dimens.cellEndPadding,
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 df7d3dede0..f2b33c251e 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
@@ -77,7 +77,7 @@ fun NotificationBanner(
modifier: Modifier = Modifier,
notification: InAppNotification?,
isPlayBuild: Boolean,
- onClickUpdateVersion: () -> Unit,
+ openAppListing: () -> Unit,
onClickShowAccount: () -> Unit,
onClickDismissNewDevice: () -> Unit,
) {
@@ -94,7 +94,7 @@ fun NotificationBanner(
Notification(
visibleNotification.toNotificationData(
isPlayBuild = isPlayBuild,
- onClickUpdateVersion,
+ openAppListing,
onClickShowAccount,
onClickDismissNewDevice,
)
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 de36f76ac7..5318933852 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
@@ -44,7 +44,7 @@ data class NotificationAction(
@Composable
fun InAppNotification.toNotificationData(
isPlayBuild: Boolean,
- onClickUpdateVersion: () -> Unit,
+ openAppListing: () -> Unit,
onClickShowAccount: () -> Unit,
onDismissNewDevice: () -> Unit,
) =
@@ -101,13 +101,11 @@ fun InAppNotification.toNotificationData(
message = stringResource(id = R.string.unsupported_version_description),
statusLevel = StatusLevel.Error,
action =
- if (isPlayBuild) null
- else
- NotificationAction(
- Icons.AutoMirrored.Default.OpenInNew,
- onClickUpdateVersion,
- stringResource(id = R.string.open_url),
- ),
+ NotificationAction(
+ Icons.AutoMirrored.Default.OpenInNew,
+ openAppListing,
+ stringResource(id = R.string.open_url),
+ ),
)
}
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
index d2e96dc1d9..67a6aac2fe 100644
--- 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
@@ -16,6 +16,7 @@ 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
@@ -24,26 +25,21 @@ 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.Changelog
+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, changeLog: Changelog) {
+fun Changelog(navController: NavController) {
val viewModel = koinViewModel<ChangelogViewModel>()
+ val uiState = viewModel.uiState.collectAsStateWithLifecycle()
- ChangelogDialog(
- changeLog,
- onDismiss = {
- viewModel.markChangelogAsRead()
- navController.navigateUp()
- },
- )
+ ChangelogDialog(uiState.value, onDismiss = { navController.navigateUp() })
}
@Composable
-fun ChangelogDialog(changeLog: Changelog, onDismiss: () -> Unit) {
+fun ChangelogDialog(changeLog: ChangelogUiState, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
@@ -103,7 +99,10 @@ private fun ChangeListItem(text: String) {
@Composable
private fun PreviewChangelogDialogWithSingleShortItem() {
AppTheme {
- ChangelogDialog(Changelog(changes = listOf("Item 1"), version = "1111.1"), onDismiss = {})
+ ChangelogDialog(
+ ChangelogUiState(changes = listOf("Item 1"), version = "1111.1"),
+ onDismiss = {},
+ )
}
}
@@ -117,7 +116,10 @@ private fun PreviewChangelogDialogWithTwoLongItems() {
AppTheme {
ChangelogDialog(
- Changelog(changes = listOf(longPreviewText, longPreviewText), version = "1111.1"),
+ ChangelogUiState(
+ changes = listOf(longPreviewText, longPreviewText),
+ version = "1111.1",
+ ),
onDismiss = {},
)
}
@@ -128,7 +130,7 @@ private fun PreviewChangelogDialogWithTwoLongItems() {
private fun PreviewChangelogDialogWithTenShortItems() {
AppTheme {
ChangelogDialog(
- Changelog(
+ ChangelogUiState(
changes =
listOf(
"Item 1",
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt
index 5dc2fa6d30..c1fc183cfa 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ApiAccessListScreen.kt
@@ -186,8 +186,6 @@ private fun ApiAccessMethodItem(
R.string.off
}
),
- titleStyle = MaterialTheme.typography.titleMedium,
- subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant,
bodyView = {
Icon(
Icons.Default.ChevronRight,
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
new file mode 100644
index 0000000000..e65dc2c8d8
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AppInfoScreen.kt
@@ -0,0 +1,155 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+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
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.compose.dropUnlessResumed
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.annotation.RootGraph
+import com.ramcosta.composedestinations.generated.destinations.ChangelogDestination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import net.mullvad.mullvadvpn.R
+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.transitions.SlideInFromRightTransition
+import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
+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)
+@Destination<RootGraph>(style = SlideInFromRightTransition::class)
+@Composable
+fun AppInfo(navigator: DestinationsNavigator) {
+ val vm = koinViewModel<AppInfoViewModel>()
+ val state by vm.uiState.collectAsStateWithLifecycle()
+
+ val uriHandler = LocalUriHandler.current
+
+ CollectSideEffectWithLifecycle(vm.uiSideEffect) {
+ when (it) {
+ is AppInfoSideEffect.OpenUri -> uriHandler.openUri(it.uri.toString())
+ }
+ }
+
+ AppInfo(
+ state = state,
+ onBackClick = dropUnlessResumed { navigator.navigateUp() },
+ navigateToChangelog = dropUnlessResumed { navigator.navigate(ChangelogDestination) },
+ openAppListing = dropUnlessResumed { vm.openAppListing() },
+ )
+}
+
+@ExperimentalMaterial3Api
+@Composable
+fun AppInfo(
+ state: AppInfoUiState,
+ onBackClick: () -> Unit,
+ navigateToChangelog: () -> Unit,
+ openAppListing: () -> Unit,
+) {
+ ScaffoldWithMediumTopBar(
+ appBarTitle = stringResource(id = R.string.app_info),
+ navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
+ ) { modifier ->
+ Column(horizontalAlignment = Alignment.Start, modifier = modifier.animateContentSize()) {
+ AppInfoContent(state, navigateToChangelog, openAppListing)
+ }
+ }
+}
+
+@Composable
+fun AppInfoContent(
+ state: AppInfoUiState,
+ navigateToChangelog: () -> Unit,
+ openAppListing: () -> Unit,
+) {
+ Column(modifier = Modifier.padding(bottom = Dimens.smallPadding).animateContentSize()) {
+ AppVersionRow(state, openAppListing)
+
+ ChangelogRow(navigateToChangelog)
+ }
+}
+
+@Composable
+private fun AppVersionRow(state: AppInfoUiState, openAppListing: () -> Unit) {
+ Column {
+ TwoRowCell(
+ titleText = stringResource(id = R.string.version),
+ subtitleText = state.version.currentVersion,
+ iconView = {
+ if (!state.version.isSupported) {
+ Icon(
+ imageVector = Icons.Default.Error,
+ modifier = Modifier.padding(end = Dimens.smallPadding),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ bodyView = {
+ Icon(
+ Icons.AutoMirrored.Default.OpenInNew,
+ contentDescription = stringResource(R.string.app_info),
+ tint = MaterialTheme.colorScheme.onPrimary,
+ )
+ },
+ onCellClicked = openAppListing,
+ )
+
+ if (!state.version.isSupported) {
+ Text(
+ text = stringResource(id = R.string.unsupported_version_description),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier =
+ Modifier.fillMaxWidth()
+ .padding(
+ start = Dimens.cellStartPadding,
+ end = Dimens.cellStartPadding,
+ top = Dimens.smallPadding,
+ bottom = Dimens.mediumPadding,
+ ),
+ )
+ } else {
+ HorizontalDivider(color = Color.Transparent)
+ }
+ }
+}
+
+@Composable
+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/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt
index a9d47893b1..9e99ae2daa 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
@@ -1,8 +1,6 @@
package net.mullvad.mullvadvpn.compose.screen
import android.content.Context
-import android.content.Intent
-import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
@@ -51,6 +49,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.dropUnlessResumed
+import co.touchlab.kermit.Logger
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.NavGraphs
@@ -109,7 +108,6 @@ import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
import net.mullvad.mullvadvpn.lib.theme.typeface.connectionStatus
import net.mullvad.mullvadvpn.lib.theme.typeface.hostname
-import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild
import net.mullvad.mullvadvpn.util.removeHtmlTags
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import org.koin.androidx.compose.koinViewModel
@@ -147,6 +145,7 @@ fun Connect(
}
val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook()
+ val uriHandler = LocalUriHandler.current
CollectSideEffectWithLifecycle(
connectViewModel.uiSideEffect,
minActiveState = Lifecycle.State.RESUMED,
@@ -175,6 +174,14 @@ fun Connect(
message = sideEffect.toMessage(context)
)
}
+
+ is ConnectViewModel.UiSideEffect.OpenUri -> {
+ try {
+ uriHandler.openUri(sideEffect.uri.toString())
+ } catch (e: IllegalArgumentException) {
+ Logger.w("Failed to open uri", e)
+ }
+ }
}
}
@@ -192,19 +199,7 @@ fun Connect(
onConnectClick = connectViewModel::onConnectClick,
onCancelClick = connectViewModel::onCancelClick,
onSwitchLocationClick = dropUnlessResumed { navigator.navigate(SelectLocationDestination) },
- onUpdateVersionClick = {
- val intent =
- Intent(
- Intent.ACTION_VIEW,
- Uri.parse(
- context
- .getString(R.string.download_url)
- .appendHideNavOnPlayBuild(state.isPlayBuild)
- ),
- )
- .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
- context.startActivity(intent)
- },
+ onOpenAppListing = connectViewModel::openAppListing,
onManageAccountClick = connectViewModel::onManageAccountClick,
onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) },
onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) },
@@ -221,7 +216,7 @@ fun ConnectScreen(
onConnectClick: () -> Unit = {},
onCancelClick: () -> Unit = {},
onSwitchLocationClick: () -> Unit = {},
- onUpdateVersionClick: () -> Unit = {},
+ onOpenAppListing: () -> Unit = {},
onManageAccountClick: () -> Unit = {},
onSettingsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
@@ -268,7 +263,7 @@ fun ConnectScreen(
NotificationBanner(
notification = state.inAppNotification,
isPlayBuild = state.isPlayBuild,
- onClickUpdateVersion = onUpdateVersionClick,
+ openAppListing = onOpenAppListing,
onClickShowAccount = onManageAccountClick,
onClickDismissNewDevice = onDismissNewDeviceClick,
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt
index 96c9a63ce2..332992c4e5 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt
@@ -13,26 +13,17 @@ import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.generated.NavGraphs
-import com.ramcosta.composedestinations.generated.destinations.ChangelogDestination
-import com.ramcosta.composedestinations.generated.destinations.ConnectDestination
import com.ramcosta.composedestinations.generated.destinations.NoDaemonDestination
-import com.ramcosta.composedestinations.generated.destinations.OutOfTimeDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.rememberNavHostEngine
-import com.ramcosta.composedestinations.utils.destination
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.map
import net.mullvad.mullvadvpn.compose.util.RequestVpnPermission
-import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import net.mullvad.mullvadvpn.viewmodel.DaemonScreenEvent
import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel
import net.mullvad.mullvadvpn.viewmodel.VpnPermissionSideEffect
import net.mullvad.mullvadvpn.viewmodel.VpnPermissionViewModel
import org.koin.androidx.compose.koinViewModel
-private val changeLogDestinations = listOf(ConnectDestination, OutOfTimeDestination)
-
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MullvadApp() {
@@ -71,19 +62,6 @@ fun MullvadApp() {
}
}
- // Globally show the changelog
- val changeLogsViewModel = koinViewModel<ChangelogViewModel>()
- LaunchedEffect(Unit) {
- changeLogsViewModel.uiSideEffect.collect {
- // Wait until we are in an acceptable destination
- navHostController.currentBackStackEntryFlow
- .map { it.destination() }
- .first { it in changeLogDestinations }
-
- navigator.navigate(ChangelogDestination(it))
- }
- }
-
// Ask for VPN Permission
val launchVpnPermission =
rememberLauncherForActivityResult(RequestVpnPermission()) { _ -> permissionVm.connect() }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
index 8a50649ac8..eaa8cc7933 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
@@ -3,14 +3,17 @@ package net.mullvad.mullvadvpn.compose.screen
import android.content.Context
import android.net.Uri
import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -24,14 +27,15 @@ import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.ApiAccessListDestination
+import com.ramcosta.composedestinations.generated.destinations.AppInfoDestination
import com.ramcosta.composedestinations.generated.destinations.ReportProblemDestination
import com.ramcosta.composedestinations.generated.destinations.SplitTunnelingDestination
import com.ramcosta.composedestinations.generated.destinations.VpnSettingsDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.cell.DefaultExternalLinkView
-import net.mullvad.mullvadvpn.compose.cell.NavigationCellBody
import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell
+import net.mullvad.mullvadvpn.compose.cell.TwoRowCell
import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider
@@ -66,6 +70,7 @@ fun Settings(navigator: DestinationsNavigator) {
onVpnSettingCellClick = dropUnlessResumed { navigator.navigate(VpnSettingsDestination) },
onSplitTunnelingCellClick =
dropUnlessResumed { navigator.navigate(SplitTunnelingDestination) },
+ onAppInfoClick = dropUnlessResumed { navigator.navigate(AppInfoDestination) },
onApiAccessClick = dropUnlessResumed { navigator.navigate(ApiAccessListDestination) },
onReportProblemCellClick =
dropUnlessResumed { navigator.navigate(ReportProblemDestination) },
@@ -79,6 +84,7 @@ fun SettingsScreen(
state: SettingsUiState,
onVpnSettingCellClick: () -> Unit = {},
onSplitTunnelingCellClick: () -> Unit = {},
+ onAppInfoClick: () -> Unit = {},
onReportProblemCellClick: () -> Unit = {},
onApiAccessClick: () -> Unit = {},
onBackClick: () -> Unit = {},
@@ -114,7 +120,7 @@ fun SettingsScreen(
}
item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) }
- item { AppVersion(context, state) }
+ item { AppInfo(onAppInfoClick, state) }
item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) }
@@ -138,54 +144,29 @@ private fun SplitTunneling(onSplitTunnelingCellClick: () -> Unit) {
}
@Composable
-private fun AppVersion(context: Context, state: SettingsUiState) {
- NavigationComposeCell(
- title = stringResource(id = R.string.app_version),
- onClick = {
- context.openLink(
- Uri.parse(
- context.resources
- .getString(R.string.download_url)
- .appendHideNavOnPlayBuild(state.isPlayBuild)
- )
- )
- },
- bodyView =
- @Composable {
- if (!state.isPlayBuild) {
- NavigationCellBody(
- content = state.appVersion,
- contentBodyDescription = stringResource(id = R.string.app_version),
- textColor = MaterialTheme.colorScheme.onSurfaceVariant,
- isExternalLink = true,
- )
- } else {
- Text(
- text = state.appVersion,
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+private fun AppInfo(navigateToAppInfo: () -> Unit, state: SettingsUiState) {
+ TwoRowCell(
+ titleText = stringResource(id = R.string.app_info),
+ subtitleText = state.appVersion,
+ bodyView = {
+ Row {
+ if (!state.isSupportedVersion) {
+ Icon(
+ imageVector = Icons.Default.Error,
+ modifier = Modifier.padding(end = Dimens.smallPadding),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
)
}
- },
- showWarning = !state.isSupportedVersion,
- isRowEnabled = !state.isPlayBuild,
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = stringResource(R.string.app_info),
+ tint = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+ },
+ onCellClicked = navigateToAppInfo,
)
-
- if (!state.isSupportedVersion) {
- Text(
- text = stringResource(id = R.string.unsupported_version_description),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier =
- Modifier.fillMaxWidth()
- .padding(
- start = Dimens.cellStartPadding,
- top = Dimens.cellTopPadding,
- end = Dimens.cellStartPadding,
- bottom = Dimens.cellLabelVerticalPadding,
- ),
- )
- }
}
@Composable
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 e00b3d9b98..2605075ef8 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
@@ -54,6 +54,7 @@ import net.mullvad.mullvadvpn.util.IChangelogDataProvider
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
import net.mullvad.mullvadvpn.viewmodel.ApiAccessListViewModel
import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsViewModel
+import net.mullvad.mullvadvpn.viewmodel.AppInfoViewModel
import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel
@@ -118,7 +119,7 @@ val uiModule = module {
single { androidContext().assets }
single { androidContext().contentResolver }
- single { ChangelogRepository(get(named(APP_PREFERENCES_NAME)), get()) }
+ single { ChangelogRepository(get()) }
single {
PrivacyDisclaimerRepository(
androidContext().getSharedPreferences(APP_PREFERENCES_NAME, Context.MODE_PRIVATE)
@@ -181,7 +182,10 @@ val uiModule = module {
// View models
viewModel { AccountViewModel(get(), get(), get(), IS_PLAY_BUILD) }
- viewModel { ChangelogViewModel(get(), get(), BuildConfig.ALWAYS_SHOW_CHANGELOG) }
+ viewModel { ChangelogViewModel(get(), get()) }
+ viewModel {
+ AppInfoViewModel(get(), get(), get(), IS_PLAY_BUILD, get(named(SELF_PACKAGE_NAME)))
+ }
viewModel {
ConnectViewModel(
get(),
@@ -194,7 +198,9 @@ val uiModule = module {
get(),
get(),
get(),
+ get(),
IS_PLAY_BUILD,
+ get(named(SELF_PACKAGE_NAME)),
)
}
viewModel { DeviceListViewModel(get(), get()) }
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 fcc07693c8..5267f52271 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,24 +1,12 @@
package net.mullvad.mullvadvpn.repository
-import android.content.SharedPreferences
import net.mullvad.mullvadvpn.util.IChangelogDataProvider
import net.mullvad.mullvadvpn.util.trimAll
-private const val MISSING_VERSION_CODE = -1
private const val NEWLINE_CHAR = '\n'
private const val BULLET_POINT_CHAR = '-'
-private const val LAST_SHOWED_CHANGELOG_VERSION_CODE = "last_showed_changelog_version_code"
-class ChangelogRepository(
- private val preferences: SharedPreferences,
- private val dataProvider: IChangelogDataProvider,
-) {
- fun getVersionCodeOfMostRecentChangelogShowed(): Int {
- return preferences.getInt(LAST_SHOWED_CHANGELOG_VERSION_CODE, MISSING_VERSION_CODE)
- }
-
- fun setVersionCodeOfMostRecentChangelogShowed(versionCode: Int) =
- preferences.edit().putInt(LAST_SHOWED_CHANGELOG_VERSION_CODE, versionCode).apply()
+class ChangelogRepository(private val dataProvider: IChangelogDataProvider) {
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/ui/serviceconnection/AppVersionInfoRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt
index 6f8b92f304..7a74c0f0d2 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt
@@ -13,10 +13,10 @@ import net.mullvad.mullvadvpn.ui.VersionInfo
class AppVersionInfoRepository(
private val buildVersion: BuildVersion,
- private val managementService: ManagementService,
- private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
+ managementService: ManagementService,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
- fun versionInfo(): StateFlow<VersionInfo> =
+ val versionInfo: StateFlow<VersionInfo> =
managementService.versionInfo
.map { appVersionInfo ->
VersionInfo(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt
index 004dd44351..9b71297d36 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt
@@ -1,9 +1,11 @@
package net.mullvad.mullvadvpn.usecase
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD
@@ -24,8 +26,10 @@ class AccountExpiryInAppNotificationUseCase(private val accountRepository: Accou
)
.map { expiresInPeriod -> InAppNotification.AccountExpiry(expiresInPeriod) }
} else {
- flowOf<InAppNotification?>(null)
+ flowOf(null)
}
}
.map(::listOfNotNull)
+ .onStart { emit(emptyList()) }
+ .distinctUntilChanged()
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt
index 18d4e2fc3e..d46089a9d3 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt
@@ -12,8 +12,7 @@ class VersionNotificationUseCase(
) {
operator fun invoke() =
- appVersionInfoRepository
- .versionInfo()
+ appVersionInfoRepository.versionInfo
.map { versionInfo -> listOfNotNull(unsupportedVersionNotification(versionInfo)) }
.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
new file mode 100644
index 0000000000..662cbdc4a1
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt
@@ -0,0 +1,69 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import android.content.res.Resources
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.repository.ChangelogRepository
+import net.mullvad.mullvadvpn.ui.VersionInfo
+import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository
+
+class AppInfoViewModel(
+ changelogRepository: ChangelogRepository,
+ appVersionInfoRepository: AppVersionInfoRepository,
+ private val resources: Resources,
+ private val isPlayBuild: Boolean,
+ private val packageName: String,
+) : ViewModel() {
+
+ private val _uiSideEffect = Channel<AppInfoSideEffect>()
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ val uiState: StateFlow<AppInfoUiState> =
+ combine(
+ appVersionInfoRepository.versionInfo,
+ flowOf(changelogRepository.getLastVersionChanges()),
+ flowOf(isPlayBuild),
+ ) { versionInfo, changes, isPlayBuild ->
+ AppInfoUiState(versionInfo, changes, isPlayBuild)
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ AppInfoUiState(
+ appVersionInfoRepository.versionInfo.value,
+ changelogRepository.getLastVersionChanges(),
+ true,
+ ),
+ )
+
+ fun openAppListing() =
+ viewModelScope.launch {
+ val uri =
+ if (isPlayBuild) {
+ resources.getString(R.string.market_uri, packageName)
+ } else {
+ resources.getString(R.string.download_url)
+ }
+ _uiSideEffect.send(AppInfoSideEffect.OpenUri(Uri.parse(uri)))
+ }
+}
+
+data class AppInfoUiState(
+ val version: VersionInfo,
+ val changes: List<String>,
+ 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 f0817ea4fe..571d5da3e3 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
@@ -2,39 +2,18 @@ package net.mullvad.mullvadvpn.viewmodel
import android.os.Parcelable
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.lib.model.BuildVersion
import net.mullvad.mullvadvpn.repository.ChangelogRepository
-class ChangelogViewModel(
- private val changelogRepository: ChangelogRepository,
- private val buildVersion: BuildVersion,
- private val alwaysShowChangelog: Boolean,
-) : ViewModel() {
-
- private val _uiSideEffect = MutableSharedFlow<Changelog>(replay = 1, extraBufferCapacity = 1)
- val uiSideEffect: SharedFlow<Changelog> = _uiSideEffect
-
- init {
- if (shouldShowChangelog()) {
- val changelog =
- Changelog(buildVersion.name, changelogRepository.getLastVersionChanges())
- viewModelScope.launch { _uiSideEffect.emit(changelog) }
- }
- }
-
- fun markChangelogAsRead() {
- changelogRepository.setVersionCodeOfMostRecentChangelogShowed(buildVersion.code)
- }
-
- private fun shouldShowChangelog(): Boolean =
- alwaysShowChangelog ||
- (changelogRepository.getVersionCodeOfMostRecentChangelogShowed() < buildVersion.code &&
- changelogRepository.getLastVersionChanges().isNotEmpty())
+class ChangelogViewModel(changelogRepository: ChangelogRepository, buildVersion: BuildVersion) :
+ ViewModel() {
+ val uiState: StateFlow<ChangelogUiState> =
+ MutableStateFlow(
+ ChangelogUiState(buildVersion.name, changelogRepository.getLastVersionChanges())
+ )
}
-@Parcelize data class Changelog(val version: String, val changes: List<String>) : Parcelable
+@Parcelize data class ChangelogUiState(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 33d81f3ba1..d9ca922f1f 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
@@ -1,5 +1,7 @@
package net.mullvad.mullvadvpn.viewmodel
+import android.content.res.Resources
+import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.FlowPreview
@@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.state.ConnectUiState
import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect
import net.mullvad.mullvadvpn.lib.model.ConnectError
@@ -46,7 +49,9 @@ class ConnectViewModel(
private val connectionProxy: ConnectionProxy,
lastKnownLocationUseCase: LastKnownLocationUseCase,
private val vpnPermissionRepository: VpnPermissionRepository,
+ private val resources: Resources,
private val isPlayBuild: Boolean,
+ private val packageName: String,
) : ViewModel() {
private val _uiSideEffect = Channel<UiSideEffect>()
@@ -169,6 +174,17 @@ class ConnectViewModel(
}
}
+ fun openAppListing() =
+ viewModelScope.launch {
+ val uri =
+ if (isPlayBuild) {
+ resources.getString(R.string.market_uri, packageName)
+ } else {
+ resources.getString(R.string.download_url)
+ }
+ _uiSideEffect.send(UiSideEffect.OpenUri(Uri.parse(uri)))
+ }
+
fun dismissNewDeviceNotification() {
newDeviceRepository.clearNewDeviceCreatedNotification()
}
@@ -186,6 +202,8 @@ class ConnectViewModel(
data object OutOfTime : UiSideEffect
+ data class OpenUri(val uri: Uri) : UiSideEffect
+
data object RevokedDevice : UiSideEffect
data object NoVpnPermission : UiSideEffect
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt
index 4cd11f2cc4..fc6b4af3ee 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt
@@ -18,7 +18,7 @@ class SettingsViewModel(
) : ViewModel() {
val uiState: StateFlow<SettingsUiState> =
- combine(deviceRepository.deviceState, appVersionInfoRepository.versionInfo()) {
+ combine(deviceRepository.deviceState, appVersionInfoRepository.versionInfo) {
deviceState,
versionInfo ->
SettingsUiState(
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 e38d6bebbc..1524549e57 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
@@ -1,6 +1,5 @@
package net.mullvad.mullvadvpn.repository
-import android.content.SharedPreferences
import io.mockk.every
import io.mockk.mockk
import net.mullvad.mullvadvpn.lib.common.test.assertLists
@@ -9,11 +8,9 @@ import org.junit.jupiter.api.Test
class ChangelogRepositoryTest {
- private val mockedPreferences: SharedPreferences = mockk()
private val mockDataProvider: IChangelogDataProvider = mockk()
- private val changelogRepository =
- ChangelogRepository(preferences = mockedPreferences, dataProvider = mockDataProvider)
+ private val changelogRepository = ChangelogRepository(dataProvider = mockDataProvider)
@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/usecase/VersionNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt
index 1aead39d85..e9452884cf 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt
@@ -29,7 +29,7 @@ class VersionNotificationUseCaseTest {
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
- every { mockAppVersionInfoRepository.versionInfo() } returns versionInfo
+ every { mockAppVersionInfoRepository.versionInfo } returns versionInfo
versionNotificationUseCase =
VersionNotificationUseCase(
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt
deleted file mode 100644
index 7888f02a4d..0000000000
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package net.mullvad.mullvadvpn.viewmodel
-
-import app.cash.turbine.test
-import io.mockk.MockKAnnotations
-import io.mockk.Runs
-import io.mockk.every
-import io.mockk.impl.annotations.MockK
-import io.mockk.just
-import io.mockk.unmockkAll
-import kotlin.test.assertEquals
-import kotlinx.coroutines.test.runTest
-import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
-import net.mullvad.mullvadvpn.lib.model.BuildVersion
-import net.mullvad.mullvadvpn.repository.ChangelogRepository
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
-import org.junit.jupiter.api.extension.ExtendWith
-
-@ExtendWith(TestCoroutineRule::class)
-class ChangelogViewModelTest {
-
- @MockK private lateinit var mockedChangelogRepository: ChangelogRepository
-
- private lateinit var viewModel: ChangelogViewModel
-
- private val buildVersion = BuildVersion("1.0", 10)
-
- @BeforeEach
- fun setup() {
- MockKAnnotations.init(this)
- every { mockedChangelogRepository.setVersionCodeOfMostRecentChangelogShowed(any()) } just
- Runs
- }
-
- @AfterEach
- fun teardown() {
- unmockkAll()
- }
-
- @Test
- fun `given up to date version code uiSideEffect should not emit`() = runTest {
- // Arrange
- every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns
- buildVersion.code
- viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersion, false)
-
- // If we have the most up to date version code, we should not show the changelog dialog
- viewModel.uiSideEffect.test { expectNoEvents() }
- }
-
- @Test
- fun `given old version code uiSideEffect should emit ChangeLog`() = runTest {
- // Arrange
- val version = -1
- val changes = listOf("first change", "second change")
- every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns
- version
- every { mockedChangelogRepository.getLastVersionChanges() } returns changes
-
- viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersion, false)
- // Given a new version with a change log we should return it
- viewModel.uiSideEffect.test {
- assertEquals(awaitItem(), Changelog(version = buildVersion.name, changes = changes))
- }
- }
-
- @Test
- fun `given old version code and empty change log uiSideEffect should not emit`() = runTest {
- // Arrange
- every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns -1
- every { mockedChangelogRepository.getLastVersionChanges() } returns emptyList()
-
- viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersion, false)
- // Given a new version with a change log we should not return it
- viewModel.uiSideEffect.test { expectNoEvents() }
- }
-}
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 9696a30539..33e836acd8 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
@@ -124,7 +124,9 @@ class ConnectViewModelTest {
connectionProxy = mockConnectionProxy,
lastKnownLocationUseCase = mockLastKnownLocationUseCase,
vpnPermissionRepository = mockVpnPermissionRepository,
+ resources = mockk(),
isPlayBuild = false,
+ packageName = "net.mullvad.mullvadvpn",
)
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt
index 97259b8b43..8857eb364a 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt
@@ -35,7 +35,7 @@ class SettingsViewModelTest {
val deviceState = MutableStateFlow<DeviceState>(DeviceState.LoggedOut)
every { mockDeviceRepository.deviceState } returns deviceState
- every { mockAppVersionInfoRepository.versionInfo() } returns versionInfo
+ every { mockAppVersionInfoRepository.versionInfo } returns versionInfo
viewModel =
SettingsViewModel(
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 693ac3a789..964ad4c3f2 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -416,4 +416,7 @@
<string name="collapse">Collapse</string>
<string name="copy">Copy</string>
<string name="share">Share…</string>
+ <string name="app_info">App info</string>
+ <string name="changelog_title">Changelog</string>
+ <string name="version">Version</string>
</resources>
diff --git a/android/lib/resource/src/main/res/values/strings_non_translatable.xml b/android/lib/resource/src/main/res/values/strings_non_translatable.xml
index 9cf571171a..0f0a64e796 100644
--- a/android/lib/resource/src/main/res/values/strings_non_translatable.xml
+++ b/android/lib/resource/src/main/res/values/strings_non_translatable.xml
@@ -4,6 +4,7 @@
<string name="voucher_hint" translatable="false">XXXX-XXXX-XXXX-XXXX</string>
<string name="account_url" translatable="false">https://mullvad.net/account</string>
<string name="download_url" translatable="false">https://mullvad.net/download/vpn/android</string>
+ <string name="market_uri" translatable="false">market://details?id=%s</string>
<string name="faqs_and_guides_url" translatable="false">https://mullvad.net/help/tag/mullvad-app/</string>
<string name="privacy_policy_url" translatable="false">https://mullvad.net/help/privacy-policy/</string>
<string name="lockdown_url" translatable="false">https://mullvad.net/l/android-lockdown</string>