diff options
| author | Albin <albin@mullvad.net> | 2023-07-13 16:38:47 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2023-07-13 16:38:47 +0200 |
| commit | a38bd0127cdc8b35aa033909eb741f6d9965bfe1 (patch) | |
| tree | 075ee7fdc792e5a155715d03e9027d20a6055fd3 | |
| parent | d3c1731a3a9d2e1e4feed8e6a035f4835341465a (diff) | |
| parent | 214a4282860dc4abfa804f29410854fee8a7ce85 (diff) | |
| download | mullvadvpn-a38bd0127cdc8b35aa033909eb741f6d9965bfe1.tar.xz mullvadvpn-a38bd0127cdc8b35aa033909eb741f6d9965bfe1.zip | |
Merge branch 'migrate-settings-view-to-compose-droid-56'
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" + } +} |
