diff options
| author | David Göransson <david.goransson90@gmail.com> | 2023-12-14 16:40:25 +0100 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2023-12-14 16:54:21 +0100 |
| commit | 435d437f344d484270c1ce55d9f65985287bfac8 (patch) | |
| tree | a53801b0a90b04944938c1db9436cbe357208fe9 /android/app/src/main | |
| parent | f33b1f76eac937b579ef589cc047da8f3421f630 (diff) | |
| download | mullvadvpn-435d437f344d484270c1ce55d9f65985287bfac8.tar.xz mullvadvpn-435d437f344d484270c1ce55d9f65985287bfac8.zip | |
Migrate to Compose Destinations
Diffstat (limited to 'android/app/src/main')
84 files changed, 3089 insertions, 1717 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt index 8219aa9984..cd5a08edbf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt @@ -37,8 +37,8 @@ import net.mullvad.mullvadvpn.lib.theme.color.selected private fun PreviewCustomPortCell() { AppTheme { SpacedColumn(Modifier.background(MaterialTheme.colorScheme.background)) { - CustomPortCell(title = "Title", isSelected = true, port = "444") - CustomPortCell(title = "Title", isSelected = false, port = "") + CustomPortCell(title = "Title", isSelected = true, port = 444) + CustomPortCell(title = "Title", isSelected = false, port = null) } } } @@ -47,7 +47,7 @@ private fun PreviewCustomPortCell() { fun CustomPortCell( title: String, isSelected: Boolean, - port: String, + port: Int?, mainTestTag: String = "", numberTestTag: String = "", onMainCellClicked: () -> Unit = {}, @@ -100,7 +100,7 @@ fun CustomPortCell( .testTag(numberTestTag) ) { Text( - text = port.ifEmpty { stringResource(id = R.string.port) }, + text = port?.toString() ?: stringResource(id = R.string.port), color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.align(Alignment.Center) ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt index 4d6fb89834..2a0043842a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt @@ -1,12 +1,10 @@ package net.mullvad.mullvadvpn.compose.cell -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.Icon 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.painterResource @@ -54,12 +52,12 @@ fun DnsCell( } @Composable -private fun DnsTitle(address: String, modifier: Modifier = Modifier) { +private fun RowScope.DnsTitle(address: String, modifier: Modifier = Modifier) { Text( text = address, color = Color.White, style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Start, - modifier = modifier.wrapContentWidth(align = Alignment.End).wrapContentHeight() + modifier = modifier.weight(1f) ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt index 6566a9f30e..d2dcf1e863 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt @@ -47,7 +47,6 @@ fun FilterCell( Modifier.horizontalScroll(scrollState) .padding( horizontal = Dimens.searchFieldHorizontalPadding, - vertical = Dimens.selectLocationTitlePadding ) .fillMaxWidth(), ) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt index 3388eb2b85..32c9f83a33 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt @@ -10,24 +10,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.AnimatedIconButton -import net.mullvad.mullvadvpn.lib.common.util.SdkUtils import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.extension.copyToClipboard @Preview @Composable private fun PreviewCopyableObfuscationView() { - AppTheme { CopyableObfuscationView("1111222233334444", modifier = Modifier.fillMaxWidth()) } + AppTheme { CopyableObfuscationView("1111222233334444", {}, modifier = Modifier.fillMaxWidth()) } } @Composable -fun CopyableObfuscationView(content: String, modifier: Modifier = Modifier) { +fun CopyableObfuscationView( + content: String, + onCopyClicked: (String) -> Unit, + modifier: Modifier = Modifier +) { var obfuscationEnabled by remember { mutableStateOf(true) } Row(verticalAlignment = CenterVertically, modifier = modifier) { @@ -44,19 +45,7 @@ fun CopyableObfuscationView(content: String, modifier: Modifier = Modifier) { onClick = { obfuscationEnabled = !obfuscationEnabled } ) - val context = LocalContext.current - val copy = { - context.copyToClipboard( - content = content, - clipboardLabel = context.getString(R.string.mullvad_account_number) - ) - SdkUtils.showCopyToastIfNeeded( - context, - context.getString(R.string.copied_mullvad_account_number) - ) - } - - CopyAnimatedIconButton(onClick = copy) + CopyAnimatedIconButton(onClick = { onCopyClicked(content) }) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index ce8507db64..9a35df1ad3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -19,33 +19,25 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll -import com.google.accompanist.systemuicontroller.rememberSystemUiController import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar @Composable fun ScaffoldWithTopBar( topBarColor: Color, - statusBarColor: Color, - navigationBarColor: Color, modifier: Modifier = Modifier, iconTintColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked: (() -> Unit)?, onAccountClicked: (() -> Unit)?, isIconAndLogoVisible: Boolean = true, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + enabled: Boolean = true, content: @Composable (PaddingValues) -> Unit, ) { - val systemUiController = rememberSystemUiController() - LaunchedEffect(key1 = statusBarColor, key2 = navigationBarColor) { - systemUiController.setStatusBarColor(statusBarColor) - systemUiController.setNavigationBarColor(navigationBarColor) - } Scaffold( modifier = modifier, @@ -55,7 +47,8 @@ fun ScaffoldWithTopBar( iconTintColor = iconTintColor, onSettingsClicked = onSettingsClicked, onAccountClicked = onAccountClicked, - isIconAndLogoVisible = isIconAndLogoVisible + isIconAndLogoVisible = isIconAndLogoVisible, + enabled = enabled, ) }, snackbarHost = { @@ -71,8 +64,6 @@ fun ScaffoldWithTopBar( @Composable fun ScaffoldWithTopBarAndDeviceName( topBarColor: Color, - statusBarColor: Color, - navigationBarColor: Color?, modifier: Modifier = Modifier, iconTintColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked: (() -> Unit)?, @@ -83,14 +74,6 @@ fun ScaffoldWithTopBarAndDeviceName( timeLeft: Int?, content: @Composable (PaddingValues) -> Unit, ) { - val systemUiController = rememberSystemUiController() - LaunchedEffect(key1 = statusBarColor, key2 = navigationBarColor) { - systemUiController.setStatusBarColor(statusBarColor) - if (navigationBarColor != null) { - systemUiController.setNavigationBarColor(navigationBarColor) - } - } - Scaffold( modifier = modifier, topBar = { @@ -130,6 +113,7 @@ fun ScaffoldWithMediumTopBar( actions: @Composable RowScope.() -> Unit = {}, lazyListState: LazyListState = rememberLazyListState(), scrollbarColor: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, content: @Composable (modifier: Modifier, lazyListState: LazyListState) -> Unit ) { @@ -147,6 +131,12 @@ fun ScaffoldWithMediumTopBar( scrollBehavior = scrollBehavior ) }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) } + ) + }, content = { content( Modifier.fillMaxSize() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt index babd89271c..73bec5f14f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -40,6 +41,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.test.TOP_BAR_ACCOUNT_BUTTON +import net.mullvad.mullvadvpn.compose.test.TOP_BAR_SETTINGS_BUTTON import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar @@ -104,6 +107,7 @@ fun MullvadTopBar( onSettingsClicked: (() -> Unit)?, onAccountClicked: (() -> Unit)?, modifier: Modifier = Modifier, + enabled: Boolean = true, iconTintColor: Color, isIconAndLogoVisible: Boolean = true ) { @@ -149,7 +153,11 @@ fun MullvadTopBar( }, actions = { if (onAccountClicked != null) { - IconButton(onClick = onAccountClicked) { + IconButton( + modifier = Modifier.testTag(TOP_BAR_ACCOUNT_BUTTON), + enabled = enabled, + onClick = onAccountClicked + ) { Icon( painter = painterResource(R.drawable.icon_account), tint = iconTintColor, @@ -159,7 +167,11 @@ fun MullvadTopBar( } if (onSettingsClicked != null) { - IconButton(onClick = onSettingsClicked) { + IconButton( + modifier = Modifier.testTag(TOP_BAR_SETTINGS_BUTTON), + enabled = enabled, + onClick = onSettingsClicked + ) { Icon( painter = painterResource(R.drawable.icon_settings), tint = iconTintColor, @@ -274,6 +286,7 @@ fun MullvadTopBarWithDeviceName( onSettingsClicked, onAccountClicked, Modifier, + enabled = true, iconTintColor, isIconAndLogoVisible, ) 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 9ce21c6bac..8e34ecdce4 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,18 +16,38 @@ 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.navigation.NavController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.spec.DestinationStyle 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.ChangelogViewModel +import org.koin.androidx.compose.koinViewModel +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun ChangelogDialog(changesList: List<String>, version: String, onDismiss: () -> Unit) { +fun Changelog(navController: NavController, changeLog: Changelog) { + val viewModel = koinViewModel<ChangelogViewModel>() + + ChangelogDialog( + changeLog, + onDismiss = { + viewModel.markChangelogAsRead() + navController.navigateUp() + } + ) +} + +@Composable +fun ChangelogDialog(changeLog: Changelog, onDismiss: () -> Unit) { AlertDialog( onDismissRequest = onDismiss, title = { Text( - text = version, + text = changeLog.version, style = MaterialTheme.typography.headlineLarge, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() @@ -46,7 +66,7 @@ fun ChangelogDialog(changesList: List<String>, version: String, onDismiss: () -> modifier = Modifier.fillMaxWidth() ) - changesList.forEach { changeItem -> ChangeListItem(text = changeItem) } + changeLog.changes.forEach { changeItem -> ChangeListItem(text = changeItem) } } }, confirmButton = { @@ -80,7 +100,9 @@ private fun ChangeListItem(text: String) { @Preview @Composable private fun PreviewChangelogDialogWithSingleShortItem() { - AppTheme { ChangelogDialog(changesList = listOf("Item 1"), version = "1111.1", onDismiss = {}) } + AppTheme { + ChangelogDialog(Changelog(changes = listOf("Item 1"), version = "1111.1"), onDismiss = {}) + } } @Preview @@ -93,8 +115,7 @@ private fun PreviewChangelogDialogWithTwoLongItems() { AppTheme { ChangelogDialog( - changesList = listOf(longPreviewText, longPreviewText), - version = "1111.1", + Changelog(changes = listOf(longPreviewText, longPreviewText), version = "1111.1"), onDismiss = {} ) } @@ -105,20 +126,22 @@ private fun PreviewChangelogDialogWithTwoLongItems() { private fun PreviewChangelogDialogWithTenShortItems() { AppTheme { ChangelogDialog( - changesList = - listOf( - "Item 1", - "Item 2", - "Item 3", - "Item 4", - "Item 5", - "Item 6", - "Item 7", - "Item 8", - "Item 9", - "Item 10" - ), - version = "1111.1", + Changelog( + changes = + listOf( + "Item 1", + "Item 2", + "Item 3", + "Item 4", + "Item 5", + "Item 6", + "Item 7", + "Item 8", + "Item 9", + "Item 10" + ), + version = "1111.1" + ), onDismiss = {} ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ContentBlockersInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ContentBlockersInfoDialog.kt index 29a57ed331..145208ce16 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ContentBlockersInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ContentBlockersInfoDialog.kt @@ -2,11 +2,15 @@ package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.textResource +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun ContentBlockersInfoDialog(onDismiss: () -> Unit) { +fun ContentBlockersInfoDialog(navigator: DestinationsNavigator) { InfoDialog( message = buildString { @@ -20,6 +24,6 @@ fun ContentBlockersInfoDialog(onDismiss: () -> Unit) { stringResource(id = R.string.settings_changes_effect_warning_content_blocker) ) }, - onDismiss = onDismiss + onDismiss = navigator::navigateUp ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomDnsInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomDnsInfoDialog.kt index cf9233ec94..f58768d0c6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomDnsInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomDnsInfoDialog.kt @@ -3,18 +3,23 @@ package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R @Preview @Composable private fun PreviewCustomDnsInfoDialog() { - CustomDnsInfoDialog(onDismiss = {}) + CustomDnsInfoDialog(EmptyDestinationsNavigator) } +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun CustomDnsInfoDialog(onDismiss: () -> Unit) { +fun CustomDnsInfoDialog(navigator: DestinationsNavigator) { InfoDialog( message = stringResource(id = R.string.settings_changes_effect_warning_content_blocker), - onDismiss = onDismiss + onDismiss = navigator::navigateUp ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceNameInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceNameInfoDialog.kt index 39e82bc57d..0e1c315959 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceNameInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceNameInfoDialog.kt @@ -2,10 +2,14 @@ package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun DeviceNameInfoDialog(onDismiss: () -> Unit) { +fun DeviceNameInfoDialog(navigator: DestinationsNavigator) { InfoDialog( message = buildString { @@ -15,6 +19,6 @@ fun DeviceNameInfoDialog(onDismiss: () -> Unit) { appendLine() append(stringResource(id = R.string.device_name_info_third_paragraph)) }, - onDismiss = onDismiss + onDismiss = navigator::navigateUp ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt index 527fcf8738..7de79207e1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt @@ -8,32 +8,45 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.textfield.DnsTextField import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.MullvadRed -import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem -import net.mullvad.mullvadvpn.viewmodel.StagedDns +import net.mullvad.mullvadvpn.viewmodel.DnsDialogSideEffect +import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewState +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf @Preview @Composable private fun PreviewDnsDialogNew() { AppTheme { DnsDialog( - stagedDns = - StagedDns.NewDns(CustomDnsItem.default(), StagedDns.ValidationResult.Success), - isAllowLanEnabled = true, - onIpAddressChanged = {}, - onAttemptToSave = {}, - onRemove = {}, - onDismiss = {} + DnsDialogViewState( + "1.1.1.1", + DnsDialogViewState.ValidationResult.Success, + false, + false, + true + ), + {}, + {}, + {}, + {} ) } } @@ -43,17 +56,17 @@ private fun PreviewDnsDialogNew() { private fun PreviewDnsDialogEdit() { AppTheme { DnsDialog( - stagedDns = - StagedDns.EditDns( - CustomDnsItem("1.1.1.1", false), - StagedDns.ValidationResult.Success, - 0 - ), - isAllowLanEnabled = true, - onIpAddressChanged = {}, - onAttemptToSave = {}, - onRemove = {}, - onDismiss = {} + DnsDialogViewState( + "1.1.1.1", + DnsDialogViewState.ValidationResult.Success, + false, + false, + false + ), + {}, + {}, + {}, + {} ) } } @@ -63,35 +76,62 @@ private fun PreviewDnsDialogEdit() { private fun PreviewDnsDialogEditAllowLanDisabled() { AppTheme { DnsDialog( - stagedDns = - StagedDns.EditDns( - CustomDnsItem(address = "1.1.1.1", isLocal = true), - StagedDns.ValidationResult.Success, - 0 - ), - isAllowLanEnabled = false, - onIpAddressChanged = {}, - onAttemptToSave = {}, - onRemove = {}, - onDismiss = {} + DnsDialogViewState( + "192.168.1.1", + DnsDialogViewState.ValidationResult.Success, + true, + false, + true + ), + {}, + {}, + {}, + {} ) } } +@Destination(style = DestinationStyle.Dialog::class) @Composable fun DnsDialog( - stagedDns: StagedDns, - isAllowLanEnabled: Boolean, - onIpAddressChanged: (String) -> Unit, - onAttemptToSave: () -> Unit, - onRemove: () -> Unit, + resultNavigator: ResultBackNavigator<Boolean>, + index: Int?, + initialValue: String?, +) { + val viewModel = + koinViewModel<DnsDialogViewModel>(parameters = { parametersOf(initialValue, index) }) + + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + DnsDialogSideEffect.Complete -> resultNavigator.navigateBack(result = true) + } + } + } + val state by viewModel.uiState.collectAsState(null) + + DnsDialog( + state ?: return, + viewModel::onDnsInputChange, + onSaveDnsClick = viewModel::onSaveDnsClick, + onRemoveDnsClick = viewModel::onRemoveDnsClick, + onDismiss = { resultNavigator.navigateBack(false) } + ) +} + +@Composable +fun DnsDialog( + state: DnsDialogViewState, + onDnsInputChange: (String) -> Unit, + onSaveDnsClick: () -> Unit, + onRemoveDnsClick: () -> Unit, onDismiss: () -> Unit ) { AlertDialog( title = { Text( text = - if (stagedDns is StagedDns.NewDns) { + if (state.isNewEntry) { stringResource(R.string.add_dns_server_dialog_title) } else { stringResource(R.string.update_dns_server_dialog_title) @@ -103,10 +143,10 @@ fun DnsDialog( text = { Column { DnsTextField( - value = stagedDns.item.address, - isValidValue = stagedDns.isValid(), - onValueChanged = { newMtuValue -> onIpAddressChanged(newMtuValue) }, - onSubmit = { onAttemptToSave() }, + value = state.ipAddress, + isValidValue = state.isValid(), + onValueChanged = { newDnsValue -> onDnsInputChange(newDnsValue) }, + onSubmit = onSaveDnsClick, isEnabled = true, placeholderText = stringResource(R.string.custom_dns_hint), modifier = Modifier.fillMaxWidth() @@ -114,11 +154,11 @@ fun DnsDialog( val errorMessage = when { - stagedDns.validationResult is - StagedDns.ValidationResult.DuplicateAddress -> { + state.validationResult is + DnsDialogViewState.ValidationResult.DuplicateAddress -> { stringResource(R.string.duplicate_address_warning) } - stagedDns.item.isLocal && isAllowLanEnabled.not() -> { + state.isLocal && !state.isAllowLanEnabled -> { stringResource(id = R.string.confirm_local_dns) } else -> { @@ -140,15 +180,15 @@ fun DnsDialog( Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { PrimaryButton( modifier = Modifier.fillMaxWidth(), - onClick = onAttemptToSave, - isEnabled = stagedDns.isValid(), + onClick = onSaveDnsClick, + isEnabled = state.isValid(), text = stringResource(id = R.string.submit_button), ) - if (stagedDns is StagedDns.EditDns) { + if (!state.isNewEntry) { PrimaryButton( modifier = Modifier.fillMaxWidth(), - onClick = onRemove, + onClick = onRemoveDnsClick, text = stringResource(id = R.string.remove_button) ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/LocalNetworkSharingInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/LocalNetworkSharingInfoDialog.kt index 983d0c1e04..ebe46b6050 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/LocalNetworkSharingInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/LocalNetworkSharingInfoDialog.kt @@ -3,17 +3,22 @@ package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.textResource @Preview @Composable private fun PreviewLocalNetworkSharingInfoDialog() { - LocalNetworkSharingInfoDialog(onDismiss = {}) + LocalNetworkSharingInfoDialog(EmptyDestinationsNavigator) } +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun LocalNetworkSharingInfoDialog(onDismiss: () -> Unit) { +fun LocalNetworkSharingInfoDialog(navigator: DestinationsNavigator) { InfoDialog( message = stringResource(id = R.string.local_network_sharing_info), additionalInfo = @@ -21,6 +26,6 @@ fun LocalNetworkSharingInfoDialog(onDismiss: () -> Unit) { appendLine(stringResource(id = R.string.local_network_sharing_additional_info)) appendLine(textResource(id = R.string.local_network_sharing_ip_ranges)) }, - onDismiss = onDismiss + onDismiss = navigator::navigateUp ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MalwareInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MalwareInfoDialog.kt index 378e95c98e..1f627be040 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MalwareInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MalwareInfoDialog.kt @@ -3,15 +3,23 @@ package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R @Preview @Composable private fun PreviewMalwareInfoDialog() { - MalwareInfoDialog(onDismiss = {}) + MalwareInfoDialog(EmptyDestinationsNavigator) } +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun MalwareInfoDialog(onDismiss: () -> Unit) { - InfoDialog(message = stringResource(id = R.string.malware_info), onDismiss = onDismiss) +fun MalwareInfoDialog(navigator: DestinationsNavigator) { + InfoDialog( + message = stringResource(id = R.string.malware_info), + onDismiss = navigator::navigateUp + ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt index bc28169bb2..d0d8da8b57 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt @@ -8,11 +8,16 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.textfield.MtuTextField @@ -22,24 +27,45 @@ import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription import net.mullvad.mullvadvpn.util.isValidMtu +import net.mullvad.mullvadvpn.viewmodel.MtuDialogSideEffect +import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable private fun PreviewMtuDialog() { - AppTheme { - MtuDialog(mtuInitial = 1234, onSave = {}, onRestoreDefaultValue = {}, onDismiss = {}) + AppTheme { MtuDialog(mtuInitial = 1234, EmptyDestinationsNavigator) } +} + +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun MtuDialog(mtuInitial: Int?, navigator: DestinationsNavigator) { + val viewModel = koinViewModel<MtuDialogViewModel>() + + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + MtuDialogSideEffect.Complete -> navigator.navigateUp() + } + } } + MtuDialog( + mtuInitial = mtuInitial, + onSaveMtu = viewModel::onSaveClick, + onResetMtu = viewModel::onRestoreClick, + onDismiss = navigator::navigateUp + ) } @Composable fun MtuDialog( mtuInitial: Int?, - onSave: (Int) -> Unit, - onRestoreDefaultValue: () -> Unit, + onSaveMtu: (Int) -> Unit, + onResetMtu: () -> Unit, onDismiss: () -> Unit, ) { - val mtu = remember { mutableStateOf(mtuInitial?.toString() ?: "") } + val mtu = remember { mutableStateOf(mtuInitial?.toString() ?: "") } val isValidMtu = mtu.value.toIntOrNull()?.isValidMtu() == true AlertDialog( @@ -59,7 +85,7 @@ fun MtuDialog( onSubmit = { newMtuValue -> val mtuInt = newMtuValue.toIntOrNull() if (mtuInt?.isValidMtu() == true) { - onSave(mtuInt) + onSaveMtu(mtuInt) } }, isEnabled = true, @@ -91,7 +117,7 @@ fun MtuDialog( onClick = { val mtuInt = mtu.value.toIntOrNull() if (mtuInt?.isValidMtu() == true) { - onSave(mtuInt) + onSaveMtu(mtuInt) } } ) @@ -99,7 +125,7 @@ fun MtuDialog( PrimaryButton( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.reset_to_default_button), - onClick = onRestoreDefaultValue + onClick = onResetMtu ) PrimaryButton( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ObfuscationInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ObfuscationInfoDialog.kt index f54eabdbaf..cf4db26e2e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ObfuscationInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ObfuscationInfoDialog.kt @@ -3,15 +3,23 @@ package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R @Preview @Composable private fun PreviewObfuscationInfoDialog() { - ObfuscationInfoDialog(onDismiss = {}) + ObfuscationInfoDialog(EmptyDestinationsNavigator) } +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun ObfuscationInfoDialog(onDismiss: () -> Unit) { - InfoDialog(message = stringResource(id = R.string.obfuscation_info), onDismiss = onDismiss) +fun ObfuscationInfoDialog(navigator: DestinationsNavigator) { + InfoDialog( + message = stringResource(id = R.string.obfuscation_info), + onDismiss = navigator::navigateUp + ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/QuantumResistanceInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/QuantumResistanceInfoDialog.kt index 3a20e9c805..e7773ed0a3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/QuantumResistanceInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/QuantumResistanceInfoDialog.kt @@ -3,19 +3,24 @@ package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R @Preview @Composable private fun PreviewQuantumResistanceInfoDialog() { - QuantumResistanceInfoDialog(onDismiss = {}) + QuantumResistanceInfoDialog(EmptyDestinationsNavigator) } +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun QuantumResistanceInfoDialog(onDismiss: () -> Unit) { +fun QuantumResistanceInfoDialog(navigator: DestinationsNavigator) { InfoDialog( message = stringResource(id = R.string.quantum_resistant_info_first_paragaph), additionalInfo = stringResource(id = R.string.quantum_resistant_info_second_paragaph), - onDismiss = onDismiss + onDismiss = navigator::navigateUp ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt index 1c48a8a64a..15d8e9f3c7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -23,6 +24,9 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.SecureFlagPolicy +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton @@ -38,7 +42,9 @@ import net.mullvad.mullvadvpn.constant.VOUCHER_LENGTH import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel import org.joda.time.DateTimeConstants +import org.koin.androidx.compose.koinViewModel @Preview(device = Devices.TV_720p) @Composable @@ -92,6 +98,18 @@ private fun PreviewRedeemVoucherDialogSuccess() { } } +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun RedeemVoucher(resultBackNavigator: ResultBackNavigator<Boolean>) { + val vm = koinViewModel<VoucherDialogViewModel>() + RedeemVoucherDialog( + uiState = vm.uiState.collectAsState().value, + onVoucherInputChange = vm::onVoucherInputChange, + onRedeem = vm::onRedeem, + onDismiss = { resultBackNavigator.navigateBack(result = it) } + ) +} + @Composable fun RedeemVoucherDialog( uiState: VoucherDialogUiState, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt new file mode 100644 index 0000000000..859f28fea3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt @@ -0,0 +1,85 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.result.EmptyResultBackNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.NegativeButton +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.compose.component.HtmlText +import net.mullvad.mullvadvpn.compose.component.textResource +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.model.Device + +@Preview +@Composable +private fun PreviewRemoveDeviceConfirmationDialog() { + AppTheme { + RemoveDeviceConfirmationDialog( + EmptyResultBackNavigator(), + device = Device("test", "test", byteArrayOf(), "test") + ) + } +} + +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun RemoveDeviceConfirmationDialog(navigator: ResultBackNavigator<String>, device: Device) { + AlertDialog( + onDismissRequest = { navigator.navigateBack() }, + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = 0.dp).fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_alert), + contentDescription = "Remove", + modifier = Modifier.width(50.dp).height(50.dp) + ) + } + }, + text = { + val htmlFormattedDialogText = + textResource( + id = R.string.max_devices_confirm_removal_description, + device.displayName() + ) + + HtmlText(htmlFormattedString = htmlFormattedDialogText, textSize = 16.sp.value) + }, + dismissButton = { + NegativeButton( + onClick = { navigator.navigateBack(result = device.id) }, + text = stringResource(id = R.string.confirm_removal) + ) + }, + confirmButton = { + PrimaryButton( + modifier = Modifier.focusRequester(FocusRequester()), + onClick = { navigator.navigateBack() }, + text = stringResource(id = R.string.back) + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt index 1e2da9f951..f053cd74f6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt @@ -12,6 +12,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.result.EmptyResultBackNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.NegativeButton import net.mullvad.mullvadvpn.compose.button.PrimaryButton @@ -21,18 +25,14 @@ import net.mullvad.mullvadvpn.lib.theme.Dimens @Preview @Composable private fun PreviewReportProblemNoEmailDialog() { - AppTheme { - ReportProblemNoEmailDialog( - onDismiss = {}, - onConfirm = {}, - ) - } + AppTheme { ReportProblemNoEmailDialog(EmptyResultBackNavigator()) } } +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun ReportProblemNoEmailDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { +fun ReportProblemNoEmailDialog(resultBackNavigator: ResultBackNavigator<Boolean>) { AlertDialog( - onDismissRequest = { onDismiss() }, + onDismissRequest = resultBackNavigator::navigateBack, icon = { Icon( painter = painterResource(id = R.drawable.icon_alert), @@ -52,14 +52,14 @@ fun ReportProblemNoEmailDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { dismissButton = { NegativeButton( modifier = Modifier.fillMaxWidth(), - onClick = onConfirm, + onClick = { resultBackNavigator.navigateBack(result = true) }, text = stringResource(id = R.string.send_anyway) ) }, confirmButton = { PrimaryButton( modifier = Modifier.fillMaxWidth(), - onClick = { onDismiss() }, + onClick = resultBackNavigator::navigateBack, text = stringResource(id = R.string.back) ) }, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/UdpOverTcpPortInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/UdpOverTcpPortInfoDialog.kt index f814127990..1c5c4ccef6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/UdpOverTcpPortInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/UdpOverTcpPortInfoDialog.kt @@ -3,18 +3,24 @@ package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AppTheme @Preview @Composable private fun PreviewUdpOverTcpPortInfoDialog() { - UdpOverTcpPortInfoDialog(onDismiss = {}) + AppTheme { UdpOverTcpPortInfoDialog(EmptyDestinationsNavigator) } } +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun UdpOverTcpPortInfoDialog(onDismiss: () -> Unit) { +fun UdpOverTcpPortInfoDialog(navigator: DestinationsNavigator) { InfoDialog( message = stringResource(id = R.string.udp_over_tcp_port_info), - onDismiss = onDismiss + onDismiss = navigator::navigateUp ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt new file mode 100644 index 0000000000..9b2f495f4d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt @@ -0,0 +1,139 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import android.os.Parcelable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.result.EmptyResultBackNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle +import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.NegativeButton +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG +import net.mullvad.mullvadvpn.compose.textfield.CustomPortTextField +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.model.PortRange +import net.mullvad.mullvadvpn.util.asString +import net.mullvad.mullvadvpn.util.isPortInValidRanges + +@Preview +@Composable +private fun PreviewWireguardCustomPortDialog() { + AppTheme { + WireguardCustomPortDialog( + WireguardCustomPortNavArgs( + customPort = null, + allowedPortRanges = listOf(PortRange(10, 10), PortRange(40, 50)), + ), + EmptyResultBackNavigator() + ) + } +} + +@Parcelize +data class WireguardCustomPortNavArgs( + val customPort: Int?, + val allowedPortRanges: List<PortRange>, +) : Parcelable + +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun WireguardCustomPortDialog( + navArg: WireguardCustomPortNavArgs, + backNavigator: ResultBackNavigator<Int?>, +) { + WireguardCustomPortDialog( + initialPort = navArg.customPort, + allowedPortRanges = navArg.allowedPortRanges, + onSave = { port -> backNavigator.navigateBack(port) }, + onDismiss = backNavigator::navigateBack + ) +} + +@Composable +fun WireguardCustomPortDialog( + initialPort: Int?, + allowedPortRanges: List<PortRange>, + onSave: (Int?) -> Unit, + onDismiss: () -> Unit +) { + val port = remember { mutableStateOf(initialPort?.toString() ?: "") } + + AlertDialog( + title = { + Text( + text = stringResource(id = R.string.custom_port_dialog_title), + style = MaterialTheme.typography.headlineSmall + ) + }, + confirmButton = { + Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { + PrimaryButton( + text = stringResource(id = R.string.custom_port_dialog_submit), + onClick = { onSave(port.value.toInt()) }, + isEnabled = + port.value.isNotEmpty() && + allowedPortRanges.isPortInValidRanges(port.value.toIntOrNull() ?: 0) + ) + if (initialPort != null) { + NegativeButton( + text = stringResource(R.string.custom_port_dialog_remove), + onClick = { onSave(null) } + ) + } + PrimaryButton(text = stringResource(id = R.string.cancel), onClick = onDismiss) + } + }, + text = { + Column { + CustomPortTextField( + value = port.value, + onSubmit = { input -> + if ( + input.isNotEmpty() && + allowedPortRanges.isPortInValidRanges(input.toIntOrNull() ?: 0) + ) { + onSave(input.toIntOrNull()) + } + }, + onValueChanged = { input -> port.value = input }, + isValidValue = + port.value.isNotEmpty() && + allowedPortRanges.isPortInValidRanges(port.value.toIntOrNull() ?: 0), + maxCharLength = 5, + modifier = Modifier.testTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).fillMaxWidth() + ) + Spacer(modifier = Modifier.height(Dimens.smallPadding)) + Text( + text = + stringResource( + id = R.string.custom_port_dialog_valid_ranges, + allowedPortRanges.asString() + ), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaDescription), + style = MaterialTheme.typography.bodySmall + ) + } + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + onDismissRequest = onDismiss + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardPortInfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardPortInfoDialog.kt index 58ddb00e20..a3329b1248 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardPortInfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardPortInfoDialog.kt @@ -1,24 +1,45 @@ package net.mullvad.mullvadvpn.compose.dialog +import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle +import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.model.PortRange import net.mullvad.mullvadvpn.util.asString @Preview @Composable private fun PreviewWireguardPortInfoDialog() { - WireguardPortInfoDialog(portRanges = listOf(PortRange(1, 2)), onDismiss = {}) + AppTheme { + WireguardPortInfoDialog( + EmptyDestinationsNavigator, + argument = WireguardPortInfoDialogArgument(listOf(PortRange(1, 2))) + ) + } } +@Parcelize data class WireguardPortInfoDialogArgument(val portRanges: List<PortRange>) : Parcelable + +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun WireguardPortInfoDialog(portRanges: List<PortRange>, onDismiss: () -> Unit) { +fun WireguardPortInfoDialog( + navigator: DestinationsNavigator, + argument: WireguardPortInfoDialogArgument +) { InfoDialog( message = stringResource(id = R.string.wireguard_port_info_description), additionalInfo = - stringResource(id = R.string.wireguard_port_info_port_range, portRanges.asString()), - onDismiss = onDismiss + stringResource( + id = R.string.wireguard_port_info_port_range, + argument.portRanges.asString() + ), + onDismiss = navigator::navigateUp ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt index 7e94b7455e..88c305b8c0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt @@ -5,17 +5,28 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.util.getActivity +import net.mullvad.mullvadvpn.viewmodel.PaymentUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -108,11 +119,38 @@ private fun PreviewPaymentDialogPaymentAvailabilityError() { } } +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun Payment(productId: ProductId, resultBackNavigator: ResultBackNavigator<Boolean>) { + val vm = koinViewModel<PaymentViewModel>() + val uiState = vm.uiState.collectAsState().value + + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { + when (it) { + is PaymentUiSideEffect.PaymentCancelled -> + resultBackNavigator.navigateBack(result = false) + } + } + } + + val context = LocalContext.current + LaunchedEffect(Unit) { vm.startBillingPayment(productId) { context.getActivity()!! } } + + if (uiState.paymentDialogData != null) { + PaymentDialog( + paymentDialogData = uiState.paymentDialogData, + retryPurchase = { vm.startBillingPayment(it) { context.getActivity()!! } }, + onCloseDialog = { resultBackNavigator.navigateBack(result = it) } + ) + } +} + @Composable fun PaymentDialog( paymentDialogData: PaymentDialogData, - retryPurchase: (ProductId) -> Unit, - onCloseDialog: (isPaymentSuccessful: Boolean) -> Unit + retryPurchase: (ProductId) -> Unit = {}, + onCloseDialog: (isPaymentSuccessful: Boolean) -> Unit = {} ) { val clickResolver: (action: PaymentDialogAction) -> Unit = { when (it) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt index 112afeebf5..7d49a133f3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt @@ -7,6 +7,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -18,6 +21,12 @@ private fun PreviewVerificationPendingDialog() { AppTheme { VerificationPendingDialog(onClose = {}) } } +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun VerificationPendingDialog(navigator: DestinationsNavigator) { + VerificationPendingDialog(onClose = navigator::navigateUp) +} + @Composable fun VerificationPendingDialog(onClose: () -> Unit) { AlertDialog( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index fecd23406a..38404ec96b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -1,6 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen -import android.app.Activity +import android.os.Build import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -13,25 +13,32 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview -import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.ExternalButton import net.mullvad.mullvadvpn.compose.button.NegativeButton import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton @@ -41,11 +48,13 @@ import net.mullvad.mullvadvpn.compose.component.MissingPolicy import net.mullvad.mullvadvpn.compose.component.NavigateBackDownIconButton import net.mullvad.mullvadvpn.compose.component.PlayPayment import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar -import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog -import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook +import net.mullvad.mullvadvpn.compose.destinations.DeviceNameInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination +import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromBottomTransition import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct @@ -58,6 +67,7 @@ import net.mullvad.mullvadvpn.util.toExpiryDateString import net.mullvad.mullvadvpn.viewmodel.AccountUiState import net.mullvad.mullvadvpn.viewmodel.AccountViewModel import org.joda.time.DateTime +import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Preview @@ -65,12 +75,12 @@ import org.joda.time.DateTime private fun PreviewAccountScreen() { AppTheme { AccountScreen( - showSitePayment = true, uiState = AccountUiState( deviceName = "Test Name", accountNumber = "1234123412341234", accountExpiry = null, + showSitePayment = true, billingPaymentState = PaymentState.PaymentAvailable( listOf( @@ -88,70 +98,94 @@ private fun PreviewAccountScreen() { ) ), uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow() ) } } +@OptIn(ExperimentalMaterial3Api::class) +@Destination(style = SlideInFromBottomTransition::class) +@Composable +fun Account( + navigator: DestinationsNavigator, + playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean> +) { + val vm = koinViewModel<AccountViewModel>() + val state by vm.uiState.collectAsState() + + playPaymentResultRecipient.onNavResult { + when (it) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value) + } + } + + AccountScreen( + uiState = state, + uiSideEffect = vm.uiSideEffect, + onRedeemVoucherClick = { + navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true } + }, + onManageAccountClick = vm::onManageAccountClick, + onLogoutClick = vm::onLogoutClick, + navigateToLogin = { + navigator.navigate(LoginDestination(null)) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + }, + onCopyAccountNumber = vm::onCopyAccountNumber, + onBackClick = navigator::navigateUp, + navigateToDeviceInfo = { + navigator.navigate(DeviceNameInfoDialogDestination) { launchSingleTop = true } + }, + onPurchaseBillingProductClick = { productId -> + navigator.navigate(PaymentDestination(productId)) { launchSingleTop = true } + }, + navigateToVerificationPendingDialog = { + navigator.navigate(VerificationPendingDialogDestination) { launchSingleTop = true } + } + ) +} + @ExperimentalMaterial3Api @Composable fun AccountScreen( - showSitePayment: Boolean, uiState: AccountUiState, - uiSideEffect: SharedFlow<AccountViewModel.UiSideEffect>, - enterTransitionEndAction: SharedFlow<Unit>, + uiSideEffect: Flow<AccountViewModel.UiSideEffect>, + onCopyAccountNumber: (String) -> Unit = {}, onRedeemVoucherClick: () -> Unit = {}, onManageAccountClick: () -> Unit = {}, onLogoutClick: () -> Unit = {}, - onPurchaseBillingProductClick: - (productId: ProductId, activityProvider: () -> Activity) -> Unit = - { _, _ -> - }, - onClosePurchaseResultDialog: (success: Boolean) -> Unit = {}, + onPurchaseBillingProductClick: (productId: ProductId) -> Unit = { _ -> }, + navigateToLogin: () -> Unit = {}, + navigateToDeviceInfo: () -> Unit = {}, + navigateToVerificationPendingDialog: () -> Unit = {}, onBackClick: () -> Unit = {} ) { // This will enable SECURE_FLAG while this screen is visible to preview screenshot SecureScreenWhileInView() val context = LocalContext.current - val backgroundColor = MaterialTheme.colorScheme.background - val systemUiController = rememberSystemUiController() - var showDeviceNameInfoDialog by remember { mutableStateOf(false) } - var showVerificationPendingDialog by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - systemUiController.setNavigationBarColor(backgroundColor) - enterTransitionEndAction.collect { systemUiController.setStatusBarColor(backgroundColor) } - } - val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() - LaunchedEffect(Unit) { - uiSideEffect.collect { viewAction -> - if (viewAction is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) { - openAccountPage(viewAction.token) - } - } - } - - if (showDeviceNameInfoDialog) { - DeviceNameInfoDialog { showDeviceNameInfoDialog = false } - } - - if (showVerificationPendingDialog) { - VerificationPendingDialog(onClose = { showVerificationPendingDialog = false }) - } - - uiState.paymentDialogData?.let { - PaymentDialog( - paymentDialogData = uiState.paymentDialogData, - retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } }, - onCloseDialog = onClosePurchaseResultDialog - ) - } - + val clipboardManager = LocalClipboardManager.current + val snackbarHostState = remember { SnackbarHostState() } + val copyTextString = stringResource(id = R.string.copied_mullvad_account_number) LaunchedEffect(Unit) { uiSideEffect.collect { uiSideEffect -> - if (uiSideEffect is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) { - context.openAccountPageInBrowser(uiSideEffect.token) + when (uiSideEffect) { + AccountViewModel.UiSideEffect.NavigateToLogin -> navigateToLogin() + is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> + context.openAccountPageInBrowser(uiSideEffect.token) + is AccountViewModel.UiSideEffect.CopyAccountNumber -> + launch { + clipboardManager.setText(AnnotatedString(uiSideEffect.accountNumber)) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(message = copyTextString) + } + } } } } @@ -165,9 +199,9 @@ fun AccountScreen( verticalArrangement = Arrangement.spacedBy(Dimens.accountRowSpacing), modifier = modifier.animateContentSize().padding(horizontal = Dimens.sideMargin) ) { - DeviceNameRow(deviceName = uiState.deviceName ?: "") { showDeviceNameInfoDialog = true } + DeviceNameRow(deviceName = uiState.deviceName ?: "", onInfoClick = navigateToDeviceInfo) - AccountNumberRow(accountNumber = uiState.accountNumber ?: "") + AccountNumberRow(accountNumber = uiState.accountNumber ?: "", onCopyAccountNumber) PaidUntilRow(accountExpiry = uiState.accountExpiry) @@ -178,14 +212,14 @@ fun AccountScreen( PlayPayment( billingPaymentState = uiState.billingPaymentState, onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) { context as Activity } + onPurchaseBillingProductClick(productId) }, - onInfoClick = { showVerificationPendingDialog = true }, + onInfoClick = navigateToVerificationPendingDialog, modifier = Modifier.padding(bottom = Dimens.buttonSpacing) ) } - if (showSitePayment) { + if (uiState.showSitePayment) { ExternalButton( text = stringResource(id = R.string.manage_account), onClick = onManageAccountClick, @@ -230,7 +264,7 @@ private fun DeviceNameRow(deviceName: String, onInfoClick: () -> Unit) { } @Composable -private fun AccountNumberRow(accountNumber: String) { +private fun AccountNumberRow(accountNumber: String, onCopyAccountNumber: (String) -> Unit) { Column(modifier = Modifier.fillMaxWidth()) { Text( style = MaterialTheme.typography.labelMedium, @@ -238,6 +272,7 @@ private fun AccountNumberRow(accountNumber: String) { ) CopyableObfuscationView( content = accountNumber, + onCopyClicked = { onCopyAccountNumber(accountNumber) }, modifier = Modifier.heightIn(min = Dimens.accountRowMinHeight).fillMaxWidth() ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 7528b46e42..cbf1f53c3d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -1,5 +1,7 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,6 +16,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember @@ -24,11 +27,11 @@ 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 com.google.accompanist.systemuicontroller.rememberSystemUiController -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.ConnectionButton import net.mullvad.mullvadvpn.compose.button.SwitchLocationButton import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText @@ -37,6 +40,10 @@ import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicator import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.component.notificationbanner.NotificationBanner +import net.mullvad.mullvadvpn.compose.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.destinations.SelectLocationDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG @@ -44,14 +51,17 @@ import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_TEST_TAG import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import org.koin.androidx.compose.koinViewModel private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000 @@ -62,16 +72,64 @@ private fun PreviewConnectScreen() { AppTheme { ConnectScreen( uiState = state, - uiSideEffect = MutableSharedFlow<ConnectViewModel.UiSideEffect>().asSharedFlow() ) } } +@Destination(style = HomeTransition::class) +@Composable +fun Connect(navigator: DestinationsNavigator) { + val connectViewModel: ConnectViewModel = koinViewModel() + + val state = connectViewModel.uiState.collectAsState().value + + val context = LocalContext.current + LaunchedEffect(key1 = Unit) { + connectViewModel.uiSideEffect.collect { uiSideEffect -> + when (uiSideEffect) { + is ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> { + context.openAccountPageInBrowser(uiSideEffect.token) + } + is ConnectViewModel.UiSideEffect.OutOfTime -> { + navigator.navigate(OutOfTimeDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + } + ConnectScreen( + uiState = state, + onDisconnectClick = connectViewModel::onDisconnectClick, + onReconnectClick = connectViewModel::onReconnectClick, + onConnectClick = connectViewModel::onConnectClick, + onCancelClick = connectViewModel::onCancelClick, + onSwitchLocationClick = { + navigator.navigate(SelectLocationDestination) { launchSingleTop = true } + }, + onToggleTunnelInfo = connectViewModel::toggleTunnelInfoExpansion, + onUpdateVersionClick = { + val intent = + Intent( + Intent.ACTION_VIEW, + Uri.parse( + context.getString(R.string.download_url).appendHideNavOnPlayBuild() + ) + ) + .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } + context.startActivity(intent) + }, + onManageAccountClick = connectViewModel::onManageAccountClick, + onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } }, + onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, + ) +} + @Composable fun ConnectScreen( uiState: ConnectUiState, - uiSideEffect: SharedFlow<ConnectViewModel.UiSideEffect>, - drawNavigationBar: Boolean = false, onDisconnectClick: () -> Unit = {}, onReconnectClick: () -> Unit = {}, onConnectClick: () -> Unit = {}, @@ -80,33 +138,10 @@ fun ConnectScreen( onToggleTunnelInfo: () -> Unit = {}, onUpdateVersionClick: () -> Unit = {}, onManageAccountClick: () -> Unit = {}, - onOpenOutOfTimeScreen: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, onDismissNewDeviceClick: () -> Unit = {} ) { - val context = LocalContext.current - - val systemUiController = rememberSystemUiController() - val navigationBarColor = MaterialTheme.colorScheme.primary - val setSystemBarColor = { systemUiController.setNavigationBarColor(navigationBarColor) } - LaunchedEffect(drawNavigationBar) { - if (drawNavigationBar) { - setSystemBarColor() - } - } - LaunchedEffect(key1 = Unit) { - uiSideEffect.collect { uiSideEffect -> - when (uiSideEffect) { - is ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> { - context.openAccountPageInBrowser(uiSideEffect.token) - } - is ConnectViewModel.UiSideEffect.OpenOutOfTimeView -> { - onOpenOutOfTimeScreen() - } - } - } - } val scrollState = rememberScrollState() var lastConnectionActionTimestamp by remember { mutableLongStateOf(0L) } @@ -126,13 +161,6 @@ fun ConnectScreen( } else { MaterialTheme.colorScheme.error }, - statusBarColor = - if (uiState.tunnelUiState.isSecured()) { - MaterialTheme.colorScheme.inversePrimary - } else { - MaterialTheme.colorScheme.error - }, - navigationBarColor = null, iconTintColor = if (uiState.tunnelUiState.isSecured()) { MaterialTheme.colorScheme.onPrimary @@ -149,8 +177,8 @@ fun ConnectScreen( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.Start, modifier = - Modifier.padding(it) - .background(color = MaterialTheme.colorScheme.primary) + Modifier.background(color = MaterialTheme.colorScheme.primary) + .padding(it) .fillMaxHeight() .drawVerticalScrollbar( scrollState, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index 1617c1fb7a..96f2894a23 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -17,12 +17,19 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton @@ -30,7 +37,9 @@ import net.mullvad.mullvadvpn.compose.cell.BaseCell import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar -import net.mullvad.mullvadvpn.compose.dialog.DeviceRemovalDialog +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.destinations.RemoveDeviceConfirmationDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.DeviceListItemUiState import net.mullvad.mullvadvpn.compose.state.DeviceListUiState import net.mullvad.mullvadvpn.lib.common.util.parseAsDateTime @@ -42,6 +51,8 @@ import net.mullvad.mullvadvpn.lib.theme.typeface.listItemSubText import net.mullvad.mullvadvpn.lib.theme.typeface.listItemText import net.mullvad.mullvadvpn.model.Device import net.mullvad.mullvadvpn.util.formatDate +import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel +import org.koin.androidx.compose.koinViewModel @Composable @Preview @@ -63,35 +74,62 @@ private fun PreviewDeviceListScreen() { isLoading = false ) ), - isLoading = true, - stagedDevice = null + isLoading = true ) ) } } +@Destination +@Composable +fun DeviceList( + navigator: DestinationsNavigator, + accountToken: String, + confirmRemoveResultRecipient: ResultRecipient<RemoveDeviceConfirmationDialogDestination, String> +) { + val viewModel = koinViewModel<DeviceListViewModel>() + val state by viewModel.uiState.collectAsState() + + confirmRemoveResultRecipient.onNavResult { + when (it) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> { + viewModel.removeDevice(accountToken = accountToken, deviceIdToRemove = it.value) + } + } + } + + DeviceListScreen( + state = state, + onBackClick = navigator::navigateUp, + onContinueWithLogin = { + navigator.navigate(LoginDestination(accountToken)) { + launchSingleTop = true + popUpTo(LoginDestination) { inclusive = true } + } + }, + onSettingsClicked = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + navigateToRemoveDeviceConfirmationDialog = { + navigator.navigate(RemoveDeviceConfirmationDialogDestination(it)) { + launchSingleTop = true + } + } + ) +} + @Composable fun DeviceListScreen( state: DeviceListUiState, onBackClick: () -> Unit = {}, onContinueWithLogin: () -> Unit = {}, onSettingsClicked: () -> Unit = {}, - onDeviceRemovalClicked: (deviceId: String) -> Unit = {}, - onDismissDeviceRemovalDialog: () -> Unit = {}, - onConfirmDeviceRemovalDialog: () -> Unit = {} + navigateToRemoveDeviceConfirmationDialog: (device: Device) -> Unit = {} ) { - if (state.stagedDevice != null) { - DeviceRemovalDialog( - onDismiss = onDismissDeviceRemovalDialog, - onConfirmDeviceRemovalDialog, - device = state.stagedDevice - ) - } ScaffoldWithTopBar( topBarColor = MaterialTheme.colorScheme.primary, - statusBarColor = MaterialTheme.colorScheme.primary, - navigationBarColor = MaterialTheme.colorScheme.background, iconTintColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked = onSettingsClicked, onAccountClicked = null, @@ -115,7 +153,7 @@ fun DeviceListScreen( DeviceListItem( deviceUiState = deviceUiState, ) { - onDeviceRemovalClicked(deviceUiState.device.id) + navigateToRemoveDeviceConfirmationDialog(deviceUiState.device) } if (state.deviceUiItems.lastIndex != index) { Divider() @@ -244,7 +282,6 @@ private fun DeviceListButtonPanel( onContinueWithLogin: () -> Unit, onBackClick: () -> Unit ) { - Column( modifier = Modifier.padding( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt index 5ec6b9a64b..11e929c905 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt @@ -3,14 +3,15 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource @@ -21,12 +22,20 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.DeviceRevokedLoginButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -34,6 +43,24 @@ private fun PreviewDeviceRevokedScreen() { AppTheme { DeviceRevokedScreen(state = DeviceRevokedUiState.SECURED) } } +@Destination +@Composable +fun DeviceRevoked(navigator: DestinationsNavigator) { + val viewModel = koinViewModel<DeviceRevokedViewModel>() + + val state by viewModel.uiState.collectAsState() + DeviceRevokedScreen( + state = state, + onSettingsClicked = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + onGoToLoginClicked = { + navigator.navigate(LoginDestination(null)) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + ) +} + @Composable fun DeviceRevokedScreen( state: DeviceRevokedUiState, @@ -49,15 +76,12 @@ fun DeviceRevokedScreen( ScaffoldWithTopBar( topBarColor = topColor, - statusBarColor = topColor, - navigationBarColor = MaterialTheme.colorScheme.background, onSettingsClicked = onSettingsClicked, onAccountClicked = null ) { ConstraintLayout( modifier = - Modifier.fillMaxHeight() - .fillMaxWidth() + Modifier.fillMaxSize() .padding(it) .background(color = MaterialTheme.colorScheme.background) ) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt index 844360c16c..b9db62d620 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.compose.screen -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -8,37 +7,45 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.ApplyButton import net.mullvad.mullvadvpn.compose.cell.CheckboxCell import net.mullvad.mullvadvpn.compose.cell.ExpandableComposeCell import net.mullvad.mullvadvpn.compose.cell.SelectableCell import net.mullvad.mullvadvpn.compose.state.RelayFilterState +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.model.Ownership import net.mullvad.mullvadvpn.relaylist.Provider +import net.mullvad.mullvadvpn.viewmodel.FilterScreenSideEffect +import net.mullvad.mullvadvpn.viewmodel.FilterViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -53,49 +60,63 @@ private fun PreviewFilterScreen() { FilterScreen( uiState = state, onSelectedOwnership = {}, - onSelectedProviders = { _, _ -> }, + onSelectedProvider = { _, _ -> }, onAllProviderCheckChange = {}, - uiCloseAction = MutableSharedFlow() ) } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun FilterScreen(navigator: DestinationsNavigator) { + val viewModel = koinViewModel<FilterViewModel>() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + FilterScreenSideEffect.CloseScreen -> navigator.navigateUp() + } + } + } + FilterScreen( + uiState = uiState, + onBackClick = navigator::navigateUp, + onApplyClick = viewModel::onApplyButtonClicked, + onSelectedOwnership = viewModel::setSelectedOwnership, + onAllProviderCheckChange = viewModel::setAllProviders, + onSelectedProvider = viewModel::setSelectedProvider + ) +} + @Composable fun FilterScreen( uiState: RelayFilterState, onBackClick: () -> Unit = {}, - uiCloseAction: SharedFlow<Unit>, onApplyClick: () -> Unit = {}, onSelectedOwnership: (ownership: Ownership?) -> Unit = {}, onAllProviderCheckChange: (isChecked: Boolean) -> Unit = {}, - onSelectedProviders: (checked: Boolean, provider: Provider) -> Unit + onSelectedProvider: (checked: Boolean, provider: Provider) -> Unit ) { var providerExpanded by rememberSaveable { mutableStateOf(false) } var ownershipExpanded by rememberSaveable { mutableStateOf(false) } val backgroundColor = MaterialTheme.colorScheme.background - LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } } Scaffold( + modifier = Modifier.background(backgroundColor).systemBarsPadding().fillMaxSize(), topBar = { - Row( - Modifier.padding( - horizontal = Dimens.selectFilterTitlePadding, - vertical = Dimens.selectFilterTitlePadding + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(id = R.drawable.icon_back), + contentDescription = null, + tint = Color.Unspecified, ) - .fillMaxWidth(), - ) { - Image( - painter = painterResource(id = R.drawable.icon_back), - contentDescription = null, - modifier = Modifier.size(Dimens.titleIconSize).clickable(onClick = onBackClick) - ) + } Text( text = stringResource(R.string.filter), - modifier = - Modifier.align(Alignment.CenterVertically) - .weight(weight = 1f) - .padding(end = Dimens.titleIconSize), + modifier = Modifier.weight(1f).padding(end = Dimens.titleIconSize), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onPrimary @@ -124,9 +145,7 @@ fun FilterScreen( } }, ) { contentPadding -> - LazyColumn( - modifier = Modifier.padding(contentPadding).background(backgroundColor).fillMaxSize() - ) { + LazyColumn(modifier = Modifier.padding(contentPadding).fillMaxSize()) { item { Divider() ExpandableComposeCell( @@ -178,7 +197,7 @@ fun FilterScreen( CheckboxCell( providerName = provider.name, checked = provider in uiState.selectedProviders, - onCheckedChange = { checked -> onSelectedProviders(checked, provider) } + onCheckedChange = { checked -> onSelectedProvider(checked, provider) } ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt index 113ef4b020..4dac203fa8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,18 +45,25 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.DeviceListDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination +import net.mullvad.mullvadvpn.compose.destinations.WelcomeDestination import net.mullvad.mullvadvpn.compose.state.LoginError import net.mullvad.mullvadvpn.compose.state.LoginState import net.mullvad.mullvadvpn.compose.state.LoginState.Idle @@ -64,10 +73,14 @@ import net.mullvad.mullvadvpn.compose.state.LoginUiState import net.mullvad.mullvadvpn.compose.test.LOGIN_INPUT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LOGIN_TITLE_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors +import net.mullvad.mullvadvpn.compose.transitions.LoginTransition import net.mullvad.mullvadvpn.compose.util.accountTokenVisualTransformation import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar +import net.mullvad.mullvadvpn.viewmodel.LoginUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.LoginViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -101,9 +114,63 @@ private fun PreviewLoginSuccess() { AppTheme { LoginScreen(uiState = LoginUiState(loginState = Success)) } } -@OptIn(ExperimentalComposeUiApi::class) +@Destination(style = LoginTransition::class) +@Composable +fun Login( + navigator: DestinationsNavigator, + accountToken: String? = null, + vm: LoginViewModel = koinViewModel() +) { + val state by vm.uiState.collectAsState() + + // Login with argument, e.g when user comes from Too Many Devices screen + LaunchedEffect(accountToken) { + if (accountToken != null) { + vm.onAccountNumberChange(accountToken) + vm.login(accountToken) + } + } + + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { + when (it) { + LoginUiSideEffect.NavigateToWelcome -> { + navigator.navigate(WelcomeDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + is LoginUiSideEffect.NavigateToConnect -> { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + is LoginUiSideEffect.TooManyDevices -> { + navigator.navigate(DeviceListDestination(it.accountToken.value)) { + launchSingleTop = true + } + } + LoginUiSideEffect.NavigateToOutOfTime -> + navigator.navigate(OutOfTimeDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + LoginScreen( + state, + vm::login, + vm::createAccount, + vm::clearAccountHistory, + vm::onAccountNumberChange, + { navigator.navigate(SettingsDestination) } + ) +} + @Composable -fun LoginScreen( +private fun LoginScreen( uiState: LoginUiState, onLoginClick: (String) -> Unit = {}, onCreateAccountClick: () -> Unit = {}, @@ -112,13 +179,11 @@ fun LoginScreen( onSettingsClick: () -> Unit = {}, ) { ScaffoldWithTopBar( - modifier = Modifier.semantics { testTagsAsResourceId = true }, topBarColor = MaterialTheme.colorScheme.primary, - statusBarColor = MaterialTheme.colorScheme.primary, - navigationBarColor = MaterialTheme.colorScheme.background, iconTintColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked = onSettingsClick, - onAccountClicked = null + enabled = uiState.loginState is Idle, + onAccountClicked = null, ) { val scrollState = rememberScrollState() Column( 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 new file mode 100644 index 0000000000..8ef535a58b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -0,0 +1,77 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.navigation.NavHostController +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.navigation.navigate +import com.ramcosta.composedestinations.navigation.popBackStack +import com.ramcosta.composedestinations.rememberNavHostEngine +import com.ramcosta.composedestinations.utils.destination +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.compose.NavGraphs +import net.mullvad.mullvadvpn.compose.destinations.ChangelogDestination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import net.mullvad.mullvadvpn.viewmodel.DaemonScreenEvent +import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel +import org.koin.androidx.compose.koinViewModel + +private val changeLogDestinations = listOf(ConnectDestination, OutOfTimeDestination) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun MullvadApp() { + val engine = rememberNavHostEngine() + val navController: NavHostController = engine.rememberNavController() + + val serviceVm = koinViewModel<NoDaemonViewModel>() + + DisposableEffect(Unit) { + navController.addOnDestinationChangedListener(serviceVm) + onDispose { navController.removeOnDestinationChangedListener(serviceVm) } + } + + DestinationsNavHost( + modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(), + engine = engine, + navController = navController, + navGraph = NavGraphs.root + ) + + // Globally handle daemon dropped connection with NoDaemonScreen + LaunchedEffect(Unit) { + serviceVm.uiSideEffect.collect { + when (it) { + DaemonScreenEvent.Show -> + navController.navigate(NoDaemonScreenDestination) { launchSingleTop = true } + DaemonScreenEvent.Remove -> + navController.popBackStack(NoDaemonScreenDestination, true) + } + } + } + + // Globally show the changelog + val changeLogsViewModel = koinViewModel<ChangelogViewModel>() + LaunchedEffect(Unit) { + changeLogsViewModel.uiSideEffect.collect { + + // Wait until we are in an acceptable destination + navController.currentBackStackEntryFlow + .map { it.destination() } + .first { it in changeLogDestinations } + + navController.navigate(ChangelogDestination(it).route) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt new file mode 100644 index 0000000000..af47b37fc2 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/NoDaemonScreen.kt @@ -0,0 +1,104 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.compositeOver +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.app.ActivityCompat.finishAffinity +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination +import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.util.getActivity + +@Preview +@Composable +private fun PreviewNoDaemonScreen() { + AppTheme { NoDaemonScreen({}) } +} + +// Set this as the start destination of the default nav graph +@Destination(style = DefaultTransition::class) +@Composable +fun NoDaemonScreen(navigator: DestinationsNavigator) { + NoDaemonScreen { navigator.navigate(SettingsDestination) } +} + +@Composable +fun NoDaemonScreen(onNavigateToSettings: () -> Unit) { + + val backgroundColor = MaterialTheme.colorScheme.primary + + val context = LocalContext.current + BackHandler { finishAffinity(context.getActivity()!!) } + + ScaffoldWithTopBar( + topBarColor = backgroundColor, + onSettingsClicked = onNavigateToSettings, + onAccountClicked = null, + isIconAndLogoVisible = false, + content = { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier.background(backgroundColor) + .padding(it) + .padding(bottom = it.calculateTopPadding()) + .fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.launch_logo), + contentDescription = "", + modifier = Modifier.size(Dimens.splashLogoSize) + ) + Image( + painter = painterResource(id = R.drawable.logo_text), + contentDescription = "", + alpha = 0.6f, + modifier = + Modifier.padding(top = Dimens.mediumPadding) + .height(Dimens.splashLogoTextHeight) + ) + Text( + text = stringResource(id = R.string.connecting_to_daemon), + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme.onPrimary + .copy(alpha = AlphaDescription) + .compositeOver(backgroundColor), + modifier = + Modifier.padding(top = Dimens.mediumPadding) + .padding(horizontal = Dimens.sideMargin), + textAlign = TextAlign.Center + ) + } + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt index b7b4744bb2..d9071be7d8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.compose.screen -import android.app.Activity import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -15,31 +14,35 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.NegativeButton import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton import net.mullvad.mullvadvpn.compose.button.SitePaymentButton import net.mullvad.mullvadvpn.compose.component.PlayPayment import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog +import net.mullvad.mullvadvpn.compose.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination +import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination +import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState +import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -50,15 +53,19 @@ import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.talpid.tunnel.ActionAfterDisconnect import net.mullvad.talpid.tunnel.ErrorState import net.mullvad.talpid.tunnel.ErrorStateCause +import org.koin.androidx.compose.koinViewModel @Preview @Composable private fun PreviewOutOfTimeScreenDisconnected() { AppTheme { OutOfTimeScreen( - showSitePayment = true, - uiState = OutOfTimeUiState(tunnelState = TunnelState.Disconnected, "Heroic Frog"), - uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow() + uiState = + OutOfTimeUiState( + tunnelState = TunnelState.Disconnected, + "Heroic Frog", + showSitePayment = true + ), ) } } @@ -68,10 +75,12 @@ private fun PreviewOutOfTimeScreenDisconnected() { private fun PreviewOutOfTimeScreenConnecting() { AppTheme { OutOfTimeScreen( - showSitePayment = true, uiState = - OutOfTimeUiState(tunnelState = TunnelState.Connecting(null, null), "Strong Rabbit"), - uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow() + OutOfTimeUiState( + tunnelState = TunnelState.Connecting(null, null), + "Strong Rabbit", + showSitePayment = true + ), ) } } @@ -81,59 +90,92 @@ private fun PreviewOutOfTimeScreenConnecting() { private fun PreviewOutOfTimeScreenError() { AppTheme { OutOfTimeScreen( - showSitePayment = true, uiState = OutOfTimeUiState( tunnelState = TunnelState.Error( ErrorState(cause = ErrorStateCause.IsOffline, isBlocking = true) ), - deviceName = "Stable Horse" + deviceName = "Stable Horse", + showSitePayment = true ), - uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow() ) } } +@Destination(style = HomeTransition::class) @Composable -fun OutOfTimeScreen( - showSitePayment: Boolean, - uiState: OutOfTimeUiState, - uiSideEffect: SharedFlow<OutOfTimeViewModel.UiSideEffect>, - onDisconnectClick: () -> Unit = {}, - onSitePaymentClick: () -> Unit = {}, - onRedeemVoucherClick: () -> Unit = {}, - openConnectScreen: () -> Unit = {}, - onSettingsClick: () -> Unit = {}, - onAccountClick: () -> Unit = {}, - onPurchaseBillingProductClick: (ProductId, activityProvider: () -> Activity) -> Unit = { _, _ -> - }, - onClosePurchaseResultDialog: (success: Boolean) -> Unit = {} +fun OutOfTime( + navigator: DestinationsNavigator, + redeemVoucherResultRecipient: ResultRecipient<RedeemVoucherDestination, Boolean>, + playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean> ) { - val context = LocalContext.current + val vm = koinViewModel<OutOfTimeViewModel>() + val state = vm.uiState.collectAsState().value + redeemVoucherResultRecipient.onNavResult { + // If we successfully redeemed a voucher, navigate to Connect screen + if (it is NavResult.Value && it.value) { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + + playPaymentResultRecipient.onNavResult { + when (it) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value) + } + } + val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() - LaunchedEffect(key1 = Unit) { - uiSideEffect.collect { uiSideEffect -> + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { uiSideEffect -> when (uiSideEffect) { is OutOfTimeViewModel.UiSideEffect.OpenAccountView -> openAccountPage(uiSideEffect.token) - OutOfTimeViewModel.UiSideEffect.OpenConnectScreen -> openConnectScreen() + OutOfTimeViewModel.UiSideEffect.OpenConnectScreen -> { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } } } } - var showVerificationPendingDialog by remember { mutableStateOf(false) } - if (showVerificationPendingDialog) { - VerificationPendingDialog(onClose = { showVerificationPendingDialog = false }) - } + OutOfTimeScreen( + uiState = state, + onSitePaymentClick = vm::onSitePaymentClick, + onRedeemVoucherClick = { + navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true } + }, + onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } }, + onDisconnectClick = vm::onDisconnectClick, + onPurchaseBillingProductClick = { productId -> + navigator.navigate(PaymentDestination(productId)) { launchSingleTop = true } + }, + navigateToVerificationPendingDialog = { + navigator.navigate(VerificationPendingDialogDestination) { launchSingleTop = true } + } + ) +} - uiState.paymentDialogData?.let { - PaymentDialog( - paymentDialogData = uiState.paymentDialogData, - retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } }, - onCloseDialog = onClosePurchaseResultDialog - ) - } +@Composable +fun OutOfTimeScreen( + uiState: OutOfTimeUiState, + onDisconnectClick: () -> Unit = {}, + onSitePaymentClick: () -> Unit = {}, + onRedeemVoucherClick: () -> Unit = {}, + onSettingsClick: () -> Unit = {}, + onAccountClick: () -> Unit = {}, + onPurchaseBillingProductClick: (ProductId) -> Unit = { _ -> }, + navigateToVerificationPendingDialog: () -> Unit = {} +) { val scrollState = rememberScrollState() ScaffoldWithTopBarAndDeviceName( @@ -143,13 +185,6 @@ fun OutOfTimeScreen( } else { MaterialTheme.colorScheme.error }, - statusBarColor = - if (uiState.tunnelState.isSecured()) { - MaterialTheme.colorScheme.inversePrimary - } else { - MaterialTheme.colorScheme.error - }, - navigationBarColor = MaterialTheme.colorScheme.background, iconTintColor = if (uiState.tunnelState.isSecured()) { MaterialTheme.colorScheme.onPrimary @@ -191,7 +226,7 @@ fun OutOfTimeScreen( text = buildString { append(stringResource(R.string.account_credit_has_expired)) - if (showSitePayment) { + if (uiState.showSitePayment) { append(" ") append(stringResource(R.string.add_time_to_account)) } @@ -223,9 +258,9 @@ fun OutOfTimeScreen( PlayPayment( billingPaymentState = uiState.billingPaymentState, onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) { context as Activity } + onPurchaseBillingProductClick(productId) }, - onInfoClick = { showVerificationPendingDialog = true }, + onInfoClick = navigateToVerificationPendingDialog, modifier = Modifier.padding( start = Dimens.sideMargin, @@ -235,7 +270,7 @@ fun OutOfTimeScreen( .align(Alignment.CenterHorizontally) ) } - if (showSitePayment) { + if (uiState.showSitePayment) { SitePaymentButton( onClick = onSitePaymentClick, isEnabled = uiState.tunnelState.enableSitePaymentButton(), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt index 02250c3663..b57e66c151 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt @@ -5,8 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -16,9 +15,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -30,14 +31,24 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition import net.mullvad.mullvadvpn.compose.util.toDp import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -45,24 +56,41 @@ private fun PreviewPrivacyDisclaimerScreen() { AppTheme { PrivacyDisclaimerScreen({}, {}) } } +@Destination(style = DefaultTransition::class) +@Composable +fun PrivacyDisclaimer( + navigator: DestinationsNavigator, +) { + val viewModel: PrivacyDisclaimerViewModel = koinViewModel() + + val context = LocalContext.current + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + PrivacyDisclaimerUiSideEffect.NavigateToLogin -> { + (context as MainActivity).initializeStateHandlerAndServiceConnection() + navigator.navigate(LoginDestination(null)) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + } + PrivacyDisclaimerScreen({}, viewModel::setPrivacyDisclosureAccepted) +} + @Composable fun PrivacyDisclaimerScreen( onPrivacyPolicyLinkClicked: () -> Unit, onAcceptClicked: () -> Unit, ) { val topColor = MaterialTheme.colorScheme.primary - ScaffoldWithTopBar( - topBarColor = topColor, - statusBarColor = topColor, - navigationBarColor = MaterialTheme.colorScheme.background, - onAccountClicked = null, - onSettingsClicked = null - ) { + ScaffoldWithTopBar(topBarColor = topColor, onAccountClicked = null, onSettingsClicked = null) { ConstraintLayout( modifier = - Modifier.fillMaxHeight() - .fillMaxWidth() - .padding(it) + Modifier.padding(it) + .fillMaxSize() .background(color = MaterialTheme.colorScheme.background) ) { val (body, actionButtons) = createRefs() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt index 0621c7ebcd..4763b21997 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt @@ -14,6 +14,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,20 +28,29 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar -import net.mullvad.mullvadvpn.compose.dialog.ReportProblemNoEmailDialog +import net.mullvad.mullvadvpn.compose.destinations.ReportProblemNoEmailDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.ViewLogsDestination import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.ReportProblemSideEffect import net.mullvad.mullvadvpn.viewmodel.ReportProblemUiState +import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.SendingReportUiState +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -50,22 +62,19 @@ private fun PreviewReportProblemScreen() { @Composable private fun PreviewReportProblemSendingScreen() { AppTheme { - ReportProblemScreen(uiState = ReportProblemUiState(false, SendingReportUiState.Sending)) + ReportProblemScreen( + uiState = ReportProblemUiState(sendingState = SendingReportUiState.Sending), + ) } } @Preview @Composable -private fun PreviewReportProblemConfirmNoEmailScreen() { - AppTheme { ReportProblemScreen(uiState = ReportProblemUiState(true)) } -} - -@Preview -@Composable private fun PreviewReportProblemSuccessScreen() { AppTheme { ReportProblemScreen( - uiState = ReportProblemUiState(false, SendingReportUiState.Success("email@mail.com")) + uiState = + ReportProblemUiState(sendingState = SendingReportUiState.Success("email@mail.com")), ) } } @@ -77,37 +86,67 @@ private fun PreviewReportProblemErrorScreen() { ReportProblemScreen( uiState = ReportProblemUiState( - false, - SendingReportUiState.Error(SendProblemReportResult.Error.CollectLog) + sendingState = + SendingReportUiState.Error(SendProblemReportResult.Error.CollectLog) ) ) } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun ReportProblem( + navigator: DestinationsNavigator, + noEmailConfirmResultRecipent: ResultRecipient<ReportProblemNoEmailDialogDestination, Boolean> +) { + val vm = koinViewModel<ReportProblemViewModel>() + val uiState by vm.uiState.collectAsState() + + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { + when (it) { + is ReportProblemSideEffect.ShowConfirmNoEmail -> { + navigator.navigate(ReportProblemNoEmailDialogDestination) + } + } + } + } + + noEmailConfirmResultRecipent.onNavResult { + when (it) { + NavResult.Canceled -> {} + is NavResult.Value -> vm.sendReport(uiState.email, uiState.description, true) + } + } + + ReportProblemScreen( + uiState, + onSendReport = { vm.sendReport(uiState.email, uiState.description) }, + onClearSendResult = vm::clearSendResult, + onNavigateToViewLogs = { + navigator.navigate(ViewLogsDestination()) { launchSingleTop = true } + }, + onEmailChanged = vm::updateEmail, + onDescriptionChanged = vm::updateDescription, + onBackClick = navigator::navigateUp, + ) +} + @Composable -fun ReportProblemScreen( +private fun ReportProblemScreen( uiState: ReportProblemUiState, - onSendReport: (String, String) -> Unit = { _, _ -> }, - onDismissNoEmailDialog: () -> Unit = {}, + onSendReport: () -> Unit = {}, onClearSendResult: () -> Unit = {}, onNavigateToViewLogs: () -> Unit = {}, - updateEmail: (String) -> Unit = {}, - updateDescription: (String) -> Unit = {}, + onEmailChanged: (String) -> Unit = {}, + onDescriptionChanged: (String) -> Unit = {}, onBackClick: () -> Unit = {} ) { - // Dialog to show confirm if no email was added - if (uiState.showConfirmNoEmail) { - ReportProblemNoEmailDialog( - onDismiss = onDismissNoEmailDialog, - onConfirm = { onSendReport(uiState.email, uiState.description) } - ) - } ScaffoldWithMediumTopBar( appBarTitle = stringResource(id = R.string.report_a_problem), navigationIcon = { NavigateBackIconButton(onBackClick) } ) { modifier -> - // Show sending states if (uiState.sendingState != null) { Column( @@ -119,11 +158,7 @@ fun ReportProblemScreen( ) { when (uiState.sendingState) { SendingReportUiState.Sending -> SendingContent() - is SendingReportUiState.Error -> - ErrorContent( - { onSendReport(uiState.email, uiState.description) }, - onClearSendResult - ) + is SendingReportUiState.Error -> ErrorContent(onSendReport, onClearSendResult) is SendingReportUiState.Success -> SentContent(uiState.sendingState) } return@ScaffoldWithMediumTopBar @@ -146,7 +181,7 @@ fun ReportProblemScreen( TextField( modifier = Modifier.fillMaxWidth(), value = uiState.email, - onValueChange = updateEmail, + onValueChange = onEmailChanged, maxLines = 1, singleLine = true, placeholder = { Text(text = stringResource(id = R.string.user_email_hint)) }, @@ -156,7 +191,7 @@ fun ReportProblemScreen( TextField( modifier = Modifier.fillMaxWidth().weight(1f), value = uiState.description, - onValueChange = updateDescription, + onValueChange = onDescriptionChanged, placeholder = { Text(stringResource(R.string.user_message_hint)) }, colors = mullvadWhiteTextFieldColors() ) @@ -168,7 +203,7 @@ fun ReportProblemScreen( ) Spacer(modifier = Modifier.height(Dimens.buttonSpacing)) VariantButton( - onClick = { onSendReport(uiState.email, uiState.description) }, + onClick = onSendReport, isEnabled = uiState.description.isNotEmpty(), text = stringResource(id = R.string.send) ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index 5bfdee94f6..d113ca258d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -1,13 +1,10 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -18,17 +15,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -38,9 +32,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.core.text.HtmlCompat -import com.google.accompanist.systemuicontroller.rememberSystemUiController -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.FilterCell import net.mullvad.mullvadvpn.compose.cell.RelayLocationCell @@ -48,15 +41,20 @@ import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicator import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.component.textResource import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.destinations.FilterScreenDestination import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.textfield.SearchTextField +import net.mullvad.mullvadvpn.compose.transitions.SelectLocationTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.relaylist.RelayCountry import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect +import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -72,18 +70,37 @@ private fun PreviewSelectLocationScreen() { AppTheme { SelectLocationScreen( uiState = state, - uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow() ) } } -@OptIn(ExperimentalComposeUiApi::class) +@Destination(style = SelectLocationTransition::class) +@Composable +fun SelectLocation(navigator: DestinationsNavigator) { + val vm = koinViewModel<SelectLocationViewModel>() + val state = vm.uiState.collectAsState().value + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { + when (it) { + SelectLocationSideEffect.CloseScreen -> navigator.navigateUp() + } + } + } + + SelectLocationScreen( + uiState = state, + onSelectRelay = vm::selectRelay, + onSearchTermInput = vm::onSearchTermInput, + onBackClick = navigator::navigateUp, + onFilterClick = { navigator.navigate(FilterScreenDestination) }, + removeOwnershipFilter = vm::removeOwnerFilter, + removeProviderFilter = vm::removeProviderFilter + ) +} + @Composable fun SelectLocationScreen( uiState: SelectLocationUiState, - uiCloseAction: SharedFlow<Unit>, - enterTransitionEndAction: SharedFlow<Unit>, onSelectRelay: (item: RelayItem) -> Unit = {}, onSearchTermInput: (searchTerm: String) -> Unit = {}, onBackClick: () -> Unit = {}, @@ -91,143 +108,131 @@ fun SelectLocationScreen( removeOwnershipFilter: () -> Unit = {}, removeProviderFilter: () -> Unit = {} ) { - val backgroundColor = MaterialTheme.colorScheme.background - val systemUiController = rememberSystemUiController() - LaunchedEffect(Unit) { uiCloseAction.collect { onBackClick() } } - LaunchedEffect(Unit) { - enterTransitionEndAction.collect { systemUiController.setStatusBarColor(backgroundColor) } - } - - val (backFocus, listFocus, searchBarFocus) = remember { FocusRequester.createRefs() } - Column(modifier = Modifier.background(backgroundColor).fillMaxWidth().fillMaxHeight()) { - Row( - modifier = - Modifier.padding(vertical = Dimens.selectLocationTitlePadding) - .padding(end = Dimens.selectLocationTitlePadding) - .fillMaxWidth() - ) { - IconButton(onClick = onBackClick) { - Icon( - painter = painterResource(id = R.drawable.icon_back), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(Dimens.titleIconSize).rotate(270f) + Scaffold { + Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) { + Row(modifier = Modifier.fillMaxWidth()) { + IconButton(onClick = onBackClick) { + Icon( + modifier = Modifier.rotate(270f), + painter = painterResource(id = R.drawable.icon_back), + tint = Color.Unspecified, + contentDescription = null, + ) + } + Text( + text = stringResource(id = R.string.select_location), + modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimary ) - } - Text( - text = stringResource(id = R.string.select_location), - modifier = - Modifier.align(Alignment.CenterVertically) - .weight(weight = 1f) - .padding(end = Dimens.titleIconSize), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimary - ) - Image( - painter = painterResource(id = R.drawable.icons_more_circle), - contentDescription = null, - modifier = Modifier.size(Dimens.titleIconSize).clickable { onFilterClick() } - ) - } - when (uiState) { - SelectLocationUiState.Loading -> {} - is SelectLocationUiState.ShowData -> { - if (uiState.hasFilter) { - FilterCell( - ownershipFilter = uiState.selectedOwnership, - selectedProviderFilter = uiState.selectedProvidersCount, - removeOwnershipFilter = removeOwnershipFilter, - removeProviderFilter = removeProviderFilter + IconButton(onClick = onFilterClick) { + Icon( + painter = painterResource(id = R.drawable.icons_more_circle), + contentDescription = null, + tint = Color.Unspecified, ) } } - } - SearchTextField( - modifier = - Modifier.fillMaxWidth() - .focusRequester(searchBarFocus) - .focusProperties { next = backFocus } - .height(Dimens.searchFieldHeight) - .padding(horizontal = Dimens.searchFieldHorizontalPadding) - ) { searchString -> - onSearchTermInput.invoke(searchString) - } - Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) - val lazyListState = rememberLazyListState() - LazyColumn( - modifier = - Modifier.focusRequester(listFocus) - .fillMaxSize() - .drawVerticalScrollbar( - lazyListState, - MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) - ), - state = lazyListState, - horizontalAlignment = Alignment.CenterHorizontally, - ) { when (uiState) { - SelectLocationUiState.Loading -> { - item(contentType = ContentType.PROGRESS) { - MullvadCircularProgressIndicatorLarge( - Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) + SelectLocationUiState.Loading -> {} + is SelectLocationUiState.ShowData -> { + if (uiState.hasFilter) { + FilterCell( + ownershipFilter = uiState.selectedOwnership, + selectedProviderFilter = uiState.selectedProvidersCount, + removeOwnershipFilter = removeOwnershipFilter, + removeProviderFilter = removeProviderFilter ) } } - is SelectLocationUiState.ShowData -> { - if (uiState.countries.isEmpty()) { - item(contentType = ContentType.EMPTY_TEXT) { - val firstRow = - HtmlCompat.fromHtml( - textResource( - id = R.string.select_location_empty_text_first_row, - uiState.searchTerm + } + + SearchTextField( + modifier = + Modifier.fillMaxWidth() + .height(Dimens.searchFieldHeight) + .padding(horizontal = Dimens.searchFieldHorizontalPadding) + ) { searchString -> + onSearchTermInput.invoke(searchString) + } + Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = + Modifier.fillMaxSize() + .drawVerticalScrollbar( + lazyListState, + MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) + ), + state = lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (uiState) { + SelectLocationUiState.Loading -> { + item(contentType = ContentType.PROGRESS) { + MullvadCircularProgressIndicatorLarge( + Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) + ) + } + } + is SelectLocationUiState.ShowData -> { + if (uiState.countries.isEmpty()) { + item(contentType = ContentType.EMPTY_TEXT) { + val firstRow = + HtmlCompat.fromHtml( + textResource( + id = R.string.select_location_empty_text_first_row, + uiState.searchTerm + ), + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + val secondRow = + textResource( + id = R.string.select_location_empty_text_second_row + ) + Column( + modifier = + Modifier.padding( + horizontal = Dimens.selectLocationTitlePadding ), - HtmlCompat.FROM_HTML_MODE_COMPACT + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = firstRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) - .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) - val secondRow = - textResource(id = R.string.select_location_empty_text_second_row) - Column( - modifier = - Modifier.padding( - horizontal = Dimens.selectLocationTitlePadding - ), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = firstRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSecondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = secondRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSecondary + Text( + text = secondRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary + ) + } + } + } else { + items( + count = uiState.countries.size, + key = { index -> uiState.countries[index].hashCode() }, + contentType = { ContentType.ITEM } + ) { index -> + val country = uiState.countries[index] + RelayLocationCell( + relay = country, + selectedItem = uiState.selectedRelay, + onSelectRelay = onSelectRelay, + modifier = Modifier.animateContentSize() ) } } - } else { - items( - count = uiState.countries.size, - key = { index -> uiState.countries[index].hashCode() }, - contentType = { ContentType.ITEM } - ) { index -> - val country = uiState.countries[index] - RelayLocationCell( - relay = country, - selectedItem = uiState.selectedRelay, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } } } } 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 b092ed981b..d057da60f8 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 @@ -11,29 +11,35 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue 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 com.google.accompanist.systemuicontroller.rememberSystemUiController -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow +import com.ramcosta.composedestinations.annotation.Destination +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.component.NavigateBackDownIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.destinations.ReportProblemDestination +import net.mullvad.mullvadvpn.compose.destinations.SplitTunnelingDestination +import net.mullvad.mullvadvpn.compose.destinations.VpnSettingsDestination 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.transitions.SettingsTransition import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.common.util.openLink import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild +import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel +import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Preview @@ -47,29 +53,41 @@ private fun PreviewSettings() { isLoggedIn = true, isUpdateAvailable = true ), - enterTransitionEndAction = MutableSharedFlow() ) } } +@OptIn(ExperimentalMaterial3Api::class) +@Destination(style = SettingsTransition::class) +@Composable +fun Settings(navigator: DestinationsNavigator) { + val vm = koinViewModel<SettingsViewModel>() + val state by vm.uiState.collectAsState() + SettingsScreen( + uiState = state, + onVpnSettingCellClick = { + navigator.navigate(VpnSettingsDestination) { launchSingleTop = true } + }, + onSplitTunnelingCellClick = { + navigator.navigate(SplitTunnelingDestination) { launchSingleTop = true } + }, + onReportProblemCellClick = { + navigator.navigate(ReportProblemDestination) { launchSingleTop = true } + }, + onBackClick = navigator::navigateUp + ) +} + @ExperimentalMaterial3Api @Composable fun SettingsScreen( uiState: SettingsUiState, - enterTransitionEndAction: SharedFlow<Unit>, onVpnSettingCellClick: () -> Unit = {}, onSplitTunnelingCellClick: () -> Unit = {}, onReportProblemCellClick: () -> Unit = {}, onBackClick: () -> Unit = {} ) { val context = LocalContext.current - val backgroundColor = MaterialTheme.colorScheme.background - val systemUiController = rememberSystemUiController() - - LaunchedEffect(Unit) { - systemUiController.setNavigationBarColor(backgroundColor) - enterTransitionEndAction.collect { systemUiController.setStatusBarColor(backgroundColor) } - } ScaffoldWithMediumTopBar( appBarTitle = stringResource(id = R.string.settings), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt new file mode 100644 index 0000000000..0252c8129d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt @@ -0,0 +1,139 @@ +package net.mullvad.mullvadvpn.compose.screen + +import android.window.SplashScreen +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.DeviceRevokedDestination +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination +import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.viewmodel.SplashUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.SplashViewModel +import org.koin.androidx.compose.koinViewModel + +@Preview +@Composable +private fun PreviewLoadingScreen() { + AppTheme { SplashScreen() } +} + +// Set this as the start destination of the default nav graph +@RootNavGraph(start = true) +@Destination(style = DefaultTransition::class) +@Composable +fun Splash(navigator: DestinationsNavigator) { + val viewModel: SplashViewModel = koinViewModel() + + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + SplashUiSideEffect.NavigateToConnect -> { + navigator.navigate(ConnectDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.NavigateToLogin -> { + navigator.navigate(LoginDestination()) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.NavigateToPrivacyDisclaimer -> { + navigator.navigate(PrivacyDisclaimerDestination) { popUpTo(NavGraphs.root) {} } + } + SplashUiSideEffect.NavigateToRevoked -> { + navigator.navigate(DeviceRevokedDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.NavigateToOutOfTime -> + navigator.navigate(OutOfTimeDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + + LaunchedEffect(Unit) { viewModel.start() } + + SplashScreen() +} + +@Composable +fun SplashScreen() { + + val backgroundColor = MaterialTheme.colorScheme.primary + + ScaffoldWithTopBar( + topBarColor = backgroundColor, + onSettingsClicked = null, + onAccountClicked = null, + isIconAndLogoVisible = false, + content = { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier.background(backgroundColor) + .padding(it) + .padding(bottom = it.calculateTopPadding()) + .fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.launch_logo), + contentDescription = "", + modifier = Modifier.size(Dimens.splashLogoSize) + ) + Image( + painter = painterResource(id = R.drawable.logo_text), + contentDescription = "", + alpha = 0.6f, + modifier = + Modifier.padding(top = Dimens.mediumPadding) + .height(Dimens.splashLogoTextHeight) + ) + Text( + text = stringResource(id = R.string.connecting_to_daemon), + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme.onPrimary + .copy(alpha = AlphaDescription) + .compositeOver(backgroundColor), + modifier = Modifier.padding(top = Dimens.mediumPadding) + ) + } + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt index a1f9bd8a97..3396739491 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt @@ -13,12 +13,19 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.core.graphics.drawable.toBitmapOrNull +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.applist.AppData import net.mullvad.mullvadvpn.compose.cell.BaseCell @@ -32,8 +39,11 @@ import net.mullvad.mullvadvpn.compose.constant.ContentType import net.mullvad.mullvadvpn.compose.constant.SplitTunnelingContentKey import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -69,6 +79,25 @@ private fun PreviewSplitTunnelingScreen() { } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun SplitTunneling(navigator: DestinationsNavigator) { + val viewModel = koinViewModel<SplitTunnelingViewModel>() + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + val packageManager = remember(context) { context.packageManager } + SplitTunnelingScreen( + uiState = state, + onShowSystemAppsClick = viewModel::onShowSystemAppsClick, + onExcludeAppClick = viewModel::onExcludeAppClick, + onIncludeAppClick = viewModel::onIncludeAppClick, + onBackClick = navigator::navigateUp, + onResolveIcon = { packageName -> + packageManager.getApplicationIcon(packageName).toBitmapOrNull() + } + ) +} + @Composable @OptIn(ExperimentalFoundationApi::class) fun SplitTunnelingScreen( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt index 7ff8aa11aa..ef4e58cda1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -26,6 +27,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium @@ -33,12 +36,15 @@ import net.mullvad.mullvadvpn.compose.component.MullvadMediumTopBar import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.provider.getLogsShareIntent import net.mullvad.mullvadvpn.viewmodel.ViewLogsUiState +import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -52,6 +58,14 @@ private fun PreviewViewLogsLoadingScreen() { AppTheme { ViewLogsScreen(uiState = ViewLogsUiState()) } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun ViewLogs(navigator: DestinationsNavigator) { + val vm = koinViewModel<ViewLogsViewModel>() + val uiState = vm.uiState.collectAsState() + ViewLogsScreen(uiState = uiState.value, onBackClick = navigator::navigateUp) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ViewLogsScreen( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt index 7290b9600f..765975a446 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.compose.screen -import android.widget.Toast import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -11,17 +10,19 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -29,11 +30,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.distinctUntilChanged +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.BaseCell import net.mullvad.mullvadvpn.compose.cell.ContentBlockersDisableModeCellSubtitle @@ -50,18 +51,20 @@ import net.mullvad.mullvadvpn.compose.cell.SelectableCell import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar -import net.mullvad.mullvadvpn.compose.dialog.ContentBlockersInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.CustomDnsInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.CustomPortDialog -import net.mullvad.mullvadvpn.compose.dialog.DnsDialog -import net.mullvad.mullvadvpn.compose.dialog.LocalNetworkSharingInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.MalwareInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.MtuDialog -import net.mullvad.mullvadvpn.compose.dialog.ObfuscationInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.QuantumResistanceInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.WireguardPortInfoDialog +import net.mullvad.mullvadvpn.compose.destinations.ContentBlockersInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.CustomDnsInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.DnsDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.LocalNetworkSharingInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.MalwareInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.MtuDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.ObfuscationInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.QuantumResistanceInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.UdpOverTcpPortInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.WireguardCustomPortDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.WireguardPortInfoDialogDestination +import net.mullvad.mullvadvpn.compose.dialog.WireguardCustomPortNavArgs +import net.mullvad.mullvadvpn.compose.dialog.WireguardPortInfoDialogArgument import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider -import net.mullvad.mullvadvpn.compose.state.VpnSettingsDialog import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_LAST_ITEM_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_QUANTUM_ITEM_OFF_TEST_TAG @@ -70,17 +73,22 @@ import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.constant.WIREGUARD_PRESET_PORTS import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.model.Constraint import net.mullvad.mullvadvpn.model.Port +import net.mullvad.mullvadvpn.model.PortRange import net.mullvad.mullvadvpn.model.QuantumResistantState import net.mullvad.mullvadvpn.model.SelectedObfuscation import net.mullvad.mullvadvpn.util.hasValue import net.mullvad.mullvadvpn.util.isCustom -import net.mullvad.mullvadvpn.util.toDisplayCustomPort +import net.mullvad.mullvadvpn.util.toValueOrNull import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem +import net.mullvad.mullvadvpn.viewmodel.VpnSettingsSideEffect +import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -94,179 +102,187 @@ private fun PreviewVpnSettings() { isCustomDnsEnabled = true, customDnsItems = listOf(CustomDnsItem("0.0.0.0", false)), ), - onMtuCellClick = {}, - onSaveMtuClick = {}, - onRestoreMtuClick = {}, - onCancelMtuDialogClick = {}, - onToggleAutoConnect = {}, - onToggleLocalNetworkSharing = {}, - onToggleDnsClick = {}, - onToggleBlockAds = {}, + snackbarHostState = SnackbarHostState(), onToggleBlockTrackers = {}, + onToggleBlockAds = {}, onToggleBlockMalware = {}, + onToggleAutoConnect = {}, + onToggleLocalNetworkSharing = {}, onToggleBlockAdultContent = {}, onToggleBlockGambling = {}, onToggleBlockSocialMedia = {}, - onDnsClick = {}, - onDnsInputChange = {}, - onSaveDnsClick = {}, - onRemoveDnsClick = {}, - onCancelDnsDialogClick = {}, - onLocalNetworkSharingInfoClick = {}, - onContentsBlockersInfoClick = {}, - onMalwareInfoClick = {}, - onCustomDnsInfoClick = {}, - onDismissInfoClick = {}, + navigateToMtuDialog = {}, + navigateToDns = { _, _ -> }, + onToggleDnsClick = {}, onBackClick = {}, - toastMessagesSharedFlow = MutableSharedFlow<String>().asSharedFlow(), - onStopEvent = {}, onSelectObfuscationSetting = {}, - onObfuscationInfoClick = {}, onSelectQuantumResistanceSetting = {}, - onQuantumResistanceInfoClicked = {}, onWireguardPortSelected = {}, - onWireguardPortInfoClicked = {}, - onShowCustomPortDialog = {}, - onCancelCustomPortDialogClick = {}, - onCloseCustomPortDialog = {} ) } } -@OptIn(ExperimentalFoundationApi::class) +@Destination(style = SlideInFromRightTransition::class) @Composable -fun VpnSettingsScreen( - lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, - uiState: VpnSettingsUiState, - onMtuCellClick: () -> Unit = {}, - onSaveMtuClick: (Int) -> Unit = {}, - onRestoreMtuClick: () -> Unit = {}, - onCancelMtuDialogClick: () -> Unit = {}, - onToggleAutoConnect: (Boolean) -> Unit = {}, - onToggleLocalNetworkSharing: (Boolean) -> Unit = {}, - onToggleDnsClick: (Boolean) -> Unit = {}, - onToggleBlockAds: (Boolean) -> Unit = {}, - onToggleBlockTrackers: (Boolean) -> Unit = {}, - onToggleBlockMalware: (Boolean) -> Unit = {}, - onToggleBlockAdultContent: (Boolean) -> Unit = {}, - onToggleBlockGambling: (Boolean) -> Unit = {}, - onToggleBlockSocialMedia: (Boolean) -> Unit = {}, - onDnsClick: (index: Int?) -> Unit = {}, - onDnsInputChange: (String) -> Unit = {}, - onSaveDnsClick: () -> Unit = {}, - onRemoveDnsClick: () -> Unit = {}, - onCancelDnsDialogClick: () -> Unit = {}, - onLocalNetworkSharingInfoClick: () -> Unit = {}, - onContentsBlockersInfoClick: () -> Unit = {}, - onMalwareInfoClick: () -> Unit = {}, - onCustomDnsInfoClick: () -> Unit = {}, - onDismissInfoClick: () -> Unit = {}, - onBackClick: () -> Unit = {}, - onStopEvent: () -> Unit = {}, - toastMessagesSharedFlow: SharedFlow<String>, - onSelectObfuscationSetting: (selectedObfuscation: SelectedObfuscation) -> Unit = {}, - onObfuscationInfoClick: () -> Unit = {}, - onSelectQuantumResistanceSetting: (quantumResistant: QuantumResistantState) -> Unit = {}, - onQuantumResistanceInfoClicked: () -> Unit = {}, - onWireguardPortSelected: (port: Constraint<Port>) -> Unit = {}, - onWireguardPortInfoClicked: () -> Unit = {}, - onShowCustomPortDialog: () -> Unit = {}, - onCancelCustomPortDialogClick: () -> Unit = {}, - onCloseCustomPortDialog: () -> Unit = {} +fun VpnSettings( + navigator: DestinationsNavigator, + dnsDialogResult: ResultRecipient<DnsDialogDestination, Boolean>, + customWgPortResult: ResultRecipient<WireguardCustomPortDialogDestination, Int?> ) { - val savedCustomPort = rememberSaveable { mutableStateOf<Constraint<Port>>(Constraint.Any()) } + val vm = koinViewModel<VpnSettingsViewModel>() + val state = vm.uiState.collectAsState().value - when (val dialog = uiState.dialog) { - is VpnSettingsDialog.Mtu -> { - MtuDialog( - mtuInitial = dialog.mtuEditValue.toIntOrNull(), - onSave = { onSaveMtuClick(it) }, - onRestoreDefaultValue = { onRestoreMtuClick() }, - onDismiss = { onCancelMtuDialogClick() } - ) - } - is VpnSettingsDialog.Dns -> { - DnsDialog( - stagedDns = dialog.stagedDns, - isAllowLanEnabled = uiState.isAllowLanEnabled, - onIpAddressChanged = { onDnsInputChange(it) }, - onAttemptToSave = { onSaveDnsClick() }, - onRemove = { onRemoveDnsClick() }, - onDismiss = { onCancelDnsDialogClick() } - ) - } - is VpnSettingsDialog.LocalNetworkSharingInfo -> { - LocalNetworkSharingInfoDialog(onDismissInfoClick) - } - is VpnSettingsDialog.ContentBlockersInfo -> { - ContentBlockersInfoDialog(onDismissInfoClick) - } - is VpnSettingsDialog.CustomDnsInfo -> { - CustomDnsInfoDialog(onDismissInfoClick) - } - is VpnSettingsDialog.MalwareInfo -> { - MalwareInfoDialog(onDismissInfoClick) - } - is VpnSettingsDialog.ObfuscationInfo -> { - ObfuscationInfoDialog(onDismissInfoClick) - } - is VpnSettingsDialog.QuantumResistanceInfo -> { - QuantumResistanceInfoDialog(onDismissInfoClick) - } - is VpnSettingsDialog.WireguardPortInfo -> { - WireguardPortInfoDialog(dialog.availablePortRanges, onDismissInfoClick) - } - is VpnSettingsDialog.CustomPort -> { - CustomPortDialog( - customPort = savedCustomPort.value.toDisplayCustomPort(), - allowedPortRanges = dialog.availablePortRanges, - onSave = { customPortString -> - onWireguardPortSelected(Constraint.Only(Port(customPortString.toInt()))) - }, - onReset = { - if (uiState.selectedWireguardPort.isCustom()) { - onWireguardPortSelected(Constraint.Any()) - } - savedCustomPort.value = Constraint.Any() - onCloseCustomPortDialog() - }, - showReset = savedCustomPort.value is Constraint.Only, - onDismissRequest = { onCancelCustomPortDialogClick() } - ) + dnsDialogResult.onNavResult { + when (it) { + NavResult.Canceled -> { + vm.onDnsDialogDismissed() + } + is NavResult.Value -> {} } } - var expandContentBlockersState by rememberSaveable { mutableStateOf(false) } - val biggerPadding = 54.dp - val topPadding = 6.dp + customWgPortResult.onNavResult { + when (it) { + NavResult.Canceled -> {} + is NavResult.Value -> { + val port = it.value - LaunchedEffect(uiState.selectedWireguardPort) { - if ( - uiState.selectedWireguardPort.isCustom() && - uiState.selectedWireguardPort != savedCustomPort.value - ) { - savedCustomPort.value = uiState.selectedWireguardPort + if (port != null) { + vm.onWireguardPortSelected(Constraint.Only(Port(port))) + } else { + vm.resetCustomPort() + } + } } } - val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(Unit) { - toastMessagesSharedFlow.distinctUntilChanged().collect { message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + vm.uiSideEffect.collect { + when (it) { + is VpnSettingsSideEffect.ShowToast -> + launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(message = it.message) + } + VpnSettingsSideEffect.NavigateToDnsDialog -> + navigator.navigate(DnsDialogDestination(null, null)) { launchSingleTop = true } + } } } + + val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { - onStopEvent() + vm.onStopEvent() } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + + VpnSettingsScreen( + uiState = state, + snackbarHostState = snackbarHostState, + navigateToContentBlockersInfo = { + navigator.navigate(ContentBlockersInfoDialogDestination) { launchSingleTop = true } + }, + navigateToCustomDnsInfo = { + navigator.navigate(CustomDnsInfoDialogDestination) { launchSingleTop = true } + }, + navigateToMalwareInfo = { + navigator.navigate(MalwareInfoDialogDestination) { launchSingleTop = true } + }, + navigateToObfuscationInfo = { + navigator.navigate(ObfuscationInfoDialogDestination) { launchSingleTop = true } + }, + navigateToQuantumResistanceInfo = { + navigator.navigate(QuantumResistanceInfoDialogDestination) { launchSingleTop = true } + }, + navigateUdp2TcpInfo = { + navigator.navigate(UdpOverTcpPortInfoDialogDestination) { launchSingleTop = true } + }, + navigateToWireguardPortInfo = { + navigator.navigate( + WireguardPortInfoDialogDestination(WireguardPortInfoDialogArgument(it)) + ) { + launchSingleTop = true + } + }, + navigateToLocalNetworkSharingInfo = { + navigator.navigate(LocalNetworkSharingInfoDialogDestination) { launchSingleTop = true } + }, + onToggleBlockTrackers = vm::onToggleBlockTrackers, + onToggleBlockAds = vm::onToggleBlockAds, + onToggleBlockMalware = vm::onToggleBlockMalware, + onToggleAutoConnect = vm::onToggleAutoConnect, + onToggleLocalNetworkSharing = vm::onToggleLocalNetworkSharing, + onToggleBlockAdultContent = vm::onToggleBlockAdultContent, + onToggleBlockGambling = vm::onToggleBlockGambling, + onToggleBlockSocialMedia = vm::onToggleBlockSocialMedia, + navigateToMtuDialog = { + navigator.navigate(MtuDialogDestination(it)) { launchSingleTop = true } + }, + navigateToDns = { index, address -> + navigator.navigate(DnsDialogDestination(index, address)) { launchSingleTop = true } + }, + navigateToWireguardPortDialog = { + val args = + WireguardCustomPortNavArgs( + state.customWireguardPort?.toValueOrNull(), + state.availablePortRanges + ) + navigator.navigate(WireguardCustomPortDialogDestination(args)) { + launchSingleTop = true + } + }, + onToggleDnsClick = vm::onToggleCustomDns, + onBackClick = navigator::navigateUp, + onSelectObfuscationSetting = vm::onSelectObfuscationSetting, + onSelectQuantumResistanceSetting = vm::onSelectQuantumResistanceSetting, + onWireguardPortSelected = vm::onWireguardPortSelected, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun VpnSettingsScreen( + uiState: VpnSettingsUiState, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + navigateToContentBlockersInfo: () -> Unit = {}, + navigateToCustomDnsInfo: () -> Unit = {}, + navigateToMalwareInfo: () -> Unit = {}, + navigateToObfuscationInfo: () -> Unit = {}, + navigateToQuantumResistanceInfo: () -> Unit = {}, + navigateUdp2TcpInfo: () -> Unit = {}, + navigateToWireguardPortInfo: (availablePortRanges: List<PortRange>) -> Unit = {}, + navigateToLocalNetworkSharingInfo: () -> Unit = {}, + navigateToWireguardPortDialog: () -> Unit = {}, + onToggleBlockTrackers: (Boolean) -> Unit = {}, + onToggleBlockAds: (Boolean) -> Unit = {}, + onToggleBlockMalware: (Boolean) -> Unit = {}, + onToggleAutoConnect: (Boolean) -> Unit = {}, + onToggleLocalNetworkSharing: (Boolean) -> Unit = {}, + onToggleBlockAdultContent: (Boolean) -> Unit = {}, + onToggleBlockGambling: (Boolean) -> Unit = {}, + onToggleBlockSocialMedia: (Boolean) -> Unit = {}, + navigateToMtuDialog: (mtu: Int?) -> Unit = {}, + navigateToDns: (index: Int?, address: String?) -> Unit = { _, _ -> }, + onToggleDnsClick: (Boolean) -> Unit = {}, + onBackClick: () -> Unit = {}, + onSelectObfuscationSetting: (selectedObfuscation: SelectedObfuscation) -> Unit = {}, + onSelectQuantumResistanceSetting: (quantumResistant: QuantumResistantState) -> Unit = {}, + onWireguardPortSelected: (port: Constraint<Port>) -> Unit = {}, +) { + var expandContentBlockersState by rememberSaveable { mutableStateOf(false) } + val biggerPadding = 54.dp + val topPadding = 6.dp + ScaffoldWithMediumTopBar( appBarTitle = stringResource(id = R.string.settings_vpn), navigationIcon = { NavigateBackIconButton(onBackClick) }, + snackbarHostState = snackbarHostState ) { modifier, lazyListState -> LazyColumn( modifier = modifier.testTag(LAZY_LIST_TEST_TAG).animateContentSize(), @@ -288,10 +304,10 @@ fun VpnSettingsScreen( Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) HeaderSwitchComposeCell( title = stringResource(R.string.local_network_sharing), - isToggled = uiState.isAllowLanEnabled, + isToggled = uiState.isLocalNetworkSharingEnabled, isEnabled = true, onCellClicked = { newValue -> onToggleLocalNetworkSharing(newValue) }, - onInfoClicked = { onLocalNetworkSharingInfoClick() } + onInfoClicked = navigateToLocalNetworkSharingInfo ) Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) } @@ -301,7 +317,7 @@ fun VpnSettingsScreen( title = stringResource(R.string.dns_content_blockers_title), isExpanded = expandContentBlockersState, isEnabled = !uiState.isCustomDnsEnabled, - onInfoClicked = { onContentsBlockersInfoClick() }, + onInfoClicked = { navigateToContentBlockersInfo() }, onCellClicked = { expandContentBlockersState = !expandContentBlockersState } ) } @@ -333,7 +349,7 @@ fun VpnSettingsScreen( isToggled = uiState.contentBlockersOptions.blockMalware, isEnabled = !uiState.isCustomDnsEnabled, onCellClicked = { onToggleBlockMalware(it) }, - onInfoClicked = { onMalwareInfoClick() }, + onInfoClicked = { navigateToMalwareInfo() }, background = MaterialTheme.colorScheme.secondaryContainer, startPadding = Dimens.indentedCellStartPadding ) @@ -391,7 +407,7 @@ fun VpnSettingsScreen( isToggled = uiState.isCustomDnsEnabled, isEnabled = uiState.contentBlockersOptions.isAnyBlockerEnabled().not(), onCellClicked = { newValue -> onToggleDnsClick(newValue) }, - onInfoClicked = { onCustomDnsInfoClick() } + onInfoClicked = { navigateToCustomDnsInfo() } ) } @@ -400,8 +416,8 @@ fun VpnSettingsScreen( DnsCell( address = item.address, isUnreachableLocalDnsWarningVisible = - item.isLocal && uiState.isAllowLanEnabled.not(), - onClick = { onDnsClick(index) }, + item.isLocal && !uiState.isLocalNetworkSharingEnabled, + onClick = { navigateToDns(index, item.address) }, modifier = Modifier.animateItemPlacement() ) Divider() @@ -409,7 +425,7 @@ fun VpnSettingsScreen( itemWithDivider { BaseCell( - onCellClicked = { onDnsClick(null) }, + onCellClicked = { navigateToDns(null, null) }, title = { Text( text = stringResource(id = R.string.add_a_server), @@ -441,7 +457,8 @@ fun VpnSettingsScreen( Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) InformationComposeCell( title = stringResource(id = R.string.wireguard_port_title), - onInfoClicked = onWireguardPortInfoClicked + onInfoClicked = { navigateToWireguardPortInfo(uiState.availablePortRanges) }, + onCellClicked = { navigateToWireguardPortInfo(uiState.availablePortRanges) }, ) } @@ -468,20 +485,15 @@ fun VpnSettingsScreen( CustomPortCell( title = stringResource(id = R.string.wireguard_custon_port_title), isSelected = uiState.selectedWireguardPort.isCustom(), - port = - if (uiState.selectedWireguardPort.isCustom()) { - uiState.selectedWireguardPort.toDisplayCustomPort() - } else { - savedCustomPort.value.toDisplayCustomPort() - }, + port = uiState.customWireguardPort?.toValueOrNull(), onMainCellClicked = { - if (savedCustomPort.value is Constraint.Only) { - onWireguardPortSelected(savedCustomPort.value) + if (uiState.customWireguardPort != null) { + onWireguardPortSelected(uiState.customWireguardPort) } else { - onShowCustomPortDialog() + navigateToWireguardPortDialog() } }, - onPortCellClicked = { onShowCustomPortDialog() }, + onPortCellClicked = navigateToWireguardPortDialog, mainTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG, numberTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG ) @@ -491,7 +503,8 @@ fun VpnSettingsScreen( Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) InformationComposeCell( title = stringResource(R.string.obfuscation_title), - onInfoClicked = { onObfuscationInfoClick() } + onInfoClicked = navigateToObfuscationInfo, + onCellClicked = navigateToObfuscationInfo ) } itemWithDivider { @@ -520,7 +533,8 @@ fun VpnSettingsScreen( Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) InformationComposeCell( title = stringResource(R.string.quantum_resistant_title), - onInfoClicked = { onQuantumResistanceInfoClicked() } + onInfoClicked = navigateToQuantumResistanceInfo, + onCellClicked = navigateToQuantumResistanceInfo ) } itemWithDivider { @@ -548,7 +562,12 @@ fun VpnSettingsScreen( Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) } - item { MtuComposeCell(mtuValue = uiState.mtu, onEditMtu = { onMtuCellClick() }) } + item { + MtuComposeCell( + mtuValue = uiState.mtu, + onEditMtu = { navigateToMtuDialog(uiState.mtu.toIntOrNull()) } + ) + } item { MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG)) Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt index 4778756648..cc8cf4977c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.compose.screen -import android.app.Activity import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -19,10 +18,9 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -30,21 +28,29 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton import net.mullvad.mullvadvpn.compose.button.SitePaymentButton import net.mullvad.mullvadvpn.compose.component.CopyAnimatedIconButton import net.mullvad.mullvadvpn.compose.component.PlayPayment import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar -import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog +import net.mullvad.mullvadvpn.compose.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.DeviceNameInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination +import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination +import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState +import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser @@ -57,13 +63,13 @@ import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar import net.mullvad.mullvadvpn.lib.theme.color.MullvadWhite import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable private fun PreviewWelcomeScreen() { AppTheme { WelcomeScreen( - showSitePayment = true, uiState = WelcomeUiState( accountNumber = "4444555566667777", @@ -76,55 +82,98 @@ private fun PreviewWelcomeScreen() { ) ) ), - uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(), onSitePaymentClick = {}, onRedeemVoucherClick = {}, onSettingsClick = {}, onAccountClick = {}, - openConnectScreen = {}, - onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onPurchaseBillingProductClick = { _ -> }, + navigateToDeviceInfoDialog = {}, + navigateToVerificationPendingDialog = {} ) } } +@Destination(style = HomeTransition::class) @Composable -fun WelcomeScreen( - showSitePayment: Boolean, - uiState: WelcomeUiState, - uiSideEffect: SharedFlow<WelcomeViewModel.UiSideEffect>, - onSitePaymentClick: () -> Unit, - onRedeemVoucherClick: () -> Unit, - onSettingsClick: () -> Unit, - onAccountClick: () -> Unit, - openConnectScreen: () -> Unit, - onPurchaseBillingProductClick: (productId: ProductId, activityProvider: () -> Activity) -> Unit, - onClosePurchaseResultDialog: (success: Boolean) -> Unit +fun Welcome( + navigator: DestinationsNavigator, + voucherRedeemResultRecipient: ResultRecipient<RedeemVoucherDestination, Boolean>, + playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean> ) { + val vm = koinViewModel<WelcomeViewModel>() + val state by vm.uiState.collectAsState() + + voucherRedeemResultRecipient.onNavResult { + when (it) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> + // If we successfully redeemed a voucher, navigate to Connect screen + if (it.value) { + navigator.navigate(ConnectDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + + playPaymentResultRecipient.onNavResult { + when (it) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value) + } + } + val context = LocalContext.current LaunchedEffect(Unit) { - uiSideEffect.collect { uiSideEffect -> + vm.uiSideEffect.collect { uiSideEffect -> when (uiSideEffect) { is WelcomeViewModel.UiSideEffect.OpenAccountView -> context.openAccountPageInBrowser(uiSideEffect.token) - WelcomeViewModel.UiSideEffect.OpenConnectScreen -> openConnectScreen() + WelcomeViewModel.UiSideEffect.OpenConnectScreen -> { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } } } } - var showVerificationPendingDialog by remember { mutableStateOf(false) } - if (showVerificationPendingDialog) { - VerificationPendingDialog(onClose = { showVerificationPendingDialog = false }) - } - - uiState.paymentDialogData?.let { - PaymentDialog( - paymentDialogData = uiState.paymentDialogData, - retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } }, - onCloseDialog = onClosePurchaseResultDialog - ) - } + WelcomeScreen( + uiState = state, + onSitePaymentClick = vm::onSitePaymentClick, + onRedeemVoucherClick = { + navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true } + }, + onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } }, + navigateToDeviceInfoDialog = { + navigator.navigate(DeviceNameInfoDialogDestination) { launchSingleTop = true } + }, + onPurchaseBillingProductClick = { productId -> + navigator.navigate(PaymentDestination(productId)) { launchSingleTop = true } + }, + navigateToVerificationPendingDialog = { + navigator.navigate(VerificationPendingDialogDestination) { launchSingleTop = true } + } + ) +} +@Composable +fun WelcomeScreen( + uiState: WelcomeUiState, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onSettingsClick: () -> Unit, + onAccountClick: () -> Unit, + onPurchaseBillingProductClick: (productId: ProductId) -> Unit, + navigateToDeviceInfoDialog: () -> Unit, + navigateToVerificationPendingDialog: () -> Unit +) { val scrollState = rememberScrollState() val snackbarHostState = remember { SnackbarHostState() } @@ -135,13 +184,6 @@ fun WelcomeScreen( } else { MaterialTheme.colorScheme.error }, - statusBarColor = - if (uiState.tunnelState.isSecured()) { - MaterialTheme.colorScheme.inversePrimary - } else { - MaterialTheme.colorScheme.error - }, - navigationBarColor = MaterialTheme.colorScheme.background, iconTintColor = if (uiState.tunnelState.isSecured()) { MaterialTheme.colorScheme.onPrimary @@ -165,18 +207,18 @@ fun WelcomeScreen( .background(color = MaterialTheme.colorScheme.primary) ) { // Welcome info area - WelcomeInfo(snackbarHostState, uiState, showSitePayment) + WelcomeInfo(snackbarHostState, uiState, navigateToDeviceInfoDialog) Spacer(modifier = Modifier.weight(1f)) // Payment button area PaymentPanel( - showSitePayment = showSitePayment, + showSitePayment = uiState.showSitePayment, billingPaymentState = uiState.billingPaymentState, onSitePaymentClick = onSitePaymentClick, onRedeemVoucherClick = onRedeemVoucherClick, onPurchaseBillingProductClick = onPurchaseBillingProductClick, - onPaymentInfoClick = { showVerificationPendingDialog = true } + onPaymentInfoClick = navigateToVerificationPendingDialog ) } } @@ -186,7 +228,7 @@ fun WelcomeScreen( private fun WelcomeInfo( snackbarHostState: SnackbarHostState, uiState: WelcomeUiState, - showSitePayment: Boolean + navigateToDeviceInfoDialog: () -> Unit ) { Column { Text( @@ -217,13 +259,13 @@ private fun WelcomeInfo( AccountNumberRow(snackbarHostState, uiState) - DeviceNameRow(deviceName = uiState.deviceName) + DeviceNameRow(deviceName = uiState.deviceName, navigateToDeviceInfoDialog) Text( text = buildString { append(stringResource(id = R.string.pay_to_start_using)) - if (showSitePayment) { + if (uiState.showSitePayment) { append(" ") append(stringResource(id = R.string.add_time_to_account)) } @@ -269,7 +311,7 @@ private fun AccountNumberRow(snackbarHostState: SnackbarHostState, uiState: Welc } @Composable -fun DeviceNameRow(deviceName: String?) { +fun DeviceNameRow(deviceName: String?, navigateToDeviceInfoDialog: () -> Unit) { Row( modifier = Modifier.padding(horizontal = Dimens.sideMargin), verticalAlignment = Alignment.CenterVertically, @@ -288,10 +330,9 @@ fun DeviceNameRow(deviceName: String?) { color = MaterialTheme.colorScheme.onPrimary ) - var showDeviceNameDialog by remember { mutableStateOf(false) } IconButton( modifier = Modifier.align(Alignment.CenterVertically), - onClick = { showDeviceNameDialog = true } + onClick = navigateToDeviceInfoDialog ) { Icon( painter = painterResource(id = R.drawable.icon_info), @@ -299,9 +340,6 @@ fun DeviceNameRow(deviceName: String?) { tint = MullvadWhite ) } - if (showDeviceNameDialog) { - DeviceNameInfoDialog { showDeviceNameDialog = false } - } } } @@ -311,10 +349,9 @@ private fun PaymentPanel( billingPaymentState: PaymentState?, onSitePaymentClick: () -> Unit, onRedeemVoucherClick: () -> Unit, - onPurchaseBillingProductClick: (productId: ProductId, activityProvider: () -> Activity) -> Unit, + onPurchaseBillingProductClick: (productId: ProductId) -> Unit, onPaymentInfoClick: () -> Unit ) { - val context = LocalContext.current Column( modifier = Modifier.fillMaxWidth() @@ -326,7 +363,7 @@ private fun PaymentPanel( PlayPayment( billingPaymentState = billingPaymentState, onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) { context as Activity } + onPurchaseBillingProductClick(productId) }, onInfoClick = onPaymentInfoClick, modifier = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt index e22aaffde2..e539dbafc6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt @@ -5,13 +5,11 @@ import net.mullvad.mullvadvpn.model.Device data class DeviceListUiState( val deviceUiItems: List<DeviceListItemUiState>, val isLoading: Boolean, - val stagedDevice: Device? ) { val hasTooManyDevices = deviceUiItems.count() >= 5 companion object { - val INITIAL = - DeviceListUiState(deviceUiItems = emptyList(), isLoading = true, stagedDevice = null) + val INITIAL = DeviceListUiState(deviceUiItems = emptyList(), isLoading = true) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt index 0491f80ea0..54fd414f86 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt @@ -1,11 +1,10 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData import net.mullvad.mullvadvpn.model.TunnelState data class OutOfTimeUiState( val tunnelState: TunnelState = TunnelState.Disconnected, val deviceName: String = "", + val showSitePayment: Boolean = false, val billingPaymentState: PaymentState? = null, - val paymentDialogData: PaymentDialogData? = null ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt index e78d2e9f43..5525dee8ce 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt @@ -7,7 +7,6 @@ import net.mullvad.mullvadvpn.model.PortRange import net.mullvad.mullvadvpn.model.QuantumResistantState import net.mullvad.mullvadvpn.model.SelectedObfuscation import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem -import net.mullvad.mullvadvpn.viewmodel.StagedDns data class VpnSettingsUiState( val mtu: String, @@ -16,12 +15,11 @@ data class VpnSettingsUiState( val isCustomDnsEnabled: Boolean, val customDnsItems: List<CustomDnsItem>, val contentBlockersOptions: DefaultDnsOptions, - val isAllowLanEnabled: Boolean, val selectedObfuscation: SelectedObfuscation, val quantumResistant: QuantumResistantState, val selectedWireguardPort: Constraint<Port>, + val customWireguardPort: Constraint<Port>?, val availablePortRanges: List<PortRange>, - val dialog: VpnSettingsDialog? ) { companion object { @@ -32,12 +30,11 @@ data class VpnSettingsUiState( isCustomDnsEnabled: Boolean = false, customDnsItems: List<CustomDnsItem> = emptyList(), contentBlockersOptions: DefaultDnsOptions = DefaultDnsOptions(), - isAllowLanEnabled: Boolean = false, selectedObfuscation: SelectedObfuscation = SelectedObfuscation.Off, quantumResistant: QuantumResistantState = QuantumResistantState.Off, selectedWireguardPort: Constraint<Port> = Constraint.Any(), + customWireguardPort: Constraint.Only<Port>? = null, availablePortRanges: List<PortRange> = emptyList(), - dialog: VpnSettingsDialog? = null ) = VpnSettingsUiState( mtu, @@ -46,36 +43,11 @@ data class VpnSettingsUiState( isCustomDnsEnabled, customDnsItems, contentBlockersOptions, - isAllowLanEnabled, selectedObfuscation, quantumResistant, selectedWireguardPort, + customWireguardPort, availablePortRanges, - dialog ) } } - -interface VpnSettingsDialog { - data class Mtu(val mtuEditValue: String) : VpnSettingsDialog - - data class Dns(val stagedDns: StagedDns) : VpnSettingsDialog - - data object LocalNetworkSharingInfo : VpnSettingsDialog - - data object ContentBlockersInfo : VpnSettingsDialog - - data object CustomDnsInfo : VpnSettingsDialog - - data object MalwareInfo : VpnSettingsDialog - - data object ObfuscationInfo : VpnSettingsDialog - - data object QuantumResistanceInfo : VpnSettingsDialog - - data class WireguardPortInfo(val availablePortRanges: List<PortRange> = emptyList()) : - VpnSettingsDialog - - data class CustomPort(val availablePortRanges: List<PortRange> = emptyList()) : - VpnSettingsDialog -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt index bd1c19e9c9..e2673a0ddf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt @@ -1,12 +1,11 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData import net.mullvad.mullvadvpn.model.TunnelState data class WelcomeUiState( val tunnelState: TunnelState = TunnelState.Disconnected, val accountNumber: String? = null, val deviceName: String? = null, + val showSitePayment: Boolean = false, val billingPaymentState: PaymentState? = null, - val paymentDialogData: PaymentDialogData? = null ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt index e3cb4faa5b..ff5bbf43cc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt @@ -1,5 +1,9 @@ package net.mullvad.mullvadvpn.compose.test +// Top Bar +const val TOP_BAR_ACCOUNT_BUTTON = "top_bar_account_button" +const val TOP_BAR_SETTINGS_BUTTON = "top_bar_settings_button" + // VpnSettingsScreen const val LAZY_LIST_TEST_TAG = "lazy_list_test_tag" const val LAZY_LIST_LAST_ITEM_TEST_TAG = "lazy_list_last_item_test_tag" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt index d7aec9e417..388bec98bf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt @@ -9,7 +9,7 @@ fun DnsTextField( value: String, modifier: Modifier = Modifier, onValueChanged: (String) -> Unit = {}, - onSubmit: (String) -> Unit = {}, + onSubmit: () -> Unit = {}, placeholderText: String?, isEnabled: Boolean = true, isValidValue: Boolean = true @@ -19,7 +19,7 @@ fun DnsTextField( keyboardType = KeyboardType.Text, modifier = modifier, onValueChanged = onValueChanged, - onSubmit = onSubmit, + onSubmit = { onSubmit() }, isEnabled = isEnabled, placeholderText = placeholderText, maxCharLength = Int.MAX_VALUE, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/DefaultTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/DefaultTransition.kt new file mode 100644 index 0000000000..4c02b278d0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/DefaultTransition.kt @@ -0,0 +1,17 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object DefaultTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() = fadeIn() + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() = fadeOut() + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() = fadeIn() + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = fadeOut() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt new file mode 100644 index 0000000000..93c94ecd87 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/HomeTransition.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS + +// This is used for OutOfTime, Welcome, and Connect destinations. +object HomeTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() = + when (this.initialState.destination()) { + is LoginDestination -> fadeIn() + else -> EnterTransition.None + } + + // TODO temporary hack until we have a proper solution. + // https://issuetracker.google.com/issues/309506799 + override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() = + fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() = + EnterTransition.None + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = fadeOut() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt new file mode 100644 index 0000000000..162dacbd90 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/LoginTransition.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.destinations.WelcomeDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS + +object LoginTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() = fadeIn() + + // TODO temporary hack until we have a proper solution. + // https://issuetracker.google.com/issues/309506799 + override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() = + when (this.targetState.destination()) { + is OutOfTimeDestination, + is WelcomeDestination, + is ConnectDestination -> fadeOut() + else -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + } + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() = fadeIn() + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = fadeOut() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt new file mode 100644 index 0000000000..75fb7286fc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS +import net.mullvad.mullvadvpn.constant.withHorizontalScalingFactor + +object SettingsTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() = + slideInVertically(initialOffsetY = { it }) + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() = + when (targetState.destination()) { + NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + else -> slideOutHorizontally(targetOffsetX = { -it.withHorizontalScalingFactor() }) + } + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() = + when (initialState.destination()) { + NoDaemonScreenDestination -> fadeIn(snap(0)) + else -> slideInHorizontally(initialOffsetX = { -it.withHorizontalScalingFactor() }) + } + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = + slideOutVertically(targetOffsetY = { it }) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt new file mode 100644 index 0000000000..da802483b5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt @@ -0,0 +1,58 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS +import net.mullvad.mullvadvpn.constant.withHorizontalScalingFactor + +object SlideInFromBottomTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() = + slideInVertically(initialOffsetY = { it }) + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() = + when (targetState.destination()) { + NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + else -> fadeOut() + } + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() = + when (initialState.destination()) { + NoDaemonScreenDestination -> fadeIn(snap(0)) + else -> fadeIn() + } + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = + slideOutVertically(targetOffsetY = { it }) +} + +object SelectLocationTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() = + slideInVertically(initialOffsetY = { it }) + + // TODO temporary hack until we have a proper solution. + // https://issuetracker.google.com/issues/309506799 + override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() = + when (targetState.destination()) { + NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + else -> slideOutHorizontally { -it.withHorizontalScalingFactor() } + } + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() = + when (initialState.destination()) { + NoDaemonScreenDestination -> fadeIn(snap(0)) + else -> slideInHorizontally { -it.withHorizontalScalingFactor() } + } + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = + slideOutVertically(targetOffsetY = { it }) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt new file mode 100644 index 0000000000..69baa8eb47 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle +import com.ramcosta.composedestinations.utils.destination +import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination +import net.mullvad.mullvadvpn.constant.SCREEN_ANIMATION_TIME_MILLIS +import net.mullvad.mullvadvpn.constant.withHorizontalScalingFactor + +object SlideInFromRightTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition() = + slideInHorizontally(initialOffsetX = { it }) + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition() = + when (targetState.destination()) { + NoDaemonScreenDestination -> fadeOut(snap(SCREEN_ANIMATION_TIME_MILLIS)) + else -> slideOutHorizontally(targetOffsetX = { -it.withHorizontalScalingFactor() }) + } + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition() = + when (initialState.destination()) { + NoDaemonScreenDestination -> fadeIn(snap(0)) + else -> slideInHorizontally(initialOffsetX = { -it.withHorizontalScalingFactor() }) + } + + override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition() = + slideOutHorizontally(targetOffsetX = { it }) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt new file mode 100644 index 0000000000..4ccf15bb63 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.constant + +import androidx.compose.animation.core.Spring + +const val MINIMUM_LOADING_TIME_MILLIS = 500L + +const val SCREEN_ANIMATION_TIME_MILLIS = Spring.StiffnessMediumLow.toInt() + +const val HORIZONTAL_SLIDE_FACTOR = 1 / 3f + +fun Int.withHorizontalScalingFactor(): Int = (this * HORIZONTAL_SLIDE_FACTOR).toInt() 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 9e35e67823..e12e3e2322 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 @@ -27,6 +27,7 @@ import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase import net.mullvad.mullvadvpn.usecase.EmptyPaymentUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase import net.mullvad.mullvadvpn.usecase.PortRangeUseCase @@ -41,13 +42,18 @@ import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel +import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel +import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel +import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel +import net.mullvad.mullvadvpn.viewmodel.SplashViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel @@ -101,6 +107,7 @@ val uiModule = module { single { NewDeviceNotificationUseCase(get()) } single { PortRangeUseCase(get()) } single { RelayListUseCase(get(), get()) } + single { OutOfTimeUseCase(get(), get()) } single { ConnectivityUseCase(get()) } single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } @@ -129,20 +136,29 @@ val uiModule = module { viewModel { ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG) } - viewModel { ConnectViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { ConnectViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { DeviceListViewModel(get(), get()) } viewModel { DeviceRevokedViewModel(get(), get()) } + viewModel { MtuDialogViewModel(get()) } + viewModel { parameters -> + DnsDialogViewModel(get(), get(), parameters.getOrNull(), parameters.getOrNull()) + } viewModel { LoginViewModel(get(), get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get()) } viewModel { SelectLocationViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get()) } + viewModel { SplashViewModel(get(), get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } - viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } - viewModel { WelcomeViewModel(get(), get(), get(), get()) } + viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } + viewModel { WelcomeViewModel(get(), get(), get(), get(), get()) } viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } - viewModel { OutOfTimeViewModel(get(), get(), get(), get()) } + viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get()) } + viewModel { PaymentViewModel(get()) } viewModel { FilterViewModel(get()) } + + // This view model must be single so we correctly attach lifecycle and share it with activity + single { NoDaemonViewModel(get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt index d1f395d387..369f3e8fee 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt @@ -4,14 +4,11 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn @@ -29,16 +26,10 @@ class AccountRepository( private val messageHandler: MessageHandler, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { - private val _cachedCreatedAccount = MutableStateFlow<String?>(null) - val cachedCreatedAccount = _cachedCreatedAccount.asStateFlow() - private val accountCreationEvents: SharedFlow<AccountCreationResult> = messageHandler .events<Event.AccountCreationEvent>() .map { it.result } - .onEach { - _cachedCreatedAccount.value = (it as? AccountCreationResult.Success)?.accountToken - } .shareIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed()) val accountExpiryState: StateFlow<AccountExpiry> = @@ -75,7 +66,6 @@ class AccountRepository( } fun logout() { - clearCreatedAccountCache() messageHandler.trySendRequest(Request.Logout) } @@ -90,8 +80,4 @@ class AccountRepository( fun clearAccountHistory() { messageHandler.trySendRequest(Request.ClearAccountHistory) } - - fun clearCreatedAccountCache() { - _cachedCreatedAccount.value = null - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt index 3086ee9b80..6c5387a5b1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/ProblemReportRepository.kt @@ -3,17 +3,15 @@ package net.mullvad.mullvadvpn.repository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import net.mullvad.mullvadvpn.dataproxy.UserReport class ProblemReportRepository { private val _problemReport = MutableStateFlow(UserReport("", "")) val problemReport: StateFlow<UserReport> = _problemReport.asStateFlow() - fun setEmail(email: String) { - _problemReport.value = _problemReport.value.copy(email = email) - } + fun setEmail(email: String) = _problemReport.update { it.copy(email = email) } - fun setDescription(description: String) { - _problemReport.value = _problemReport.value.copy(description = description) - } + fun setDescription(description: String) = + _problemReport.update { it.copy(description = description) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt index ac9637c683..81c4b85b88 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt @@ -39,16 +39,38 @@ class SettingsRepository( dnsList: List<InetAddress>, contentBlockersOptions: DefaultDnsOptions ) { - serviceConnectionManager - .customDns() - ?.setDnsOptions( - dnsOptions = - DnsOptions( - state = if (isCustomDnsEnabled) DnsState.Custom else DnsState.Default, - customOptions = CustomDnsOptions(ArrayList(dnsList)), - defaultOptions = contentBlockersOptions + updateDnsSettings { + DnsOptions( + state = if (isCustomDnsEnabled) DnsState.Custom else DnsState.Default, + customOptions = CustomDnsOptions(ArrayList(dnsList)), + defaultOptions = contentBlockersOptions + ) + } + } + + fun setDnsState( + state: DnsState, + ) { + updateDnsSettings { it.copy(state = state) } + } + + fun updateCustomDnsList(update: (List<InetAddress>) -> List<InetAddress>) { + updateDnsSettings { dnsOptions -> + val newDnsList = ArrayList(update(dnsOptions.customOptions.addresses.map { it })) + dnsOptions.copy( + state = if (newDnsList.isEmpty()) DnsState.Default else DnsState.Custom, + customOptions = + CustomDnsOptions( + addresses = newDnsList, ) ) + } + } + + private fun updateDnsSettings(lambda: (DnsOptions) -> DnsOptions) { + settingsUpdates.value?.tunnelOptions?.dnsOptions?.let { + serviceConnectionManager.customDns()?.setDnsOptions(lambda(it)) + } } fun setWireguardMtu(value: Int?) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index f5e24dacf1..e0ee6cdd21 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -2,127 +2,69 @@ package net.mullvad.mullvadvpn.ui import android.Manifest import android.app.Activity -import android.app.UiModeManager import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.res.Configuration import android.net.VpnService import android.os.Bundle import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onSubscription -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import net.mullvad.mullvadvpn.BuildConfig -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog +import androidx.core.view.WindowCompat +import net.mullvad.mullvadvpn.compose.screen.MullvadApp import net.mullvad.mullvadvpn.di.paymentModule import net.mullvad.mullvadvpn.di.uiModule import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionGranted -import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository -import net.mullvad.mullvadvpn.ui.fragment.AccountFragment -import net.mullvad.mullvadvpn.ui.fragment.ConnectFragment -import net.mullvad.mullvadvpn.ui.fragment.DeviceRevokedFragment -import net.mullvad.mullvadvpn.ui.fragment.FilterFragment -import net.mullvad.mullvadvpn.ui.fragment.LoadingFragment -import net.mullvad.mullvadvpn.ui.fragment.LoginFragment -import net.mullvad.mullvadvpn.ui.fragment.OutOfTimeFragment -import net.mullvad.mullvadvpn.ui.fragment.PrivacyDisclaimerFragment -import net.mullvad.mullvadvpn.ui.fragment.SettingsFragment -import net.mullvad.mullvadvpn.ui.fragment.WelcomeFragment import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS -import net.mullvad.mullvadvpn.util.addDebounceForUnknownState -import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules -open class MainActivity : FragmentActivity() { +class MainActivity : ComponentActivity() { private val requestNotificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { // NotificationManager.areNotificationsEnabled is used to check the state rather than // handling the callback value. } - private val deviceIsTv by lazy { - val uiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager - - uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION - } - private lateinit var accountRepository: AccountRepository private lateinit var deviceRepository: DeviceRepository private lateinit var privacyDisclaimerRepository: PrivacyDisclaimerRepository private lateinit var serviceConnectionManager: ServiceConnectionManager private lateinit var changelogViewModel: ChangelogViewModel - - private var deviceStateJob: Job? = null - private var currentDeviceState: DeviceState? = null + private lateinit var serviceConnectionViewModel: NoDaemonViewModel override fun onCreate(savedInstanceState: Bundle?) { loadKoinModules(listOf(uiModule, paymentModule)) + // Tell the system that we will draw behind the status bar and navigation bar + WindowCompat.setDecorFitsSystemWindows(window, false) + getKoin().apply { accountRepository = get() deviceRepository = get() privacyDisclaimerRepository = get() serviceConnectionManager = get() changelogViewModel = get() + serviceConnectionViewModel = get() } - - requestedOrientation = - if (deviceIsTv) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } + lifecycle.addObserver(serviceConnectionViewModel) super.onCreate(savedInstanceState) - setContentView(R.layout.main) - } - - override fun onStart() { - Log.d("mullvad", "Starting main activity") - super.onStart() - - if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { - initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() - ) - } else { - openPrivacyDisclaimerFragment() - } + setContent { AppTheme { MullvadApp() } } } - fun initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration: ApiEndpointConfiguration? - ) { - deviceStateJob = launchDeviceStateHandler() + fun initializeStateHandlerAndServiceConnection() { checkForNotificationPermission() serviceConnectionManager.bind( vpnPermissionRequestHandler = ::requestVpnPermission, - apiEndpointConfiguration = apiEndpointConfiguration + apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() ) } @@ -130,6 +72,14 @@ open class MainActivity : FragmentActivity() { serviceConnectionManager.onVpnPermissionResult(resultCode == Activity.RESULT_OK) } + override fun onStart() { + super.onStart() + + if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + initializeStateHandlerAndServiceConnection() + } + } + override fun onStop() { Log.d("mullvad", "Stopping main activity") super.onStop() @@ -137,111 +87,14 @@ open class MainActivity : FragmentActivity() { // NOTE: `super.onStop()` must be called before unbinding due to the fragment state handling // otherwise the fragments will believe there was an unexpected disconnect. serviceConnectionManager.unbind() - - deviceStateJob?.cancel() } override fun onDestroy() { serviceConnectionManager.onDestroy() + lifecycle.removeObserver(serviceConnectionViewModel) super.onDestroy() } - fun openAccount() { - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_bottom, - R.anim.do_nothing, - R.anim.do_nothing, - R.anim.fragment_exit_to_bottom - ) - replace(R.id.main_fragment, AccountFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - fun openSettings() { - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_bottom, - R.anim.do_nothing, - R.anim.do_nothing, - R.anim.fragment_exit_to_bottom - ) - replace(R.id.main_fragment, SettingsFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - fun openFilter() { - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_right, - R.anim.do_nothing, - R.anim.do_nothing, - R.anim.fragment_exit_to_right - ) - replace(R.id.main_fragment, FilterFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - private fun launchDeviceStateHandler(): Job { - return lifecycleScope.launch { - launch { - deviceRepository.deviceState - .debounce { - // Debounce DeviceState.Unknown to delay view transitions during reconnect. - it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) - } - .collect { newState -> - if (newState != currentDeviceState) - when (newState) { - is DeviceState.Initial, - is DeviceState.Unknown -> openLaunchView() - is DeviceState.LoggedOut -> openLoginView() - is DeviceState.Revoked -> openRevokedView() - is DeviceState.LoggedIn -> { - openLoggedInView( - accountToken = newState.accountAndDevice.account_token, - shouldDelayLogin = - currentDeviceState is DeviceState.LoggedOut - ) - } - } - currentDeviceState = newState - } - } - - lifecycleScope.launch { - deviceRepository.deviceState - .filter { it is DeviceState.LoggedIn || it is DeviceState.LoggedOut } - .collect { loadChangelogComponent() } - } - } - } - - private fun loadChangelogComponent() { - findViewById<ComposeView>(R.id.compose_view).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) - setContent { - val state = changelogViewModel.uiState.collectAsState().value - if (state is ChangelogDialogUiState.Show) { - AppTheme { - ChangelogDialog( - changesList = state.changes, - version = BuildConfig.VERSION_NAME, - onDismiss = { changelogViewModel.dismissChangelogDialog() } - ) - } - } - } - changelogViewModel.refreshChangelogDialogUiState() - } - } - @Suppress("DEPRECATION") private fun requestVpnPermission() { val intent = VpnService.prepare(this) @@ -249,97 +102,9 @@ open class MainActivity : FragmentActivity() { startActivityForResult(intent, 0) } - private fun openLaunchView() { - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, LoadingFragment()) - commitAllowingStateLoss() - } - } - - private fun openPrivacyDisclaimerFragment() { - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, PrivacyDisclaimerFragment()) - commitAllowingStateLoss() - } - } - - private suspend fun openLoggedInView(accountToken: String, shouldDelayLogin: Boolean) { - val isNewAccount = accountToken == accountRepository.cachedCreatedAccount.value - val isExpired = isNewAccount.not() && isExpired(LOGIN_AWAIT_EXPIRY_MILLIS) - - val fragment = - when { - isNewAccount -> WelcomeFragment() - isExpired -> { - if (shouldDelayLogin) { - delay(LOGIN_DELAY_MILLIS) - } - OutOfTimeFragment() - } - else -> { - if (shouldDelayLogin) { - delay(LOGIN_DELAY_MILLIS) - } - ConnectFragment() - } - } - - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, fragment) - commitAllowingStateLoss() - } - } - - private suspend fun isExpired(timeoutMillis: Long): Boolean { - return withTimeoutOrNull(timeoutMillis) { - accountRepository.accountExpiryState - .onSubscription { accountRepository.fetchAccountExpiry() } - .filter { it is AccountExpiry.Available } - .map { it.date()?.isBeforeNow } - .first() - } - ?: false - } - - private fun openLoginView() { - clearBackStack() - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, LoginFragment()) - commitAllowingStateLoss() - } - } - - private fun openRevokedView() { - clearBackStack() - supportFragmentManager.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, DeviceRevokedFragment()) - commitAllowingStateLoss() - } - } - - fun clearBackStack() { - supportFragmentManager.apply { - if (backStackEntryCount > 0) { - val firstEntry = getBackStackEntryAt(0) - popBackStack(firstEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - } - } - private fun checkForNotificationPermission() { if (isNotificationPermissionGranted().not()) { requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } - - companion object { - private const val LOGIN_DELAY_MILLIS = 1000L - private const val LOGIN_AWAIT_EXPIRY_MILLIS = 1000L - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt index 7f44b0c7d4..4e1d773f1e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt @@ -3,6 +3,8 @@ package net.mullvad.mullvadvpn.ui.serviceconnection import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build import android.os.IBinder import android.os.Messenger import android.util.Log @@ -76,7 +78,15 @@ class ServiceConnectionManager(private val context: Context) : MessageHandler { } context.startService(intent) - context.bindService(intent, serviceConnection, 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + context.bindService( + intent, + serviceConnection, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED + ) + } else { + context.bindService(intent, serviceConnection, 0) + } isBound = true } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt new file mode 100644 index 0000000000..fd004562a3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/ContextExtensions.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.util + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> this.baseContext.getActivity() + else -> null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt index 9be4b13b59..39cee0342a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -1,9 +1,6 @@ package net.mullvad.mullvadvpn.util -import android.view.animation.Animation -import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -12,31 +9,10 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retryWhen -import kotlinx.coroutines.flow.take import kotlinx.coroutines.withTimeoutOrNull -import net.mullvad.mullvadvpn.lib.common.util.safeOffer import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.talpid.util.EventNotifier -fun Animation.transitionFinished(): Flow<Unit> = - callbackFlow { - val transitionAnimationListener = - object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - safeOffer(Unit) - } - - override fun onAnimationRepeat(animation: Animation?) {} - } - setAnimationListener(transitionAnimationListener) - awaitClose { - Dispatchers.Main.dispatch(EmptyCoroutineContext) { setAnimationListener(null) } - } - } - .take(1) - fun <R> Flow<ServiceConnectionState>.flatMapReadyConnectionOrDefault( default: Flow<R>, transform: (value: ServiceConnectionState.ConnectedReady) -> Flow<R> @@ -134,6 +110,13 @@ inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine( suspend inline fun <T> Deferred<T>.awaitWithTimeoutOrNull(timeout: Long) = withTimeoutOrNull(timeout) { await() } +fun <T> Deferred<T>.getOrDefault(default: T) = + try { + getCompleted() + } catch (e: IllegalStateException) { + default + } + @Suppress("UNCHECKED_CAST") suspend inline fun <T> Flow<T>.retryWithExponentialBackOff( maxAttempts: Int, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt index 0a5167da2e..0f0708707e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt @@ -16,8 +16,8 @@ fun Constraint<Port>.isCustom() = is Constraint.Only -> !WIREGUARD_PRESET_PORTS.contains(this.value.value) } -fun Constraint<Port>.toDisplayCustomPort() = +fun Constraint<Port>.toValueOrNull() = when (this) { - is Constraint.Any -> "" - is Constraint.Only -> this.value.value.toString() + is Constraint.Any -> null + is Constraint.Only -> this.value.value } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt index 439d0c3c3b..eda8674802 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt @@ -3,15 +3,16 @@ package net.mullvad.mullvadvpn.viewmodel import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.DeviceState @@ -20,7 +21,6 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.mullvadvpn.util.toPaymentState import org.joda.time.DateTime @@ -30,32 +30,25 @@ class AccountViewModel( private val paymentUseCase: PaymentUseCase, deviceRepository: DeviceRepository ) : ViewModel() { - - private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1) - private val _enterTransitionEndAction = MutableSharedFlow<Unit>() - - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() val uiState: StateFlow<AccountUiState> = combine( deviceRepository.deviceState, accountRepository.accountExpiryState, - paymentUseCase.purchaseResult, paymentUseCase.paymentAvailability - ) { deviceState, accountExpiry, purchaseResult, paymentAvailability -> + ) { deviceState, accountExpiry, paymentAvailability -> AccountUiState( deviceName = deviceState.deviceName() ?: "", accountNumber = deviceState.token() ?: "", accountExpiry = accountExpiry.date(), - paymentDialogData = purchaseResult?.toPaymentDialogData(), + showSitePayment = IS_PLAY_BUILD.not(), billingPaymentState = paymentAvailability?.toPaymentState() ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AccountUiState.default()) - @Suppress("konsist.ensure public properties use permitted names") - val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() - init { updateAccountExpiry() verifyPurchases() @@ -64,7 +57,7 @@ class AccountViewModel( fun onManageAccountClick() { viewModelScope.launch { - _uiSideEffect.tryEmit( + _uiSideEffect.send( UiSideEffect.OpenAccountManagementPageInBrowser( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) @@ -74,10 +67,11 @@ class AccountViewModel( fun onLogoutClick() { accountRepository.logout() + viewModelScope.launch { _uiSideEffect.send(UiSideEffect.NavigateToLogin) } } - fun onTransitionAnimationEnd() { - viewModelScope.launch { _enterTransitionEndAction.emit(Unit) } + fun onCopyAccountNumber(accountNumber: String) { + viewModelScope.launch { _uiSideEffect.send(UiSideEffect.CopyAccountNumber(accountNumber)) } } fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { @@ -116,7 +110,11 @@ class AccountViewModel( } sealed class UiSideEffect { + data object NavigateToLogin : UiSideEffect() + data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect() + + data class CopyAccountNumber(val accountNumber: String) : UiSideEffect() } } @@ -124,8 +122,8 @@ data class AccountUiState( val deviceName: String?, val accountNumber: String?, val accountExpiry: DateTime?, + val showSitePayment: Boolean, val billingPaymentState: PaymentState? = null, - val paymentDialogData: PaymentDialogData? = null ) { companion object { fun default() = @@ -133,8 +131,8 @@ data class AccountUiState( deviceName = DeviceState.Unknown.deviceName(), accountNumber = DeviceState.Unknown.token(), accountExpiry = AccountExpiry.Missing.date(), + showSitePayment = false, billingPaymentState = PaymentState.Loading, - paymentDialogData = null, ) } } 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 f6549cded6..6b17592b8e 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 @@ -1,8 +1,13 @@ package net.mullvad.mullvadvpn.viewmodel +import android.os.Parcelable import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.repository.ChangelogRepository class ChangelogViewModel( @@ -10,34 +15,26 @@ class ChangelogViewModel( private val buildVersionCode: Int, private val alwaysShowChangelog: Boolean ) : ViewModel() { - private val _uiState = MutableStateFlow<ChangelogDialogUiState>(ChangelogDialogUiState.Hide) - val uiState = _uiState.asStateFlow() - fun refreshChangelogDialogUiState() { - val shouldShowChangelogDialog = - alwaysShowChangelog || - changelogRepository.getVersionCodeOfMostRecentChangelogShowed() < buildVersionCode - _uiState.value = - if (shouldShowChangelogDialog) { - val changelogList = changelogRepository.getLastVersionChanges() - if (changelogList.isNotEmpty()) { - ChangelogDialogUiState.Show(changelogList) - } else { - ChangelogDialogUiState.Hide - } - } else { - ChangelogDialogUiState.Hide - } + private val _uiSideEffect = MutableSharedFlow<Changelog>(replay = 1, extraBufferCapacity = 1) + val uiSideEffect: SharedFlow<Changelog> = _uiSideEffect + + init { + if (shouldShowChangelog()) { + val changelog = + Changelog(BuildConfig.VERSION_NAME, changelogRepository.getLastVersionChanges()) + viewModelScope.launch { _uiSideEffect.emit(changelog) } + } } - fun dismissChangelogDialog() { + fun markChangelogAsRead() { changelogRepository.setVersionCodeOfMostRecentChangelogShowed(buildVersionCode) - _uiState.value = ChangelogDialogUiState.Hide } -} -sealed class ChangelogDialogUiState { - data class Show(val changes: List<String>) : ChangelogDialogUiState() - - data object Hide : ChangelogDialogUiState() + private fun shouldShowChangelog(): Boolean = + alwaysShowChangelog || + (changelogRepository.getVersionCodeOfMostRecentChangelogShowed() < buildVersionCode && + changelogRepository.getLastVersionChanges().isNotEmpty()) } + +@Parcelize data class Changelog(val version: String, val changes: List<String>) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index 76c290f439..976eed1270 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -3,20 +3,22 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -33,6 +35,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier @@ -41,7 +44,6 @@ import net.mullvad.mullvadvpn.util.daysFromNow import net.mullvad.mullvadvpn.util.toInAddress import net.mullvad.mullvadvpn.util.toOutAddress import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.tunnel.ErrorStateCause @OptIn(FlowPreview::class) class ConnectViewModel( @@ -51,10 +53,11 @@ class ConnectViewModel( private val inAppNotificationController: InAppNotificationController, private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase, private val relayListUseCase: RelayListUseCase, + private val outOfTimeUseCase: OutOfTimeUseCase, private val paymentUseCase: PaymentUseCase ) : ViewModel() { - private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1) - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() private val _shared: SharedFlow<ServiceConnectionContainer> = serviceConnectionManager.connectionState @@ -90,9 +93,6 @@ class ConnectViewModel( accountExpiry, isTunnelInfoExpanded, deviceName -> - if (tunnelRealState.isTunnelErrorStateDueToExpiredAccount()) { - _uiSideEffect.tryEmit(UiSideEffect.OpenOutOfTimeView) - } ConnectUiState( location = when (tunnelRealState) { @@ -136,9 +136,12 @@ class ConnectViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ConnectUiState.INITIAL) init { - // The create account cache is no longer needed as we have successfully reached the connect - // screen - accountRepository.clearCreatedAccountCache() + viewModelScope.launch { + // This once we get isOutOfTime true we will navigate to OutOfTime view. + outOfTimeUseCase.isOutOfTime().first { it == true } + _uiSideEffect.send(UiSideEffect.OutOfTime) + } + viewModelScope.launch { paymentUseCase.verifyPurchases { accountRepository.fetchAccountExpiry() } } @@ -155,12 +158,6 @@ class ConnectViewModel( private fun ConnectionProxy.tunnelRealStateFlow(): Flow<TunnelState> = callbackFlowFromNotifier(this.onStateChange) - private fun TunnelState.isTunnelErrorStateDueToExpiredAccount(): Boolean { - return ((this as? TunnelState.Error)?.errorState?.cause as? ErrorStateCause.AuthFailed) - ?.isCausedByExpiredAccount() - ?: false - } - fun toggleTunnelInfoExpansion() { _isTunnelInfoExpanded.value = _isTunnelInfoExpanded.value.not() } @@ -183,7 +180,7 @@ class ConnectViewModel( fun onManageAccountClick() { viewModelScope.launch { - _uiSideEffect.tryEmit( + _uiSideEffect.send( UiSideEffect.OpenAccountManagementPageInBrowser( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) @@ -198,7 +195,7 @@ class ConnectViewModel( sealed interface UiSideEffect { data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect - data object OpenOutOfTimeView : UiSideEffect + data object OutOfTime : UiSideEffect } companion object { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt index 98648e0015..48a8782d04 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt @@ -5,14 +5,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -33,22 +34,15 @@ class DeviceListViewModel( private val resources: Resources, private val dispatcher: CoroutineDispatcher = Dispatchers.Default ) : ViewModel() { - private val _stagedDeviceId = MutableStateFlow<DeviceId?>(null) private val _loadingDevices = MutableStateFlow<List<DeviceId>>(emptyList()) - private val _toastMessages = MutableSharedFlow<String>(extraBufferCapacity = 1) - @Suppress("konsist.ensure public properties use permitted names") - val toastMessages = _toastMessages.asSharedFlow() + private val _uiSideEffect = Channel<DeviceListSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() - @Suppress("konsist.ensure public properties use permitted names") - var accountToken: String? = null private var cachedDeviceList: List<Device>? = null val uiState = - combine(deviceRepository.deviceList, _stagedDeviceId, _loadingDevices) { - deviceList, - stagedDeviceId, - loadingDevices -> + combine(deviceRepository.deviceList, _loadingDevices) { deviceList, loadingDevices -> val devices = if (deviceList is DeviceList.Available) { deviceList.devices.also { cachedDeviceList = it } @@ -65,66 +59,47 @@ class DeviceListViewModel( ) } val isLoading = devices == null - val stagedDevice = devices?.firstOrNull { device -> device.id == stagedDeviceId } DeviceListUiState( deviceUiItems = deviceUiItems ?: emptyList(), isLoading = isLoading, - stagedDevice = stagedDevice ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), DeviceListUiState.INITIAL) - fun stageDeviceForRemoval(deviceId: DeviceId) { - _stagedDeviceId.value = deviceId - } - - fun clearStagedDevice() { - _stagedDeviceId.value = null - } + fun removeDevice(accountToken: String, deviceIdToRemove: DeviceId) { - fun confirmRemovalOfStagedDevice() { - val token = accountToken - val stagedDeviceId = _stagedDeviceId.value - - if (token != null && stagedDeviceId != null) { - viewModelScope.launch { - withContext(dispatcher) { - val result = - withTimeoutOrNull(DEVICE_REMOVAL_TIMEOUT_MILLIS) { - deviceRepository.deviceRemovalEvent - .onSubscription { - clearStagedDevice() - setLoadingDevice(stagedDeviceId) - deviceRepository.removeDevice(token, stagedDeviceId) - } - .filter { (deviceId, result) -> - deviceId == stagedDeviceId && result == RemoveDeviceResult.Ok - } - .first() - } + viewModelScope.launch { + withContext(dispatcher) { + val result = + withTimeoutOrNull(DEVICE_REMOVAL_TIMEOUT_MILLIS) { + deviceRepository.deviceRemovalEvent + .onSubscription { + setLoadingDevice(deviceIdToRemove) + deviceRepository.removeDevice(accountToken, deviceIdToRemove) + } + .filter { (deviceId, result) -> + deviceId == deviceIdToRemove && result == RemoveDeviceResult.Ok + } + .first() + } - clearLoadingDevice(stagedDeviceId) + clearLoadingDevice(deviceIdToRemove) - if (result == null) { - _toastMessages.tryEmit( + if (result == null) { + _uiSideEffect.send( + DeviceListSideEffect.ShowToast( resources.getString(R.string.failed_to_remove_device) ) - refreshDeviceList() - } + ) + refreshDeviceList(accountToken) } } - } else { - _toastMessages.tryEmit(resources.getString(R.string.error_occurred)) - clearLoadingDevices() - clearStagedDevice() - refreshDeviceList() } } fun refreshDeviceState() = deviceRepository.refreshDeviceState() - fun refreshDeviceList() = - accountToken?.let { token -> deviceRepository.refreshDeviceList(token) } + fun refreshDeviceList(accountToken: String) = deviceRepository.refreshDeviceList(accountToken) private fun setLoadingDevice(deviceId: DeviceId) { _loadingDevices.value = _loadingDevices.value.toMutableList().apply { add(deviceId) } @@ -134,11 +109,11 @@ class DeviceListViewModel( _loadingDevices.value = _loadingDevices.value.toMutableList().apply { remove(deviceId) } } - private fun clearLoadingDevices() { - _loadingDevices.value = emptyList() - } - companion object { private const val DEVICE_REMOVAL_TIMEOUT_MILLIS = 5000L } } + +sealed interface DeviceListSideEffect { + data class ShowToast(val text: String) : DeviceListSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt new file mode 100644 index 0000000000..b931d4a7ba --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt @@ -0,0 +1,171 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import java.net.InetAddress +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.constant.EMPTY_STRING +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.repository.SettingsRepository +import org.apache.commons.validator.routines.InetAddressValidator + +sealed interface DnsDialogSideEffect { + data object Complete : DnsDialogSideEffect +} + +data class DnsDialogViewModelState( + val customDnsList: List<InetAddress>, + val isAllowLanEnabled: Boolean +) { + companion object { + fun default() = DnsDialogViewModelState(emptyList(), false) + } +} + +data class DnsDialogViewState( + val ipAddress: String, + val validationResult: ValidationResult = ValidationResult.Success, + val isLocal: Boolean, + val isAllowLanEnabled: Boolean, + val isNewEntry: Boolean +) { + + fun isValid() = (validationResult is ValidationResult.Success) + + sealed class ValidationResult { + data object Success : ValidationResult() + + data object InvalidAddress : ValidationResult() + + data object DuplicateAddress : ValidationResult() + } +} + +class DnsDialogViewModel( + private val repository: SettingsRepository, + private val inetAddressValidator: InetAddressValidator, + private val index: Int? = null, + initialValue: String?, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + + private val _ipAddressInput = MutableStateFlow(initialValue ?: EMPTY_STRING) + + private val vmState = + repository.settingsUpdates + .filterNotNull() + .map { + val customDnsList = it.addresses() + val isAllowLanEnabled = it.allowLan + DnsDialogViewModelState(customDnsList, isAllowLanEnabled = isAllowLanEnabled) + } + .stateIn(viewModelScope, SharingStarted.Lazily, DnsDialogViewModelState.default()) + + val uiState: StateFlow<DnsDialogViewState> = + combine(_ipAddressInput, vmState, ::createViewState) + .stateIn( + viewModelScope, + SharingStarted.Lazily, + createViewState(_ipAddressInput.value, vmState.value) + ) + + private val _uiSideEffect = Channel<DnsDialogSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + private fun createViewState(ipAddress: String, vmState: DnsDialogViewModelState) = + DnsDialogViewState( + ipAddress, + ipAddress.validateDnsEntry(index, vmState.customDnsList), + ipAddress.isLocalAddress(), + isAllowLanEnabled = vmState.isAllowLanEnabled, + index == null + ) + + private fun String.validateDnsEntry( + index: Int?, + dnsList: List<InetAddress> + ): DnsDialogViewState.ValidationResult = + when { + this.isBlank() || !this.isValidIp() -> { + DnsDialogViewState.ValidationResult.InvalidAddress + } + InetAddress.getByName(this).isDuplicateDnsEntry(index, dnsList) -> { + DnsDialogViewState.ValidationResult.DuplicateAddress + } + else -> DnsDialogViewState.ValidationResult.Success + } + + fun onDnsInputChange(ipAddress: String) { + _ipAddressInput.value = ipAddress + } + + fun onSaveDnsClick() = + viewModelScope.launch(dispatcher) { + if (!uiState.value.isValid()) return@launch + + val address = InetAddress.getByName(uiState.value.ipAddress) + + repository.updateCustomDnsList { + it.toMutableList().apply { + if (index != null) { + set(index, address) + } else { + add(address) + } + } + } + + _uiSideEffect.send(DnsDialogSideEffect.Complete) + } + + fun onRemoveDnsClick() = + viewModelScope.launch(dispatcher) { + repository.updateCustomDnsList { + it.filter { it.hostAddress != uiState.value.ipAddress } + } + _uiSideEffect.send(DnsDialogSideEffect.Complete) + } + + private fun String.isValidIp(): Boolean { + return inetAddressValidator.isValid(this) + } + + private fun String.isLocalAddress(): Boolean { + return isValidIp() && InetAddress.getByName(this).isLocalAddress() + } + + private fun InetAddress.isLocalAddress(): Boolean { + return isLinkLocalAddress || isSiteLocalAddress + } + + private fun InetAddress.isDuplicateDnsEntry( + currentIndex: Int? = null, + dnsList: List<InetAddress> + ): Boolean = + dnsList.withIndex().any { (index, entry) -> + if (index == currentIndex) { + // Ignore current index, it may be the same + false + } else { + entry == this + } + } + + private fun Settings.addresses() = tunnelOptions.dnsOptions.customOptions.addresses + + companion object { + private const val EMPTY_STRING = "" + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt index 9178a22110..3f95d79193 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt @@ -2,13 +2,14 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.RelayFilterState @@ -23,8 +24,8 @@ import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase class FilterViewModel( private val relayListFilterUseCase: RelayListFilterUseCase, ) : ViewModel() { - private val _uiSideEffect = MutableSharedFlow<Unit>() - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel<FilterScreenSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() private val selectedOwnership = MutableStateFlow<Ownership?>(null) private val selectedProviders = MutableStateFlow<List<Provider>>(emptyList()) @@ -101,7 +102,11 @@ class FilterViewModel( newSelectedOwnership, newSelectedProviders ) - _uiSideEffect.emit(Unit) + _uiSideEffect.send(FilterScreenSideEffect.CloseScreen) } } } + +sealed interface FilterScreenSideEffect { + data object CloseScreen : FilterScreenSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt index 34648f1d53..2de5d42a05 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt @@ -5,13 +5,17 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -23,6 +27,7 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.Success import net.mullvad.mullvadvpn.compose.state.LoginUiState import net.mullvad.mullvadvpn.constant.LOGIN_TIMEOUT_MILLIS import net.mullvad.mullvadvpn.model.AccountCreationResult +import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.AccountToken import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.repository.AccountRepository @@ -30,6 +35,7 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.util.awaitWithTimeoutOrNull +import net.mullvad.mullvadvpn.util.getOrDefault private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L @@ -38,6 +44,8 @@ sealed interface LoginUiSideEffect { data object NavigateToConnect : LoginUiSideEffect + data object NavigateToOutOfTime : LoginUiSideEffect + data class TooManyDevices(val accountToken: AccountToken) : LoginUiSideEffect } @@ -51,8 +59,8 @@ class LoginViewModel( private val _loginState = MutableStateFlow(LoginUiState.INITIAL.loginState) private val _loginInput = MutableStateFlow(LoginUiState.INITIAL.accountNumberInput) - private val _uiSideEffect = MutableSharedFlow<LoginUiSideEffect>(extraBufferCapacity = 1) - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel<LoginUiSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() private val _uiState = combine( @@ -95,8 +103,19 @@ class LoginViewModel( when (val result = loginDeferred.awaitWithTimeoutOrNull(LOGIN_TIMEOUT_MILLIS)) { LoginResult.Ok -> { launch { + val isOutOfTimeDeferred = async { + accountRepository.accountExpiryState + .filterIsInstance<AccountExpiry.Available>() + .map { it.expiryDateTime.isBeforeNow } + .first() + } delay(1000) - _uiSideEffect.emit(LoginUiSideEffect.NavigateToConnect) + val isOutOfTime = isOutOfTimeDeferred.getOrDefault(false) + if (isOutOfTime) { + _uiSideEffect.send(LoginUiSideEffect.NavigateToOutOfTime) + } else { + _uiSideEffect.send(LoginUiSideEffect.NavigateToConnect) + } } newDeviceNotificationUseCase.newDeviceCreated() Success @@ -114,10 +133,11 @@ class LoginViewModel( if (refreshResult.isAvailable()) { // Navigate to device list - _uiSideEffect.emit( + + _uiSideEffect.send( LoginUiSideEffect.TooManyDevices(AccountToken(accountToken)) ) - return@launch + Idle() } else { // Failed to fetch devices list Idle(LoginError.Unknown(result.toString())) @@ -137,7 +157,7 @@ class LoginViewModel( private suspend fun AccountCreationResult.mapToUiState(): LoginState? { return if (this is AccountCreationResult.Success) { - _uiSideEffect.emit(LoginUiSideEffect.NavigateToWelcome) + _uiSideEffect.send(LoginUiSideEffect.NavigateToWelcome) null } else { Idle(LoginError.UnableToCreateAccount) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt new file mode 100644 index 0000000000..db324e0b13 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt @@ -0,0 +1,39 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.util.isValidMtu + +class MtuDialogViewModel( + private val repository: SettingsRepository, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : ViewModel() { + + private val _uiSideEffect = Channel<MtuDialogSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun onSaveClick(mtuValue: Int) = + viewModelScope.launch(dispatcher) { + if (mtuValue.isValidMtu()) { + repository.setWireguardMtu(mtuValue) + } + _uiSideEffect.send(MtuDialogSideEffect.Complete) + } + + fun onRestoreClick() = + viewModelScope.launch(dispatcher) { + repository.setWireguardMtu(null) + _uiSideEffect.send(MtuDialogSideEffect.Complete) + } +} + +sealed interface MtuDialogSideEffect { + data object Complete : MtuDialogSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt new file mode 100644 index 0000000000..eff31be0ee --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt @@ -0,0 +1,119 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import com.ramcosta.composedestinations.spec.DestinationSpec +import com.ramcosta.composedestinations.utils.destination +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination +import net.mullvad.mullvadvpn.compose.destinations.SplashDestination +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState + +private val noServiceDestinations = listOf(SplashDestination, PrivacyDisclaimerDestination) + +class NoDaemonViewModel(serviceConnectionManager: ServiceConnectionManager) : + ViewModel(), LifecycleEventObserver, NavController.OnDestinationChangedListener { + + private val lifecycleFlow: MutableSharedFlow<Lifecycle.Event> = MutableSharedFlow() + private val destinationFlow: MutableSharedFlow<DestinationSpec<*>> = MutableSharedFlow() + + @OptIn(FlowPreview::class) + val uiSideEffect = + combine(lifecycleFlow, serviceConnectionManager.connectionState, destinationFlow) { + event, + connEvent, + destination -> + toDaemonState(event, connEvent, destination) + } + .map { state -> + when (state) { + is DaemonState.Show -> DaemonScreenEvent.Show + is DaemonState.Hidden.Ignored -> DaemonScreenEvent.Remove + DaemonState.Hidden.Connected -> DaemonScreenEvent.Remove + } + } + .distinctUntilChanged() + // We debounce any disconnected state to let the UI have some time to connect after a + // onStart/onStop event. + .debounce { + when (it) { + is DaemonScreenEvent.Remove -> 0.seconds + is DaemonScreenEvent.Show -> SERVICE_DISCONNECT_DEBOUNCE + } + } + .distinctUntilChanged() + .shareIn(viewModelScope, SharingStarted.Eagerly) + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + viewModelScope.launch { lifecycleFlow.emit(event) } + } + + private fun toDaemonState( + lifecycleEvent: Lifecycle.Event, + serviceState: ServiceConnectionState, + currentDestination: DestinationSpec<*> + ): DaemonState { + // In these destinations we don't care about showing the NoDaemonScreen + if (currentDestination in noServiceDestinations) { + return DaemonState.Hidden.Ignored + } + + return if (lifecycleEvent.targetState.isAtLeast(Lifecycle.State.STARTED)) { + // If we are started we want to show the overlay if we are not connected to daemon + when (serviceState) { + is ServiceConnectionState.ConnectedNotReady, + ServiceConnectionState.Disconnected -> DaemonState.Show + is ServiceConnectionState.ConnectedReady -> DaemonState.Hidden.Connected + } + } else { + // If we are stopped we intentionally stop service and don't care about showing overlay. + DaemonState.Hidden.Ignored + } + } + + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle? + ) { + viewModelScope.launch { + controller.currentBackStackEntry?.destination()?.let { destinationFlow.emit(it) } + } + } + + companion object { + private val SERVICE_DISCONNECT_DEBOUNCE = 2.seconds + } +} + +sealed interface DaemonState { + data object Show : DaemonState + + sealed interface Hidden : DaemonState { + data object Ignored : Hidden + + data object Connected : Hidden + } +} + +sealed interface DaemonScreenEvent { + data object Show : DaemonScreenEvent + + data object Remove : DaemonScreenEvent +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt index 001469c26b..8c9a39bbdc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt @@ -1,23 +1,23 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL -import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository @@ -26,22 +26,22 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.mullvadvpn.util.toPaymentState -import org.joda.time.DateTime class OutOfTimeViewModel( private val accountRepository: AccountRepository, private val serviceConnectionManager: ServiceConnectionManager, private val deviceRepository: DeviceRepository, private val paymentUseCase: PaymentUseCase, + private val outOfTimeUseCase: OutOfTimeUseCase, private val pollAccountExpiry: Boolean = true, ) : ViewModel() { - private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1) - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() val uiState = serviceConnectionManager.connectionState @@ -57,13 +57,12 @@ class OutOfTimeViewModel( serviceConnection.connectionProxy.tunnelStateFlow(), deviceRepository.deviceState, paymentUseCase.paymentAvailability, - paymentUseCase.purchaseResult - ) { tunnelState, deviceState, paymentAvailability, purchaseResult -> + ) { tunnelState, deviceState, paymentAvailability -> OutOfTimeUiState( tunnelState = tunnelState, deviceName = deviceState.deviceName() ?: "", + showSitePayment = IS_PLAY_BUILD.not(), billingPaymentState = paymentAvailability?.toPaymentState(), - paymentDialogData = purchaseResult?.toPaymentDialogData() ) } } @@ -71,18 +70,11 @@ class OutOfTimeViewModel( init { viewModelScope.launch { - accountRepository.accountExpiryState.collectLatest { accountExpiry -> - accountExpiry.date()?.let { expiry -> - val tomorrow = DateTime.now().plusHours(20) - - if (expiry.isAfter(tomorrow)) { - // Reset purchase state - paymentUseCase.resetPurchaseResult() - _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) - } - } - } + outOfTimeUseCase.isOutOfTime().first { it == false } + paymentUseCase.resetPurchaseResult() + _uiSideEffect.send(UiSideEffect.OpenConnectScreen) } + viewModelScope.launch { while (pollAccountExpiry) { updateAccountExpiry() @@ -98,7 +90,7 @@ class OutOfTimeViewModel( fun onSitePaymentClick() { viewModelScope.launch { - _uiSideEffect.tryEmit( + _uiSideEffect.send( UiSideEffect.OpenAccountView( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) @@ -110,10 +102,6 @@ class OutOfTimeViewModel( viewModelScope.launch { serviceConnectionManager.connectionProxy()?.disconnect() } } - fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { - viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } - } - private fun verifyPurchases() { viewModelScope.launch { paymentUseCase.verifyPurchases() @@ -132,7 +120,7 @@ class OutOfTimeViewModel( // should check payment availability and verify any purchases to handle potential errors. if (success) { updateAccountExpiry() - _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) + // _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) } else { fetchPaymentAvailability() verifyPurchases() // Attempt to verify again diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt new file mode 100644 index 0000000000..7f210721df --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt @@ -0,0 +1,46 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.app.Activity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult +import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.toPaymentDialogData + +class PaymentViewModel( + private val paymentUseCase: PaymentUseCase, +) : ViewModel() { + val uiState: StateFlow<PaymentUiState> = + paymentUseCase.purchaseResult + .filterNot { + it is PurchaseResult.Completed.Cancelled || it is PurchaseResult.Error.BillingError + } + .map { PaymentUiState(it?.toPaymentDialogData()) } + .stateIn(viewModelScope, SharingStarted.Lazily, PaymentUiState(PaymentDialogData())) + + val uiSideEffect = + paymentUseCase.purchaseResult + .filter { + it is PurchaseResult.Completed.Cancelled || it is PurchaseResult.Error.BillingError + } + .map { PaymentUiSideEffect.PaymentCancelled } + + fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { + viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } + } +} + +data class PaymentUiState(val paymentDialogData: PaymentDialogData?) + +sealed interface PaymentUiSideEffect { + data object PaymentCancelled : PaymentUiSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt index c3b63bb818..f8e6b13f3d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt @@ -1,10 +1,27 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository class PrivacyDisclaimerViewModel( private val privacyDisclaimerRepository: PrivacyDisclaimerRepository ) : ViewModel() { - fun setPrivacyDisclosureAccepted() = privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + + private val _uiSideEffect = + Channel<PrivacyDisclaimerUiSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun setPrivacyDisclosureAccepted() { + privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + viewModelScope.launch { _uiSideEffect.send(PrivacyDisclaimerUiSideEffect.NavigateToLogin) } + } +} + +sealed interface PrivacyDisclaimerUiSideEffect { + data object NavigateToLogin : PrivacyDisclaimerUiSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt index 82e66b0c4b..52311f82a0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt @@ -3,10 +3,13 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.constant.MINIMUM_LOADING_TIME_MILLIS @@ -16,7 +19,6 @@ import net.mullvad.mullvadvpn.dataproxy.UserReport import net.mullvad.mullvadvpn.repository.ProblemReportRepository data class ReportProblemUiState( - val showConfirmNoEmail: Boolean = false, val sendingState: SendingReportUiState? = null, val email: String = "", val description: String = "", @@ -30,22 +32,23 @@ sealed interface SendingReportUiState { data class Error(val error: SendProblemReportResult.Error) : SendingReportUiState } +sealed interface ReportProblemSideEffect { + data object ShowConfirmNoEmail : ReportProblemSideEffect +} + class ReportProblemViewModel( private val mullvadProblemReporter: MullvadProblemReport, private val problemReportRepository: ProblemReportRepository ) : ViewModel() { - private val showConfirmNoEmail = MutableStateFlow(false) private val sendingState: MutableStateFlow<SendingReportUiState?> = MutableStateFlow(null) val uiState = combine( - showConfirmNoEmail, sendingState, problemReportRepository.problemReport, - ) { showConfirmNoEmail, pendingState, userReport -> + ) { pendingState, userReport -> ReportProblemUiState( - showConfirmNoEmail = showConfirmNoEmail, sendingState = pendingState, email = userReport.email ?: "", description = userReport.description, @@ -53,18 +56,17 @@ class ReportProblemViewModel( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ReportProblemUiState()) - fun sendReport( - email: String, - description: String, - ) { + private val _uiSideEffect = Channel<ReportProblemSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun sendReport(email: String, description: String, skipEmptyEmailCheck: Boolean = false) { viewModelScope.launch { val userEmail = email.trim() val nullableEmail = if (email.isEmpty()) null else userEmail - if (shouldShowConfirmNoEmail(nullableEmail)) { - showConfirmNoEmail.tryEmit(true) + if (!skipEmptyEmailCheck && shouldShowConfirmNoEmail(nullableEmail)) { + _uiSideEffect.send(ReportProblemSideEffect.ShowConfirmNoEmail) } else { - sendingState.tryEmit(SendingReportUiState.Sending) - showConfirmNoEmail.tryEmit(false) + sendingState.emit(SendingReportUiState.Sending) // Ensure we show loading for at least MINIMUM_LOADING_TIME_MILLIS val deferredResult = async { @@ -87,10 +89,6 @@ class ReportProblemViewModel( sendingState.tryEmit(null) } - fun dismissConfirmNoEmail() { - showConfirmNoEmail.tryEmit(false) - } - fun updateEmail(email: String) { problemReportRepository.setEmail(email) } @@ -100,9 +98,7 @@ class ReportProblemViewModel( } private fun shouldShowConfirmNoEmail(userEmail: String?): Boolean = - userEmail.isNullOrEmpty() && - !uiState.value.showConfirmNoEmail && - uiState.value.sendingState !is SendingReportUiState + userEmail.isNullOrEmpty() && uiState.value.sendingState !is SendingReportUiState private fun SendProblemReportResult.toUiResult(email: String?): SendingReportUiState = when (this) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt index caddae313b..dc9d5e7d6f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt @@ -2,12 +2,13 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState @@ -28,9 +29,6 @@ class SelectLocationViewModel( private val relayListUseCase: RelayListUseCase, private val relayListFilterUseCase: RelayListFilterUseCase ) : ViewModel() { - - private val _closeAction = MutableSharedFlow<Unit>() - private val _enterTransitionEndAction = MutableSharedFlow<Unit>() private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) val uiState = @@ -83,20 +81,13 @@ class SelectLocationViewModel( SelectLocationUiState.Loading ) - @Suppress("konsist.ensure public properties use permitted names") - val uiCloseAction = _closeAction.asSharedFlow() - - @Suppress("konsist.ensure public properties use permitted names") - val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() + private val _uiSideEffect = Channel<SelectLocationSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() fun selectRelay(relayItem: RelayItem) { relayListUseCase.updateSelectedRelayLocation(relayItem.location) serviceConnectionManager.connectionProxy()?.connect() - viewModelScope.launch { _closeAction.emit(Unit) } - } - - fun onTransitionAnimationEnd() { - viewModelScope.launch { _enterTransitionEndAction.emit(Unit) } + viewModelScope.launch { _uiSideEffect.send(SelectLocationSideEffect.CloseScreen) } } fun onSearchTermInput(searchTerm: String) { @@ -147,3 +138,7 @@ class SelectLocationViewModel( private const val EMPTY_SEARCH_TERM = "" } } + +sealed interface SelectLocationSideEffect { + data object CloseScreen : SelectLocationSideEffect +} 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 fb357dfe2a..8ef85cfca8 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 @@ -2,13 +2,10 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.SettingsUiState import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.repository.DeviceRepository @@ -18,7 +15,6 @@ class SettingsViewModel( deviceRepository: DeviceRepository, serviceConnectionManager: ServiceConnectionManager ) : ViewModel() { - private val _enterTransitionEndAction = MutableSharedFlow<Unit>() private val vmState: StateFlow<SettingsUiState> = combine(deviceRepository.deviceState, serviceConnectionManager.connectionState) { @@ -44,11 +40,4 @@ class SettingsViewModel( SharingStarted.WhileSubscribed(), SettingsUiState(appVersion = "", isLoggedIn = false, isUpdateAvailable = false) ) - - @Suppress("konsist.ensure public properties use permitted names") - val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() - - fun onTransitionAnimationEnd() { - viewModelScope.launch { _enterTransitionEndAction.emit(Unit) } - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt new file mode 100644 index 0000000000..8163fb9770 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt @@ -0,0 +1,110 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.onTimeout +import kotlinx.coroutines.selects.select +import net.mullvad.mullvadvpn.lib.ipc.Event +import net.mullvad.mullvadvpn.lib.ipc.MessageHandler +import net.mullvad.mullvadvpn.lib.ipc.events +import net.mullvad.mullvadvpn.model.AccountAndDevice +import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.model.DeviceState +import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository + +class SplashViewModel( + private val privacyDisclaimerRepository: PrivacyDisclaimerRepository, + private val deviceRepository: DeviceRepository, + private val messageHandler: MessageHandler, +) : ViewModel() { + + private val _uiSideEffect = Channel<SplashUiSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + fun start() { + viewModelScope.launch { + if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + _uiSideEffect.send(getStartDestination()) + } else { + _uiSideEffect.send(SplashUiSideEffect.NavigateToPrivacyDisclaimer) + } + } + } + + private suspend fun getStartDestination(): SplashUiSideEffect { + val deviceState = + deviceRepository.deviceState + .map { + when (it) { + DeviceState.Initial -> null + is DeviceState.LoggedIn -> + ValidStartDeviceState.LoggedIn(it.accountAndDevice) + DeviceState.LoggedOut -> ValidStartDeviceState.LoggedOut + DeviceState.Revoked -> ValidStartDeviceState.Revoked + DeviceState.Unknown -> null + } + } + .filterNotNull() + .first() + + return when (deviceState) { + ValidStartDeviceState.LoggedOut -> SplashUiSideEffect.NavigateToLogin + ValidStartDeviceState.Revoked -> SplashUiSideEffect.NavigateToRevoked + is ValidStartDeviceState.LoggedIn -> getLoggedInStartDestination() + } + } + + // We know the user is logged in, but we need to find out if their account has expired + private suspend fun getLoggedInStartDestination(): SplashUiSideEffect { + val expiry = + viewModelScope.async { + messageHandler.events<Event.AccountExpiryEvent>().map { it.expiry }.first() + } + + val accountExpiry = select { + expiry.onAwait { it } + // If we don't get a response within 1 second, assume the account expiry is Missing + onTimeout(1000) { AccountExpiry.Missing } + } + + return when (accountExpiry) { + is AccountExpiry.Available -> { + if (accountExpiry.expiryDateTime.isBeforeNow) { + SplashUiSideEffect.NavigateToOutOfTime + } else { + SplashUiSideEffect.NavigateToConnect + } + } + AccountExpiry.Missing -> SplashUiSideEffect.NavigateToConnect + } + } +} + +private sealed interface ValidStartDeviceState { + data class LoggedIn(val accountAndDevice: AccountAndDevice) : ValidStartDeviceState + + data object Revoked : ValidStartDeviceState + + data object LoggedOut : ValidStartDeviceState +} + +sealed interface SplashUiSideEffect { + data object NavigateToPrivacyDisclaimer : SplashUiSideEffect + + data object NavigateToRevoked : SplashUiSideEffect + + data object NavigateToLogin : SplashUiSideEffect + + data object NavigateToConnect : SplashUiSideEffect + + data object NavigateToOutOfTime : SplashUiSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt index 3691fc79b5..0cc55b992c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt @@ -45,7 +45,7 @@ class VoucherDialogViewModel( } .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) - var uiState = + val uiState = _shared .flatMapLatest { combine(vmState, voucherInput) { state, input -> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt index dfae3df539..80b51a811c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt @@ -7,12 +7,15 @@ import androidx.lifecycle.viewModelScope import java.net.InetAddress import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -32,29 +35,32 @@ import net.mullvad.mullvadvpn.model.WireguardConstraints import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.usecase.PortRangeUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase -import net.mullvad.mullvadvpn.util.isValidMtu -import org.apache.commons.validator.routines.InetAddressValidator +import net.mullvad.mullvadvpn.util.isCustom + +sealed interface VpnSettingsSideEffect { + data class ShowToast(val message: String) : VpnSettingsSideEffect + + data object NavigateToDnsDialog : VpnSettingsSideEffect +} class VpnSettingsViewModel( private val repository: SettingsRepository, - private val inetAddressValidator: InetAddressValidator, private val resources: Resources, portRangeUseCase: PortRangeUseCase, private val relayListUseCase: RelayListUseCase, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { - private val _toastMessages = MutableSharedFlow<String>(extraBufferCapacity = 1) - @Suppress("konsist.ensure public properties use permitted names") - val toastMessages = _toastMessages.asSharedFlow() + private val _uiSideEffect = Channel<VpnSettingsSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() - private val dialogState = MutableStateFlow<VpnSettingsDialogState?>(null) + private val customPort = MutableStateFlow<Constraint<Port>?>(null) private val vmState = - combine(repository.settingsUpdates, portRangeUseCase.portRanges(), dialogState) { + combine(repository.settingsUpdates, portRangeUseCase.portRanges(), customPort) { settings, portRanges, - dialogState -> + customWgPort -> VpnSettingsViewModelState( mtuValue = settings?.mtuString() ?: "", isAutoConnectEnabled = settings?.autoConnect ?: false, @@ -63,12 +69,11 @@ class VpnSettingsViewModel( customDnsList = settings?.addresses()?.asStringAddressList() ?: listOf(), contentBlockersOptions = settings?.contentBlockersSettings() ?: DefaultDnsOptions(), - isAllowLanEnabled = settings?.allowLan ?: false, selectedObfuscation = settings?.selectedObfuscationSettings() ?: SelectedObfuscation.Off, - dialogState = dialogState, quantumResistant = settings?.quantumResistant() ?: QuantumResistantState.Off, selectedWireguardPort = settings?.getWireguardPort() ?: Constraint.Any(), + customWireguardPort = customWgPort, availablePortRanges = portRanges ) } @@ -87,142 +92,20 @@ class VpnSettingsViewModel( VpnSettingsUiState.createDefault() ) - fun onMtuCellClick() { - dialogState.update { VpnSettingsDialogState.MtuDialog(vmState.value.mtuValue) } - } - - fun onSaveMtuClick(mtuValue: Int) = + init { viewModelScope.launch(dispatcher) { - if (mtuValue.isValidMtu()) { - repository.setWireguardMtu(mtuValue) - } - hideDialog() - } - - fun onRestoreMtuClick() = - viewModelScope.launch(dispatcher) { - repository.setWireguardMtu(null) - hideDialog() - } - - fun onCancelDialogClick() { - hideDialog() - } - - fun onLocalNetworkSharingInfoClick() { - dialogState.update { VpnSettingsDialogState.LocalNetworkSharingInfoDialog } - } - - fun onContentsBlockerInfoClick() { - dialogState.update { VpnSettingsDialogState.ContentBlockersInfoDialog } - } - - fun onCustomDnsInfoClick() { - dialogState.update { VpnSettingsDialogState.CustomDnsInfoDialog } - } - - fun onMalwareInfoClick() { - dialogState.update { VpnSettingsDialogState.MalwareInfoDialog } - } - - fun onDismissInfoClick() { - hideDialog() - } - - fun onDnsClick(index: Int? = null) { - val stagedDns = - if (index == null) { - StagedDns.NewDns( - item = CustomDnsItem.default(), - validationResult = StagedDns.ValidationResult.InvalidAddress - ) - } else { - vmState.value.customDnsList.getOrNull(index)?.let { listItem -> - StagedDns.EditDns(item = listItem, index = index) + val initialSettings = repository.settingsUpdates.filterNotNull().first() + customPort.update { + val initialPort = initialSettings.getWireguardPort() + if (initialPort.isCustom()) { + initialPort + } else { + null } } - - if (stagedDns != null) { - dialogState.update { VpnSettingsDialogState.DnsDialog(stagedDns) } - } - } - - fun onDnsInputChange(ipAddress: String) { - dialogState.update { state -> - val dialog = state as? VpnSettingsDialogState.DnsDialog ?: return - - val error = - when { - ipAddress.isBlank() || ipAddress.isValidIp().not() -> { - StagedDns.ValidationResult.InvalidAddress - } - ipAddress.isDuplicateDns((state.stagedDns as? StagedDns.EditDns)?.index) -> { - StagedDns.ValidationResult.DuplicateAddress - } - else -> StagedDns.ValidationResult.Success - } - - return@update VpnSettingsDialogState.DnsDialog( - stagedDns = - if (dialog.stagedDns is StagedDns.EditDns) { - StagedDns.EditDns( - item = - CustomDnsItem( - address = ipAddress, - isLocal = ipAddress.isLocalAddress() - ), - validationResult = error, - index = dialog.stagedDns.index - ) - } else { - StagedDns.NewDns( - item = - CustomDnsItem( - address = ipAddress, - isLocal = ipAddress.isLocalAddress() - ), - validationResult = error - ) - } - ) } } - fun onSaveDnsClick() = - viewModelScope.launch(dispatcher) { - val dialog = - vmState.value.dialogState as? VpnSettingsDialogState.DnsDialog ?: return@launch - - if (dialog.stagedDns.isValid().not()) return@launch - - val updatedList = - vmState.value.customDnsList - .toMutableList() - .map { it.address } - .toMutableList() - .let { activeList -> - if (dialog.stagedDns is StagedDns.EditDns) { - activeList - .apply { - set(dialog.stagedDns.index, dialog.stagedDns.item.address) - } - .asInetAddressList() - } else { - activeList - .apply { add(dialog.stagedDns.item.address) } - .asInetAddressList() - } - } - - repository.setDnsOptions( - isCustomDnsEnabled = true, - dnsList = updatedList, - contentBlockersOptions = vmState.value.contentBlockersOptions - ) - - hideDialog() - } - fun onToggleAutoConnect(isEnabled: Boolean) { viewModelScope.launch(dispatcher) { repository.setAutoConnect(isEnabled) } } @@ -231,12 +114,19 @@ class VpnSettingsViewModel( viewModelScope.launch(dispatcher) { repository.setLocalNetworkSharing(isEnabled) } } - fun onToggleDnsClick(isEnabled: Boolean) { - updateCustomDnsState(isEnabled) - if (isEnabled && vmState.value.customDnsList.isEmpty()) { - onDnsClick(null) + fun onDnsDialogDismissed() { + if (vmState.value.customDnsList.isEmpty()) { + onToggleCustomDns(false) + } + } + + fun onToggleCustomDns(enable: Boolean) { + repository.setDnsState(if (enable) DnsState.Custom else DnsState.Default) + if (enable && vmState.value.customDnsList.isEmpty()) { + viewModelScope.launch { _uiSideEffect.send(VpnSettingsSideEffect.NavigateToDnsDialog) } + } else { + showApplySettingChangesWarningToast() } - showApplySettingChangesWarningToast() } fun onToggleBlockAds(isEnabled: Boolean) { @@ -281,29 +171,9 @@ class VpnSettingsViewModel( showApplySettingChangesWarningToast() } - fun onRemoveDnsClick() = - viewModelScope.launch(dispatcher) { - val dialog = - vmState.value.dialogState as? VpnSettingsDialogState.DnsDialog ?: return@launch - - val updatedList = - vmState.value.customDnsList - .toMutableList() - .filter { it.address != dialog.stagedDns.item.address } - .map { it.address } - .asInetAddressList() - - repository.setDnsOptions( - isCustomDnsEnabled = vmState.value.isCustomDnsEnabled && updatedList.isNotEmpty(), - dnsList = updatedList, - contentBlockersOptions = vmState.value.contentBlockersOptions - ) - hideDialog() - } - fun onStopEvent() { if (vmState.value.customDnsList.isEmpty()) { - updateCustomDnsState(false) + repository.setDnsState(DnsState.Default) } } @@ -318,31 +188,27 @@ class VpnSettingsViewModel( } } - fun onObfuscationInfoClick() { - dialogState.update { VpnSettingsDialogState.ObfuscationInfoDialog } - } - fun onSelectQuantumResistanceSetting(quantumResistant: QuantumResistantState) { viewModelScope.launch(dispatcher) { repository.setWireguardQuantumResistant(quantumResistant) } } - fun onQuantumResistanceInfoClicked() { - dialogState.update { VpnSettingsDialogState.QuantumResistanceInfoDialog } - } - fun onWireguardPortSelected(port: Constraint<Port>) { + if (port.isCustom()) { + customPort.update { port } + } relayListUseCase.updateSelectedWireguardConstraints(WireguardConstraints(port = port)) - hideDialog() } - fun onWireguardPortInfoClicked() { - dialogState.update { VpnSettingsDialogState.WireguardPortInfoDialog } - } - - fun onShowCustomPortDialog() { - dialogState.update { VpnSettingsDialogState.CustomPortDialog } + fun resetCustomPort() { + customPort.update { null } + // If custom port was selected, update selection to be any. + if (vmState.value.selectedWireguardPort.isCustom()) { + relayListUseCase.updateSelectedWireguardConstraints( + WireguardConstraints(port = Constraint.Any()) + ) + } } private fun updateDefaultDnsOptionsViaRepository(contentBlockersOption: DefaultDnsOptions) = @@ -354,26 +220,6 @@ class VpnSettingsViewModel( ) } - private fun hideDialog() { - dialogState.update { null } - } - - fun onCancelDns() { - if ( - vmState.value.dialogState is VpnSettingsDialogState.DnsDialog && - vmState.value.customDnsList.isEmpty() - ) { - onToggleDnsClick(false) - } - hideDialog() - } - - private fun String.isDuplicateDns(stagedIndex: Int? = null): Boolean { - return vmState.value.customDnsList - .filterIndexed { index, listItem -> index != stagedIndex && listItem.address == this } - .isNotEmpty() - } - private fun List<String>.asInetAddressList(): List<InetAddress> { return try { map { InetAddress.getByName(it) } @@ -408,32 +254,20 @@ class VpnSettingsViewModel( (relaySettings as RelaySettings.Normal).relayConstraints.wireguardConstraints.port } - private fun String.isValidIp(): Boolean { - return inetAddressValidator.isValid(this) - } - - private fun String.isLocalAddress(): Boolean { - return isValidIp() && InetAddress.getByName(this).isLocalAddress() - } - private fun InetAddress.isLocalAddress(): Boolean { return isLinkLocalAddress || isSiteLocalAddress } - private fun updateCustomDnsState(isEnabled: Boolean) { - viewModelScope.launch(dispatcher) { - repository.setDnsOptions( - isEnabled, - dnsList = vmState.value.customDnsList.map { it.address }.asInetAddressList(), - contentBlockersOptions = vmState.value.contentBlockersOptions + private fun showApplySettingChangesWarningToast() { + viewModelScope.launch { + _uiSideEffect.send( + VpnSettingsSideEffect.ShowToast( + resources.getString(R.string.settings_changes_effect_warning_short) + ) ) } } - private fun showApplySettingChangesWarningToast() { - _toastMessages.tryEmit(resources.getString(R.string.settings_changes_effect_warning_short)) - } - companion object { private const val EMPTY_STRING = "" } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt index 2ebc2b397c..fd236e8405 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.viewmodel -import net.mullvad.mullvadvpn.compose.state.VpnSettingsDialog import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState import net.mullvad.mullvadvpn.model.Constraint import net.mullvad.mullvadvpn.model.DefaultDnsOptions @@ -14,14 +13,13 @@ data class VpnSettingsViewModelState( val isAutoConnectEnabled: Boolean, val isLocalNetworkSharingEnabled: Boolean, val isCustomDnsEnabled: Boolean, - val isAllowLanEnabled: Boolean, val customDnsList: List<CustomDnsItem>, val contentBlockersOptions: DefaultDnsOptions, val selectedObfuscation: SelectedObfuscation, val quantumResistant: QuantumResistantState, val selectedWireguardPort: Constraint<Port>, + val customWireguardPort: Constraint<Port>?, val availablePortRanges: List<PortRange>, - val dialogState: VpnSettingsDialogState?, ) { fun toUiState(): VpnSettingsUiState = VpnSettingsUiState( @@ -31,12 +29,11 @@ data class VpnSettingsViewModelState( isCustomDnsEnabled, customDnsList, contentBlockersOptions, - isAllowLanEnabled, selectedObfuscation, quantumResistant, selectedWireguardPort, + customWireguardPort, availablePortRanges, - dialogState.toUi(this@VpnSettingsViewModelState) ) companion object { @@ -50,86 +47,15 @@ data class VpnSettingsViewModelState( isCustomDnsEnabled = false, customDnsList = listOf(), contentBlockersOptions = DefaultDnsOptions(), - isAllowLanEnabled = false, - dialogState = null, selectedObfuscation = SelectedObfuscation.Auto, quantumResistant = QuantumResistantState.Off, selectedWireguardPort = Constraint.Any(), + customWireguardPort = null, availablePortRanges = emptyList() ) } } -private fun VpnSettingsDialogState?.toUi( - vpnSettingsViewModelState: VpnSettingsViewModelState -): VpnSettingsDialog? = - when (this) { - VpnSettingsDialogState.ContentBlockersInfoDialog -> VpnSettingsDialog.ContentBlockersInfo - VpnSettingsDialogState.CustomDnsInfoDialog -> VpnSettingsDialog.CustomDnsInfo - VpnSettingsDialogState.CustomPortDialog -> - VpnSettingsDialog.CustomPort(vpnSettingsViewModelState.availablePortRanges) - is VpnSettingsDialogState.DnsDialog -> VpnSettingsDialog.Dns(stagedDns) - VpnSettingsDialogState.LocalNetworkSharingInfoDialog -> - VpnSettingsDialog.LocalNetworkSharingInfo - VpnSettingsDialogState.MalwareInfoDialog -> VpnSettingsDialog.MalwareInfo - is VpnSettingsDialogState.MtuDialog -> VpnSettingsDialog.Mtu(mtuEditValue) - VpnSettingsDialogState.ObfuscationInfoDialog -> VpnSettingsDialog.ObfuscationInfo - VpnSettingsDialogState.QuantumResistanceInfoDialog -> - VpnSettingsDialog.QuantumResistanceInfo - VpnSettingsDialogState.WireguardPortInfoDialog -> - VpnSettingsDialog.WireguardPortInfo(vpnSettingsViewModelState.availablePortRanges) - null -> null - } - -sealed class VpnSettingsDialogState { - - data class MtuDialog(val mtuEditValue: String) : VpnSettingsDialogState() - - data class DnsDialog(val stagedDns: StagedDns) : VpnSettingsDialogState() - - data object LocalNetworkSharingInfoDialog : VpnSettingsDialogState() - - data object ContentBlockersInfoDialog : VpnSettingsDialogState() - - data object CustomDnsInfoDialog : VpnSettingsDialogState() - - data object MalwareInfoDialog : VpnSettingsDialogState() - - data object ObfuscationInfoDialog : VpnSettingsDialogState() - - data object QuantumResistanceInfoDialog : VpnSettingsDialogState() - - data object WireguardPortInfoDialog : VpnSettingsDialogState() - - data object CustomPortDialog : VpnSettingsDialogState() -} - -sealed interface StagedDns { - val item: CustomDnsItem - val validationResult: ValidationResult - - data class NewDns( - override val item: CustomDnsItem, - override val validationResult: ValidationResult = ValidationResult.Success, - ) : StagedDns - - data class EditDns( - override val item: CustomDnsItem, - override val validationResult: ValidationResult = ValidationResult.Success, - val index: Int - ) : StagedDns - - sealed class ValidationResult { - data object Success : ValidationResult() - - data object InvalidAddress : ValidationResult() - - data object DuplicateAddress : ValidationResult() - } - - fun isValid() = (validationResult is ValidationResult.Success) -} - data class CustomDnsItem(val address: String, val isLocal: Boolean) { companion object { private const val EMPTY_STRING = "" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt index 69e9764d4f..7c77d183ca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt @@ -1,25 +1,25 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL -import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository @@ -27,13 +27,12 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS import net.mullvad.mullvadvpn.util.addDebounceForUnknownState import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import net.mullvad.mullvadvpn.util.toPaymentDialogData import net.mullvad.mullvadvpn.util.toPaymentState -import org.joda.time.DateTime @OptIn(FlowPreview::class) class WelcomeViewModel( @@ -41,10 +40,11 @@ class WelcomeViewModel( private val deviceRepository: DeviceRepository, private val serviceConnectionManager: ServiceConnectionManager, private val paymentUseCase: PaymentUseCase, + private val outOfTimeUseCase: OutOfTimeUseCase, private val pollAccountExpiry: Boolean = true ) : ViewModel() { - private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1) - val uiSideEffect = _uiSideEffect.asSharedFlow() + private val _uiSideEffect = Channel<UiSideEffect>(1, BufferOverflow.DROP_OLDEST) + val uiSideEffect = _uiSideEffect.receiveAsFlow() val uiState = serviceConnectionManager.connectionState @@ -62,14 +62,13 @@ class WelcomeViewModel( it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) }, paymentUseCase.paymentAvailability, - paymentUseCase.purchaseResult - ) { tunnelState, deviceState, paymentAvailability, purchaseResult -> + ) { tunnelState, deviceState, paymentAvailability -> WelcomeUiState( tunnelState = tunnelState, accountNumber = deviceState.token(), deviceName = deviceState.deviceName(), + showSitePayment = IS_PLAY_BUILD.not(), billingPaymentState = paymentAvailability?.toPaymentState(), - paymentDialogData = purchaseResult?.toPaymentDialogData() ) } } @@ -77,24 +76,16 @@ class WelcomeViewModel( init { viewModelScope.launch { - accountRepository.accountExpiryState.collectLatest { accountExpiry -> - accountExpiry.date()?.let { expiry -> - val tomorrow = DateTime.now().plusHours(20) - - if (expiry.isAfter(tomorrow)) { - // Reset purchase state - paymentUseCase.resetPurchaseResult() - _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) - } - } - } - } - viewModelScope.launch { while (pollAccountExpiry) { updateAccountExpiry() delay(ACCOUNT_EXPIRY_POLL_INTERVAL) } } + viewModelScope.launch { + outOfTimeUseCase.isOutOfTime().first { it == false } + paymentUseCase.resetPurchaseResult() + _uiSideEffect.send(UiSideEffect.OpenConnectScreen) + } verifyPurchases() fetchPaymentAvailability() } @@ -104,7 +95,7 @@ class WelcomeViewModel( fun onSitePaymentClick() { viewModelScope.launch { - _uiSideEffect.tryEmit( + _uiSideEffect.send( UiSideEffect.OpenAccountView( serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" ) @@ -112,10 +103,6 @@ class WelcomeViewModel( } } - fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { - viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } - } - private fun verifyPurchases() { viewModelScope.launch { paymentUseCase.verifyPurchases() @@ -123,7 +110,6 @@ class WelcomeViewModel( } } - @OptIn(FlowPreview::class) private fun fetchPaymentAvailability() { viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } } @@ -135,7 +121,7 @@ class WelcomeViewModel( // should check payment availability and verify any purchases to handle potential errors. if (success) { updateAccountExpiry() - _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) + // Emission of out of time navigation is handled by launch in onStart } else { fetchPaymentAvailability() verifyPurchases() // Attempt to verify again |
