diff options
| author | David Göransson <david.goransson@mullvad.net> | 2024-10-15 12:00:36 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2024-10-18 09:39:03 +0200 |
| commit | da32d827b37f276d17a7ad8f9e9ce241ae58a1c2 (patch) | |
| tree | 3bd38438480315e8237b2ce86456ca97b6f14539 /android | |
| parent | a6b2db5e8ffefc4197edf7b092bf47662f365fb9 (diff) | |
| download | mullvadvpn-da32d827b37f276d17a7ad8f9e9ce241ae58a1c2.tar.xz mullvadvpn-da32d827b37f276d17a7ad8f9e9ce241ae58a1c2.zip | |
Move changelog into new App Info screen
Diffstat (limited to 'android')
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> |
