summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAlbin <albin@mullvad.net>2023-07-13 16:38:47 +0200
committerAlbin <albin@mullvad.net>2023-07-13 16:38:47 +0200
commita38bd0127cdc8b35aa033909eb741f6d9965bfe1 (patch)
tree075ee7fdc792e5a155715d03e9027d20a6055fd3
parentd3c1731a3a9d2e1e4feed8e6a035f4835341465a (diff)
parent214a4282860dc4abfa804f29410854fee8a7ce85 (diff)
downloadmullvadvpn-a38bd0127cdc8b35aa033909eb741f6d9965bfe1.tar.xz
mullvadvpn-a38bd0127cdc8b35aa033909eb741f6d9965bfe1.zip
Merge branch 'migrate-settings-view-to-compose-droid-56'
-rw-r--r--CHANGELOG.md1
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt55
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt104
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt44
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt198
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt194
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeLeftFormatter.kt38
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.kt43
-rw-r--r--android/app/src/main/res/layout/settings.xml81
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt142
16 files changed, 638 insertions, 295 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 455dec5421..daf47344cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -63,6 +63,7 @@ Line wrap the file at 100 chars. Th
- Move the "Split tunneling" menu item up a level from "VPN settings" to "Settings".
- Migrate split tunneling view to compose.
- Migrate select Location view to compose.
+- Migrate settings view to compose.
### Fixed
- Update relay list after logging in. Previously, if the user wasn't logged in when the daemon
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
new file mode 100644
index 0000000000..5ed9ac1c8a
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt
@@ -0,0 +1,55 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import io.mockk.MockKAnnotations
+import net.mullvad.mullvadvpn.compose.state.SettingsUiState
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class SettingsScreenTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ @OptIn(ExperimentalMaterial3Api::class)
+ fun testLoggedInState() {
+ // Arrange
+ composeTestRule.setContent {
+ SettingsScreen(
+ uiState =
+ SettingsUiState(appVersion = "", isLoggedIn = true, isUpdateAvailable = true)
+ )
+ }
+ // Assert
+ composeTestRule.apply {
+ onNodeWithText("VPN settings").assertExists()
+ onNodeWithText("Split tunneling").assertExists()
+ onNodeWithText("App version").assertExists()
+ }
+ }
+
+ @Test
+ @OptIn(ExperimentalMaterial3Api::class)
+ fun testLoggedOutState() {
+ // Arrange
+ composeTestRule.setContent {
+ SettingsScreen(
+ uiState =
+ SettingsUiState(appVersion = "", isLoggedIn = false, isUpdateAvailable = true)
+ )
+ }
+ // Assert
+ composeTestRule.apply {
+ onNodeWithText("VPN settings").assertDoesNotExist()
+ onNodeWithText("Split tunneling").assertDoesNotExist()
+ onNodeWithText("App version").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 7b1ee8ee46..cd087f0b9f 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
@@ -1,58 +1,134 @@
package net.mullvad.mullvadvpn.compose.cell
import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
+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.Color
-import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.sp
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.theme.Dimens
@Preview
@Composable
private fun PreviewNavigationCell() {
- NavigationComposeCell(title = "Navigation sample", onClick = {})
+ NavigationComposeCell(
+ title = "Navigation sample",
+ bodyView = {
+ NavigationCellBody(
+ contentBodyDescription = "",
+ content = "content body",
+ contentColor = MaterialTheme.colorScheme.error,
+ )
+ },
+ onClick = {},
+ showWarning = true
+ )
+}
+
+@Preview
+@Composable
+private fun PreviewExternalLinkComposeCell() {
+ NavigationComposeCell(
+ title = "External link sample",
+ bodyView = {
+ NavigationCellBody(
+ contentBodyDescription = "content body",
+ content = "content body",
+ contentColor = MaterialTheme.colorScheme.onSecondary,
+ isExternalLink = true
+ )
+ },
+ onClick = {},
+ showWarning = false
+ )
}
@Composable
fun NavigationComposeCell(
title: String,
modifier: Modifier = Modifier,
+ showWarning: Boolean = false,
bodyView: @Composable () -> Unit = { DefaultNavigationView(chevronContentDescription = title) },
onClick: () -> Unit
) {
BaseCell(
onCellClicked = onClick,
- title = { NavigationTitleView(title = title, modifier = modifier) },
+ title = {
+ NavigationTitleView(title = title, modifier = modifier, showWarning = showWarning)
+ },
bodyView = { bodyView() }
)
}
@Composable
-private fun NavigationTitleView(title: String, modifier: Modifier = Modifier) {
- val textMediumSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp
+internal fun NavigationTitleView(
+ title: String,
+ modifier: Modifier = Modifier,
+ showWarning: Boolean = false
+) {
+ if (showWarning) {
+ Image(
+ painter = painterResource(id = R.drawable.icon_alert),
+ modifier = Modifier.padding(end = Dimens.smallPadding),
+ contentDescription = stringResource(id = R.string.update_available)
+ )
+ }
Text(
text = title,
- textAlign = TextAlign.Center,
- fontWeight = FontWeight.Bold,
- fontSize = textMediumSize,
- color = Color.White,
- modifier = modifier.wrapContentWidth(align = Alignment.End).wrapContentHeight()
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onPrimary
)
}
@Composable
-private fun DefaultNavigationView(chevronContentDescription: String) {
+internal fun DefaultNavigationView(chevronContentDescription: String) {
Image(
painter = painterResource(id = R.drawable.icon_chevron),
contentDescription = chevronContentDescription
)
}
+
+@Composable
+internal fun DefaultExternalLinkView(chevronContentDescription: String) {
+ Image(
+ painter = painterResource(id = R.drawable.icon_extlink),
+ contentDescription = chevronContentDescription
+ )
+}
+
+@Composable
+internal fun NavigationCellBody(
+ content: String,
+ contentBodyDescription: String,
+ modifier: Modifier = Modifier,
+ contentColor: Color = MaterialTheme.colorScheme.onSecondary,
+ isExternalLink: Boolean = false
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.wrapContentWidth().wrapContentHeight()
+ ) {
+ Text(
+ text = content.uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ color = contentColor
+ )
+ Spacer(modifier = Modifier.width(Dimens.sideMargin))
+ if (isExternalLink) {
+ DefaultExternalLinkView(content)
+ } else {
+ DefaultNavigationView(chevronContentDescription = contentBodyDescription)
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt
index f6679f2c58..ef1fdbf54d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt
@@ -16,6 +16,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
@@ -50,8 +51,9 @@ fun CollapsingTopBar(
title: String,
progress: Float,
backTitle: String,
+ modifier: Modifier,
backIcon: Int? = null,
- modifier: Modifier
+ shouldRotateBackButtonDown: Boolean = false
) {
val expandedToolbarHeight = dimensionResource(id = R.dimen.expanded_toolbar_height)
val iconSize = dimensionResource(id = R.dimen.icon_size)
@@ -81,7 +83,10 @@ fun CollapsingTopBar(
Image(
painter = painterResource(id = backIcon ?: R.drawable.icon_back),
contentDescription = stringResource(id = R.string.back),
- modifier = Modifier.width(iconSize).height(iconSize)
+ modifier =
+ Modifier.rotate(if (shouldRotateBackButtonDown) 270f else 0f)
+ .width(iconSize)
+ .height(iconSize)
)
Spacer(modifier = Modifier.width(iconPadding).fillMaxHeight())
Text(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt
new file mode 100644
index 0000000000..dca2a6aeb9
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt
@@ -0,0 +1,44 @@
+package net.mullvad.mullvadvpn.compose.extensions
+
+import android.content.res.Resources
+import net.mullvad.mullvadvpn.R
+import org.joda.time.DateTime
+import org.joda.time.Duration
+import org.joda.time.PeriodType
+
+fun Resources.getExpiryQuantityString(accountExpiry: DateTime): String {
+ val remainingTime = Duration(DateTime.now(), accountExpiry)
+
+ return getExpiryQuantityString(this, accountExpiry, remainingTime)
+}
+
+private fun getExpiryQuantityString(
+ resources: Resources,
+ accountExpiry: DateTime,
+ remainingTime: Duration
+): String {
+ if (remainingTime.isShorterThan(Duration.ZERO)) {
+ return resources.getString(R.string.out_of_time)
+ } else {
+ val remainingTimeInfo =
+ remainingTime.toPeriodTo(accountExpiry, PeriodType.yearMonthDayTime())
+
+ if (remainingTimeInfo.years > 0) {
+ return getRemainingText(resources, R.plurals.years_left, remainingTimeInfo.years)
+ } else if (remainingTimeInfo.months >= 3) {
+ return getRemainingText(resources, R.plurals.months_left, remainingTimeInfo.months)
+ } else if (remainingTimeInfo.months > 0 || remainingTimeInfo.days >= 1) {
+ return getRemainingText(
+ resources,
+ R.plurals.days_left,
+ remainingTime.standardDays.toInt()
+ )
+ } else {
+ return resources.getString(R.string.less_than_a_day_left)
+ }
+ }
+}
+
+private fun getRemainingText(resources: Resources, pluralId: Int, quantity: Int): String {
+ return resources.getQuantityString(pluralId, quantity, quantity)
+}
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
new file mode 100644
index 0000000000..54f3223360
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt
@@ -0,0 +1,198 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import android.net.Uri
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ExperimentalMaterial3Api
+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.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import me.onebone.toolbar.ScrollStrategy
+import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState
+import net.mullvad.mullvadvpn.BuildConfig
+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.component.CollapsableAwareToolbarScaffold
+import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar
+import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
+import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider
+import net.mullvad.mullvadvpn.compose.state.SettingsUiState
+import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_TEST_TAG
+import net.mullvad.mullvadvpn.compose.theme.Dimens
+import net.mullvad.mullvadvpn.constant.BuildTypes
+import net.mullvad.mullvadvpn.ui.extension.openLink
+import net.mullvad.mullvadvpn.util.appendHideNavOnReleaseBuild
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview
+@Composable
+private fun PreviewSettings() {
+ SettingsScreen(
+ uiState =
+ SettingsUiState(appVersion = "2222.22", isLoggedIn = true, isUpdateAvailable = true)
+ )
+}
+
+@ExperimentalMaterial3Api
+@Composable
+fun SettingsScreen(
+ uiState: SettingsUiState,
+ onVpnSettingCellClick: () -> Unit = {},
+ onSplitTunnelingCellClick: () -> Unit = {},
+ onReportProblemCellClick: () -> Unit = {},
+ onBackClick: () -> Unit = {}
+) {
+ val context = LocalContext.current
+ val lazyListState = rememberLazyListState()
+ val state = rememberCollapsingToolbarScaffoldState()
+ val progress = state.toolbarState.progress
+
+ CollapsableAwareToolbarScaffold(
+ backgroundColor = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxSize(),
+ state = state,
+ scrollStrategy = ScrollStrategy.ExitUntilCollapsed,
+ isEnabledWhenCollapsable = true,
+ toolbar = {
+ val scaffoldModifier =
+ Modifier.road(
+ whenCollapsed = Alignment.TopCenter,
+ whenExpanded = Alignment.BottomStart
+ )
+ CollapsingTopBar(
+ backgroundColor = MaterialTheme.colorScheme.secondary,
+ onBackClicked = { onBackClick() },
+ title = stringResource(id = R.string.settings),
+ progress = progress,
+ modifier = scaffoldModifier,
+ backTitle = String(),
+ shouldRotateBackButtonDown = true
+ )
+ },
+ ) {
+ LazyColumn(
+ modifier =
+ Modifier.drawVerticalScrollbar(lazyListState)
+ .testTag(LAZY_LIST_TEST_TAG)
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .animateContentSize(),
+ state = lazyListState
+ ) {
+ item { Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) }
+ if (uiState.isLoggedIn) {
+ item {
+ NavigationComposeCell(
+ title = stringResource(id = R.string.settings_vpn),
+ onClick = { onVpnSettingCellClick() }
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
+ NavigationComposeCell(
+ title = stringResource(id = R.string.split_tunneling),
+ onClick = { onSplitTunnelingCellClick() }
+ )
+ Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
+ }
+ }
+ item {
+ NavigationComposeCell(
+ title = stringResource(id = R.string.app_version),
+ onClick = {
+ context.openLink(
+ Uri.parse(
+ context.resources
+ .getString(R.string.download_url)
+ .appendHideNavOnReleaseBuild()
+ )
+ )
+ },
+ bodyView =
+ @Composable {
+ NavigationCellBody(
+ content = uiState.appVersion,
+ contentBodyDescription = stringResource(id = R.string.app_version),
+ isExternalLink = true,
+ )
+ },
+ showWarning = uiState.isUpdateAvailable,
+ )
+ }
+ if (uiState.isUpdateAvailable) {
+ item {
+ Text(
+ text = stringResource(id = R.string.update_available_footer),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondary,
+ modifier =
+ Modifier.background(MaterialTheme.colorScheme.secondary)
+ .padding(
+ start = Dimens.cellStartPadding,
+ top = Dimens.cellTopPadding,
+ end = Dimens.cellStartPadding,
+ bottom = Dimens.cellLabelVerticalPadding,
+ )
+ )
+ }
+ }
+
+ itemWithDivider {
+ Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
+ NavigationComposeCell(
+ title = stringResource(id = R.string.report_a_problem),
+ onClick = { onReportProblemCellClick() }
+ )
+ }
+
+ if (BuildConfig.BUILD_TYPE != BuildTypes.RELEASE) {
+ itemWithDivider {
+ val faqGuideLabel = stringResource(id = R.string.faqs_and_guides)
+ NavigationComposeCell(
+ title = faqGuideLabel,
+ bodyView = @Composable { DefaultExternalLinkView(faqGuideLabel) },
+ onClick = {
+ context.openLink(
+ Uri.parse(context.resources.getString(R.string.faqs_and_guides_url))
+ )
+ }
+ )
+ }
+ }
+
+ itemWithDivider {
+ val privacyPolicyLabel = stringResource(id = R.string.privacy_policy_label)
+ NavigationComposeCell(
+ title = privacyPolicyLabel,
+ bodyView = @Composable { DefaultExternalLinkView(privacyPolicyLabel) },
+ onClick = {
+ context.openLink(
+ Uri.parse(
+ context.resources
+ .getString(R.string.privacy_policy_url)
+ .appendHideNavOnReleaseBuild()
+ )
+ )
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt
new file mode 100644
index 0000000000..06bd0749eb
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SettingsUiState.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.compose.state
+
+data class SettingsUiState(
+ val appVersion: String,
+ val isLoggedIn: Boolean,
+ val isUpdateAvailable: Boolean
+)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
index 0d038fe10e..8c949531ff 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
@@ -10,6 +10,8 @@ data class Dimensions(
val cellHeight: Dp = 52.dp,
val cellLabelVerticalPadding: Dp = 14.dp,
val cellStartPadding: Dp = 22.dp,
+ val cellTopPadding: Dp = 6.dp,
+ val cellVerticalSpacing: Dp = 14.dp,
val cityRowPadding: Dp = 34.dp,
val countryRowPadding: Dp = 18.dp,
val customPortBoxMinWidth: Dp = 80.dp,
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 2244438ef0..0e10278ac3 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
@@ -28,6 +28,7 @@ import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel
import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel
import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
+import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel
import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel
import org.apache.commons.validator.routines.InetAddressValidator
@@ -87,6 +88,7 @@ val uiModule = module {
viewModel { PrivacyDisclaimerViewModel(get()) }
viewModel { VpnSettingsViewModel(get(), get(), get(), get()) }
viewModel { SelectLocationViewModel(get()) }
+ viewModel { SettingsViewModel(get(), get()) }
}
const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt
index ecaa995e4a..2ebff31c5e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt
@@ -48,3 +48,8 @@ fun Context.resolveAlwaysOnVpnPackageName(): String? {
null
}
}
+
+fun Context.openLink(uri: Uri) {
+ val intent = Intent(Intent.ACTION_VIEW, uri)
+ startActivity(intent)
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt
index 1aefb900fb..7758986702 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt
@@ -4,182 +4,66 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.widget.ImageButton
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.debounce
-import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.launch
-import net.mullvad.mullvadvpn.BuildConfig
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.ComposeView
+import androidx.fragment.app.Fragment
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.constant.BuildTypes
-import net.mullvad.mullvadvpn.model.DeviceState
-import net.mullvad.mullvadvpn.repository.DeviceRepository
-import net.mullvad.mullvadvpn.ui.CollapsibleTitleController
+import net.mullvad.mullvadvpn.compose.screen.SettingsScreen
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
import net.mullvad.mullvadvpn.ui.NavigationBarPainter
import net.mullvad.mullvadvpn.ui.StatusBarPainter
-import net.mullvad.mullvadvpn.ui.VersionInfo
-import net.mullvad.mullvadvpn.ui.paintNavigationBar
-import net.mullvad.mullvadvpn.ui.paintStatusBar
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
-import net.mullvad.mullvadvpn.ui.serviceconnection.appVersionInfoCache
-import net.mullvad.mullvadvpn.ui.widget.AppVersionCell
-import net.mullvad.mullvadvpn.ui.widget.NavigateCell
-import net.mullvad.mullvadvpn.ui.widget.UrlCell
-import net.mullvad.mullvadvpn.util.JobTracker
-import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS
-import net.mullvad.mullvadvpn.util.addDebounceForUnknownState
-import net.mullvad.mullvadvpn.util.appVersionCallbackFlow
-import org.koin.android.ext.android.inject
+import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
class SettingsFragment : BaseFragment(), StatusBarPainter, NavigationBarPainter {
+ private val vm by viewModel<SettingsViewModel>()
- // Injected dependencies
- private val deviceRepository: DeviceRepository by inject()
- private val serviceConnectionManager: ServiceConnectionManager by inject()
-
- private lateinit var appVersionMenu: AppVersionCell
- private lateinit var vpnSettingsMenu: View
- private lateinit var splitTunnelingMenu: View
- private lateinit var titleController: CollapsibleTitleController
-
- @Deprecated("Refactor code to instead rely on Lifecycle.") private val jobTracker = JobTracker()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- lifecycleScope.launchUiSubscriptionsOnResume()
- }
-
+ @OptIn(ExperimentalMaterial3Api::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View {
- val view = inflater.inflate(R.layout.settings, container, false)
-
- view.findViewById<ImageButton>(R.id.close).setOnClickListener { activity?.onBackPressed() }
-
- vpnSettingsMenu =
- view.findViewById<NavigateCell>(R.id.vpn_settings).apply {
- targetFragment = VpnSettingsFragment::class
- }
-
- splitTunnelingMenu =
- view.findViewById<NavigateCell>(R.id.split_tunneling).apply {
- targetFragment = SplitTunnelingFragment::class
- }
-
- view.findViewById<NavigateCell>(R.id.report_a_problem).apply {
- targetFragment = ProblemReportFragment::class
- }
-
- appVersionMenu = view.findViewById<AppVersionCell>(R.id.app_version)
-
- titleController = CollapsibleTitleController(view)
-
- view.findViewById<UrlCell>(R.id.faqs_and_guides).visibility =
- if (BuildTypes.RELEASE == BuildConfig.BUILD_TYPE) {
- View.GONE
- } else {
- View.VISIBLE
- }
-
- return view
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- initializeUiState()
- }
-
- override fun onResume() {
- super.onResume()
- paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
- }
-
- override fun onStop() {
- jobTracker.cancelAllJobs()
- super.onStop()
- }
-
- override fun onDestroyView() {
- titleController.onDestroy()
- super.onDestroyView()
- }
-
- private fun initializeUiState() {
- updateLoggedInStatus(deviceRepository.deviceState.value is DeviceState.LoggedIn)
- appVersionMenu.version = BuildConfig.VERSION_NAME
- serviceConnectionManager.appVersionInfoCache().let { cache ->
- updateVersionInfo(
- if (cache != null) {
- VersionInfo(
- currentVersion = cache.version,
- upgradeVersion = cache.upgradeVersion,
- isOutdated = cache.isOutdated,
- isSupported = cache.isSupported
- )
- } else {
- VersionInfo(
- currentVersion = null,
- upgradeVersion = null,
- isOutdated = false,
- isSupported = true
+ ): View? {
+ return inflater.inflate(R.layout.fragment_compose, container, false).apply {
+ findViewById<ComposeView>(R.id.compose_view).setContent {
+ AppTheme {
+ val state = vm.uiState.collectAsState().value
+ SettingsScreen(
+ uiState = state,
+ onVpnSettingCellClick = { openVpnSettingsFragment() },
+ onSplitTunnelingCellClick = { openSplitTunnelingFragment() },
+ onReportProblemCellClick = { openReportProblemFragment() },
+ onBackClick = { activity?.onBackPressed() }
)
}
- )
- }
- }
-
- private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch {
- repeatOnLifecycle(Lifecycle.State.RESUMED) {
- launchPaintStatusBarAfterTransition()
- luanchConfigureMenuOnDeviceChanges()
- launchVersionInfoSubscription()
+ }
}
}
- private fun CoroutineScope.launchPaintStatusBarAfterTransition() = launch {
- transitionFinishedFlow.collect {
- paintStatusBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
+ private fun openFragment(fragment: Fragment) {
+ parentFragmentManager.beginTransaction().apply {
+ setCustomAnimations(
+ R.anim.fragment_enter_from_right,
+ R.anim.fragment_exit_to_left,
+ R.anim.fragment_half_enter_from_left,
+ R.anim.fragment_exit_to_right
+ )
+ replace(R.id.main_fragment, fragment)
+ addToBackStack(null)
+ commitAllowingStateLoss()
}
}
- private fun CoroutineScope.luanchConfigureMenuOnDeviceChanges() = launch {
- deviceRepository.deviceState
- .debounce { it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) }
- .collect { device -> updateLoggedInStatus(device is DeviceState.LoggedIn) }
- }
-
- private fun CoroutineScope.launchVersionInfoSubscription() = launch {
- serviceConnectionManager.connectionState
- .flatMapLatest { state ->
- if (state is ServiceConnectionState.ConnectedReady) {
- state.container.appVersionInfoCache.appVersionCallbackFlow()
- } else {
- emptyFlow()
- }
- }
- .collect { versionInfo -> updateVersionInfo(versionInfo) }
+ private fun openVpnSettingsFragment() {
+ openFragment(VpnSettingsFragment())
}
- private fun updateLoggedInStatus(loggedIn: Boolean) {
- val visibility =
- if (loggedIn) {
- View.VISIBLE
- } else {
- View.GONE
- }
-
- vpnSettingsMenu.visibility = visibility
- splitTunnelingMenu.visibility = visibility
+ private fun openSplitTunnelingFragment() {
+ openFragment(SplitTunnelingFragment())
}
- private fun updateVersionInfo(versionInfo: VersionInfo) {
- appVersionMenu.updateAvailable = versionInfo.isOutdated || !versionInfo.isSupported
+ private fun openReportProblemFragment() {
+ openFragment(ProblemReportFragment())
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt
index 38c8f8ed90..2e674ebc45 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt
@@ -2,14 +2,12 @@ package net.mullvad.mullvadvpn.ui.notification
import android.content.Context
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.util.TimeLeftFormatter
+import net.mullvad.mullvadvpn.compose.extensions.getExpiryQuantityString
import org.joda.time.DateTime
class AccountExpiryNotification(
- context: Context,
+ val context: Context,
) : InAppNotification() {
- private val timeLeftFormatter = TimeLeftFormatter(context.resources)
-
init {
status = StatusLevel.Error
title = context.getString(R.string.account_credit_expires_soon)
@@ -19,7 +17,7 @@ class AccountExpiryNotification(
val threeDaysFromNow = DateTime.now().plusDays(3)
if (expiry != null && expiry.isBefore(threeDaysFromNow)) {
- message = timeLeftFormatter.format(expiry)
+ message = context.resources.getExpiryQuantityString(expiry)
shouldShow = true
} else {
shouldShow = false
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeLeftFormatter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeLeftFormatter.kt
deleted file mode 100644
index c3a6aaa1cb..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeLeftFormatter.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package net.mullvad.mullvadvpn.util
-
-import android.content.res.Resources
-import net.mullvad.mullvadvpn.R
-import org.joda.time.DateTime
-import org.joda.time.Duration
-import org.joda.time.PeriodType
-
-class TimeLeftFormatter(val resources: Resources) {
- fun format(accountExpiry: DateTime): String {
- val remainingTime = Duration(DateTime.now(), accountExpiry)
-
- return format(accountExpiry, remainingTime)
- }
-
- fun format(accountExpiry: DateTime, remainingTime: Duration): String {
- if (remainingTime.isShorterThan(Duration.ZERO)) {
- return resources.getString(R.string.out_of_time)
- } else {
- val remainingTimeInfo =
- remainingTime.toPeriodTo(accountExpiry, PeriodType.yearMonthDayTime())
-
- if (remainingTimeInfo.years > 0) {
- return getRemainingText(R.plurals.years_left, remainingTimeInfo.years)
- } else if (remainingTimeInfo.months >= 3) {
- return getRemainingText(R.plurals.months_left, remainingTimeInfo.months)
- } else if (remainingTimeInfo.months > 0 || remainingTimeInfo.days >= 1) {
- return getRemainingText(R.plurals.days_left, remainingTime.standardDays.toInt())
- } else {
- return resources.getString(R.string.less_than_a_day_left)
- }
- }
- }
-
- private fun getRemainingText(pluralId: Int, quantity: Int): String {
- return resources.getQuantityString(pluralId, quantity, quantity)
- }
-}
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
new file mode 100644
index 0000000000..8ef85cfca8
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModel.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.stateIn
+import net.mullvad.mullvadvpn.compose.state.SettingsUiState
+import net.mullvad.mullvadvpn.model.DeviceState
+import net.mullvad.mullvadvpn.repository.DeviceRepository
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+
+class SettingsViewModel(
+ deviceRepository: DeviceRepository,
+ serviceConnectionManager: ServiceConnectionManager
+) : ViewModel() {
+
+ private val vmState: StateFlow<SettingsUiState> =
+ combine(deviceRepository.deviceState, serviceConnectionManager.connectionState) {
+ deviceState,
+ versionInfo ->
+ val cachedVersionInfo = versionInfo.readyContainer()?.appVersionInfoCache
+ SettingsUiState(
+ isLoggedIn = deviceState is DeviceState.LoggedIn,
+ appVersion = cachedVersionInfo?.version ?: "",
+ isUpdateAvailable =
+ cachedVersionInfo?.let { it.isSupported.not() || it.isOutdated } ?: false
+ )
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ SettingsUiState(appVersion = "", isLoggedIn = false, isUpdateAvailable = false)
+ )
+
+ val uiState =
+ vmState.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ SettingsUiState(appVersion = "", isLoggedIn = false, isUpdateAvailable = false)
+ )
+}
diff --git a/android/app/src/main/res/layout/settings.xml b/android/app/src/main/res/layout/settings.xml
deleted file mode 100644
index fe4d75ba99..0000000000
--- a/android/app/src/main/res/layout/settings.xml
+++ /dev/null
@@ -1,81 +0,0 @@
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:mullvad="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="@color/darkBlue"
- android:gravity="left">
- <TextView android:id="@+id/title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/settings"
- style="@style/SettingsCollapsedHeader" />
- <LinearLayout android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical">
- <FrameLayout android:layout_width="match_parent"
- android:layout_height="wrap_content">
- <ImageButton android:id="@+id/close"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:padding="12dp"
- android:background="?android:attr/selectableItemBackground"
- android:src="@drawable/icon_close" />
- <TextView android:id="@+id/collapsed_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="4dp"
- android:layout_gravity="center"
- android:text="@string/settings"
- style="@style/SettingsCollapsedHeader" />
- </FrameLayout>
- <net.mullvad.mullvadvpn.ui.widget.ListenableScrollView android:id="@+id/scroll_area"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <LinearLayout android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical">
- <TextView android:id="@+id/expanded_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="4dp"
- android:layout_marginLeft="@dimen/side_margin"
- android:lines="1"
- android:text="@string/settings"
- style="@style/SettingsExpandedHeader" />
- <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/vpn_settings"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/vertical_space"
- mullvad:text="@string/settings_vpn" />
- <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/split_tunneling"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/vertical_space"
- mullvad:text="@string/split_tunneling" />
- <net.mullvad.mullvadvpn.ui.widget.AppVersionCell android:id="@+id/app_version"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/vertical_space"
- mullvad:text="@string/app_version"
- mullvad:footer="@string/update_available_footer" />
- <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/report_a_problem"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/vertical_space"
- mullvad:text="@string/report_a_problem" />
- <net.mullvad.mullvadvpn.ui.widget.UrlCell android:id="@+id/faqs_and_guides"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="1dp"
- mullvad:text="@string/faqs_and_guides"
- mullvad:url="@string/faqs_and_guides_url" />
- <net.mullvad.mullvadvpn.ui.widget.UrlCell android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="1dp"
- mullvad:text="@string/privacy_policy_label"
- mullvad:url="@string/privacy_policy_url" />
- </LinearLayout>
- </net.mullvad.mullvadvpn.ui.widget.ListenableScrollView>
- </LinearLayout>
-</FrameLayout>
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
new file mode 100644
index 0000000000..8f32ddcc8e
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt
@@ -0,0 +1,142 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.viewModelScope
+import app.cash.turbine.test
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import kotlin.test.assertEquals
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.DeviceState
+import net.mullvad.mullvadvpn.repository.DeviceRepository
+import net.mullvad.mullvadvpn.ui.VersionInfo
+import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+import net.mullvad.mullvadvpn.util.appVersionCallbackFlow
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class SettingsViewModelTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val mockDeviceRepository: DeviceRepository = mockk()
+ private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+ private lateinit var mockAppVersionInfoCache: AppVersionInfoCache
+ private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
+
+ private val serviceConnectionState =
+ MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
+ private val versionInfo =
+ MutableStateFlow(
+ VersionInfo(
+ currentVersion = null,
+ upgradeVersion = null,
+ isOutdated = false,
+ isSupported = false
+ )
+ )
+
+ private lateinit var viewModel: SettingsViewModel
+
+ @Before
+ fun setUp() {
+ mockkStatic(CACHE_EXTENSION_CLASS)
+ val deviceState = MutableStateFlow<DeviceState>(DeviceState.LoggedOut)
+ mockAppVersionInfoCache =
+ mockk<AppVersionInfoCache>().apply {
+ every { appVersionCallbackFlow() } returns versionInfo
+ }
+
+ every { mockServiceConnectionManager.connectionState } returns serviceConnectionState
+ every { mockServiceConnectionContainer.appVersionInfoCache } returns mockAppVersionInfoCache
+ every { mockDeviceRepository.deviceState } returns deviceState
+ every { mockAppVersionInfoCache.onUpdate = any() } answers {}
+
+ viewModel =
+ SettingsViewModel(
+ deviceRepository = mockDeviceRepository,
+ serviceConnectionManager = mockServiceConnectionManager
+ )
+ }
+
+ @After
+ fun tearDown() {
+ viewModel.viewModelScope.coroutineContext.cancel()
+ unmockkAll()
+ }
+
+ @Test
+ fun test_device_state_default_state() = runTest {
+ // Act, Assert
+ viewModel.uiState.test { assertEquals(false, awaitItem().isLoggedIn) }
+ }
+
+ @Test
+ fun test_device_state_supported_version_state() = runTest {
+ // Arrange
+ val versionInfoTestItem =
+ VersionInfo(
+ currentVersion = "1.0",
+ upgradeVersion = "1.0",
+ isOutdated = false,
+ isSupported = true
+ )
+
+ // Act, Assert
+ viewModel.uiState.test {
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ versionInfo.value = versionInfoTestItem
+ val result = awaitItem()
+ assertEquals(false, result.isUpdateAvailable)
+ }
+ }
+
+ @Test
+ fun test_device_state_unsupported_version_state() = runTest {
+ // Arrange
+ every { mockAppVersionInfoCache.isSupported } returns false
+ every { mockAppVersionInfoCache.isOutdated } returns false
+ every { mockAppVersionInfoCache.version } returns ""
+
+ // Act, Assert
+ viewModel.uiState.test {
+ awaitItem()
+
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ val result = awaitItem()
+ assertEquals(true, result.isUpdateAvailable)
+ }
+ }
+
+ @Test
+ fun test_device_state_outdated_version_state() = runTest {
+ // Arrange
+ every { mockAppVersionInfoCache.isSupported } returns true
+ every { mockAppVersionInfoCache.isOutdated } returns true
+ every { mockAppVersionInfoCache.version } returns ""
+
+ // Act, Assert
+ viewModel.uiState.test {
+ awaitItem()
+
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ val result = awaitItem()
+ assertEquals(true, result.isUpdateAvailable)
+ }
+ }
+
+ companion object {
+ private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt"
+ }
+}