summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2024-10-15 12:00:36 +0200
committerDavid Göransson <david.goransson@mullvad.net>2024-10-18 09:39:03 +0200
commitda32d827b37f276d17a7ad8f9e9ce241ae58a1c2 (patch)
tree3bd38438480315e8237b2ce86456ca97b6f14539 /android
parenta6b2db5e8ffefc4197edf7b092bf47662f365fb9 (diff)
downloadmullvadvpn-da32d827b37f276d17a7ad8f9e9ce241ae58a1c2.tar.xz
mullvadvpn-da32d827b37f276d17a7ad8f9e9ce241ae58a1c2.zip
Move changelog into new App Info screen
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt38
-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/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/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.kt6
-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.kt43
-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/SettingsViewModel.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModelTest.kt78
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml3
18 files changed, 352 insertions, 178 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..29341b2876 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
@@ -1,58 +1,24 @@
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 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 org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
@OptIn(ExperimentalTestApi::class)
class ChangelogDialogTest {
@JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension()
- @MockK lateinit var mockedViewModel: ChangelogViewModel
+ @MockK lateinit var mockedViewModel: AppInfoViewModel
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
}
- @Test
- fun testShowChangeLogWhenNeeded() =
- composeExtension.use {
- // Arrange
- // Arrange
- every { mockedViewModel.markChangelogAsRead() } just Runs
-
- setContentWithTheme {
- ChangelogDialog(
- Changelog(changes = listOf(CHANGELOG_ITEM), version = CHANGELOG_VERSION),
- onDismiss = { mockedViewModel.markChangelogAsRead() },
- )
- }
-
- // 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()
-
- // Assert
- verify { mockedViewModel.markChangelogAsRead() }
- }
-
companion object {
private const val CHANGELOG_BUTTON_TEXT = "Got it!"
private const val CHANGELOG_ITEM = "Changelog item"
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/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..f871aa6e7b
--- /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 android.content.Context
+import android.net.Uri
+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.material.icons.filled.OpenInNew
+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.LocalContext
+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.lib.common.util.openLink
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild
+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()
+
+ AppInfo(
+ state = state,
+ onBackClick = dropUnlessResumed { navigator.navigateUp() },
+ dropUnlessResumed { navigator.navigate(ChangelogDestination) },
+ )
+}
+
+@ExperimentalMaterial3Api
+@Composable
+fun AppInfo(state: AppInfoUiState, onBackClick: () -> Unit, navigateToChangelog: () -> Unit) {
+
+ ScaffoldWithMediumTopBar(
+ appBarTitle = stringResource(id = R.string.app_info),
+ navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
+ ) { modifier ->
+ Column(horizontalAlignment = Alignment.Start, modifier = modifier.animateContentSize()) {
+ AppInfoContent(state, navigateToChangelog)
+ }
+ }
+}
+
+@Composable
+fun AppInfoContent(state: AppInfoUiState, navigateToChangelog: () -> Unit) {
+ Column(modifier = Modifier.padding(bottom = Dimens.smallPadding).animateContentSize()) {
+ AppVersionRow(LocalContext.current, state)
+
+ ChangelogRow(navigateToChangelog)
+ }
+}
+
+@Composable
+private fun AppVersionRow(context: Context, state: AppInfoUiState) {
+ 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 = {
+ if (!state.isPlayBuild) {
+ Icon(
+ Icons.AutoMirrored.Default.OpenInNew,
+ contentDescription = stringResource(R.string.app_info),
+ tint = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+ },
+ onCellClicked =
+ if (state.isPlayBuild) null
+ else {
+ {
+ context.openLink(
+ Uri.parse(
+ context.resources
+ .getString(R.string.download_url)
+ .appendHideNavOnPlayBuild(state.isPlayBuild)
+ )
+ )
+ }
+ },
+ )
+
+ 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/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..cc06c417fc 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,8 @@ 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(), IS_PLAY_BUILD) }
viewModel {
ConnectViewModel(
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..83e334a719 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,10 @@
package net.mullvad.mullvadvpn.usecase
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
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 +25,9 @@ class AccountExpiryInAppNotificationUseCase(private val accountRepository: Accou
)
.map { expiresInPeriod -> InAppNotification.AccountExpiry(expiresInPeriod) }
} else {
- flowOf<InAppNotification?>(null)
+ emptyFlow<InAppNotification.AccountExpiry?>()
}
}
+ .onStart { emit(null) }
.map(::listOfNotNull)
}
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..6e78fb2f10
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt
@@ -0,0 +1,43 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.stateIn
+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,
+ isPlayBuild: Boolean,
+) : ViewModel() {
+
+ 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,
+ ),
+ )
+}
+
+data class AppInfoUiState(
+ val version: VersionInfo,
+ val changes: List<String>,
+ val isPlayBuild: Boolean,
+)
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/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/viewmodel/AppInfoViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModelTest.kt
new file mode 100644
index 0000000000..1d370b75a8
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModelTest.kt
@@ -0,0 +1,78 @@
+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 AppInfoViewModelTest {
+
+ @MockK private lateinit var mockedChangelogRepository: ChangelogRepository
+
+ private lateinit var viewModel: AppInfoViewModel
+
+ 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 = AppInfoViewModel(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 = AppInfoViewModel(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 = AppInfoViewModel(mockedChangelogRepository, buildVersion, false)
+ // Given a new version with a change log we should not return it
+ viewModel.uiSideEffect.test { expectNoEvents() }
+ }
+}
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>