diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a3035f0a12da..eb2ee7ef614c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id(Dependencies.Plugin.playPublisherId) id(Dependencies.Plugin.kotlinAndroidId) id(Dependencies.Plugin.kotlinParcelizeId) + id(Dependencies.Plugin.ksp) version Versions.Plugin.ksp } val repoRootPath = rootProject.projectDir.absoluteFile.parentFile.absolutePath @@ -181,8 +182,7 @@ android { val enableInAppVersionNotifications = gradleLocalProperties(rootProject.projectDir) - .getProperty("ENABLE_IN_APP_VERSION_NOTIFICATIONS") - ?: "true" + .getProperty("ENABLE_IN_APP_VERSION_NOTIFICATIONS") ?: "true" buildConfigField( "boolean", @@ -337,6 +337,8 @@ dependencies { implementation(Dependencies.Compose.uiController) implementation(Dependencies.Compose.ui) implementation(Dependencies.Compose.uiUtil) + implementation(Dependencies.Compose.destinations) + ksp("io.github.raamcosta.compose-destinations:ksp:1.9.54") implementation(Dependencies.jodaTime) implementation(Dependencies.Koin.core) implementation(Dependencies.Koin.android) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt index e997ae29e4e3..f1b13616bca2 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt @@ -49,7 +49,6 @@ class AccountScreenTest { accountExpiry = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } @@ -74,7 +73,6 @@ class AccountScreenTest { accountExpiry = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow(), onManageAccountClick = mockedClickHandler ) } @@ -100,7 +98,6 @@ class AccountScreenTest { accountExpiry = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow(), onRedeemVoucherClick = mockedClickHandler ) } @@ -126,7 +123,6 @@ class AccountScreenTest { accountExpiry = null ), uiSideEffect = MutableSharedFlow().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow(), onLogoutClick = mockedClickHandler ) } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt index ab8f2b15123e..5410c627a988 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt @@ -9,10 +9,9 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.verify -import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState +import net.mullvad.mullvadvpn.viewmodel.ChangeLog import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import org.junit.Before import org.junit.Rule @@ -31,15 +30,15 @@ class ChangelogDialogTest { @Test fun testShowChangeLogWhenNeeded() { // Arrange - every { mockedViewModel.uiState } returns - MutableStateFlow(ChangelogDialogUiState.Show(listOf(CHANGELOG_ITEM))) - every { mockedViewModel.dismissChangelogDialog() } just Runs + every { mockedViewModel.markChangeLogAsRead() } just Runs composeTestRule.setContentWithTheme { ChangelogDialog( - changesList = listOf(CHANGELOG_ITEM), - version = CHANGELOG_VERSION, - onDismiss = { mockedViewModel.dismissChangelogDialog() } + ChangeLog( + changes = listOf(CHANGELOG_ITEM), + version = CHANGELOG_VERSION, + ), + onDismiss = { mockedViewModel.markChangeLogAsRead() } ) } @@ -50,7 +49,7 @@ class ChangelogDialogTest { composeTestRule.onNodeWithText(CHANGELOG_BUTTON_TEXT).performClick() // Assert - verify { mockedViewModel.dismissChangelogDialog() } + verify { mockedViewModel.markChangeLogAsRead() } } companion object { diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index 56894addeaa3..55a7f491f807 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -9,9 +9,6 @@ import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll import io.mockk.verify -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR @@ -26,7 +23,6 @@ import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.talpid.net.TransportProtocol import net.mullvad.talpid.net.TunnelEndpoint import net.mullvad.talpid.tunnel.ActionAfterDisconnect @@ -57,7 +53,6 @@ class ConnectScreenTest { composeTestRule.setContentWithTheme { ConnectScreen( uiState = ConnectUiState.INITIAL, - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -88,7 +83,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.TunnelStateBlocked ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -124,7 +118,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.TunnelStateBlocked ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -158,7 +151,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -191,7 +183,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -225,7 +216,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -259,7 +249,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -298,7 +287,6 @@ class ConnectScreenTest { ErrorState(ErrorStateCause.StartTunnelError, true) ) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -338,7 +326,6 @@ class ConnectScreenTest { ErrorState(ErrorStateCause.StartTunnelError, false) ) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -372,7 +359,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.TunnelStateBlocked ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -408,7 +394,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.TunnelStateBlocked ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -444,7 +429,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onSwitchLocationClick = mockedClickHandler ) } @@ -477,7 +461,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onDisconnectClick = mockedClickHandler ) } @@ -510,7 +493,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onReconnectClick = mockedClickHandler ) } @@ -542,7 +524,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onConnectClick = mockedClickHandler ) } @@ -574,7 +555,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onCancelClick = mockedClickHandler ) } @@ -607,7 +587,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow(), onToggleTunnelInfo = mockedClickHandler ) } @@ -647,7 +626,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = null ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -686,7 +664,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.UpdateAvailable(versionInfo) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -723,7 +700,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.UnsupportedVersion(versionInfo) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -757,7 +733,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.AccountExpiry(expiryDate) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -796,7 +771,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.UnsupportedVersion(versionInfo) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -829,7 +803,6 @@ class ConnectScreenTest { daysLeftUntilExpiry = null, inAppNotification = InAppNotification.AccountExpiry(expiryDate) ), - uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -846,10 +819,6 @@ class ConnectScreenTest { composeTestRule.setContentWithTheme { ConnectScreen( uiState = ConnectUiState.INITIAL, - uiSideEffect = - MutableStateFlow( - ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser("222") - ) ) } @@ -864,8 +833,6 @@ class ConnectScreenTest { composeTestRule.setContentWithTheme { ConnectScreen( uiState = ConnectUiState.INITIAL, - uiSideEffect = MutableStateFlow(ConnectViewModel.UiSideEffect.OpenOutOfTimeView), - onOpenOutOfTimeScreen = mockedOpenScreenHandler ) } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt index 3b5da50d3374..0b5df29662ec 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt @@ -8,7 +8,6 @@ import io.mockk.MockKAnnotations import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR @@ -40,7 +39,6 @@ class SelectLocationScreenTest { SelectLocationScreen( uiState = SelectLocationUiState.Loading, uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } @@ -59,7 +57,6 @@ class SelectLocationScreenTest { selectedRelay = null ), uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } @@ -96,7 +93,6 @@ class SelectLocationScreenTest { selectedRelay = updatedDummyList[0].cities[0].relays[0] ), uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } @@ -120,7 +116,6 @@ class SelectLocationScreenTest { uiState = SelectLocationUiState.ShowData(countries = emptyList(), selectedRelay = null), uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow(), onSearchTermInput = mockedSearchTermInput ) } @@ -142,7 +137,6 @@ class SelectLocationScreenTest { SelectLocationScreen( uiState = SelectLocationUiState.NoSearchResultFound(searchTerm = mockSearchString), uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow(), onSearchTermInput = mockedSearchTermInput ) } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt index 576660551e6a..e15ed012d6a3 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreenTest.kt @@ -4,8 +4,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import io.mockk.MockKAnnotations -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.SettingsUiState import org.junit.Before @@ -28,7 +26,6 @@ class SettingsScreenTest { SettingsScreen( uiState = SettingsUiState(appVersion = "", isLoggedIn = true, isUpdateAvailable = true), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } // Assert @@ -47,7 +44,6 @@ class SettingsScreenTest { SettingsScreen( uiState = SettingsUiState(appVersion = "", isLoggedIn = false, isUpdateAvailable = true), - enterTransitionEndAction = MutableSharedFlow().asSharedFlow() ) } // Assert diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt index 9b6dd9e492ba..fa92507a3c45 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt @@ -16,7 +16,6 @@ import io.mockk.verify import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.VpnSettingsDialog import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_LAST_ITEM_TEST_TAG @@ -28,11 +27,10 @@ import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_ import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_TAG 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.onNodeWithTagAndText import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem -import net.mullvad.mullvadvpn.viewmodel.StagedDns +import net.mullvad.mullvadvpn.viewmodel.VpnSettingsSideEffect import org.junit.Before import org.junit.Rule import org.junit.Test @@ -51,7 +49,7 @@ class VpnSettingsScreenTest { composeTestRule.setContentWithTheme { VpnSettingsScreen( uiState = VpnSettingsUiState.createDefault(), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -74,7 +72,7 @@ class VpnSettingsScreenTest { composeTestRule.setContentWithTheme { VpnSettingsScreen( uiState = VpnSettingsUiState.createDefault(mtu = VALID_DUMMY_MTU_VALUE), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -93,8 +91,7 @@ class VpnSettingsScreenTest { composeTestRule.setContentWithTheme { VpnSettingsScreen( uiState = VpnSettingsUiState.createDefault(), - onMtuCellClick = mockedClickHandler, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow(), ) } @@ -114,11 +111,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = VpnSettingsDialog.Mtu(mtuEditValue = EMPTY_STRING), - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -131,11 +125,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = VpnSettingsDialog.Mtu(mtuEditValue = VALID_DUMMY_MTU_VALUE) - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -148,11 +139,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = VpnSettingsDialog.Mtu(mtuEditValue = EMPTY_STRING) - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -169,12 +157,8 @@ class VpnSettingsScreenTest { val mockedSubmitHandler: (Int) -> Unit = mockk(relaxed = true) composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = VpnSettingsDialog.Mtu(mtuEditValue = VALID_DUMMY_MTU_VALUE) - ), - onSaveMtuClick = mockedSubmitHandler, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow(), ) } @@ -190,11 +174,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = VpnSettingsDialog.Mtu(mtuEditValue = INVALID_DUMMY_MTU_VALUE) - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -208,12 +189,8 @@ class VpnSettingsScreenTest { val mockedClickHandler: () -> Unit = mockk(relaxed = true) composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = VpnSettingsDialog.Mtu(mtuEditValue = EMPTY_STRING) - ), - onRestoreMtuClick = mockedClickHandler, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow(), ) } @@ -230,12 +207,8 @@ class VpnSettingsScreenTest { val mockedClickHandler: () -> Unit = mockk(relaxed = true) composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = VpnSettingsDialog.Mtu(mtuEditValue = EMPTY_STRING) - ), - onCancelMtuDialogClick = mockedClickHandler, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow(), ) } @@ -254,7 +227,6 @@ class VpnSettingsScreenTest { uiState = VpnSettingsUiState.createDefault( isCustomDnsEnabled = true, - isAllowLanEnabled = false, customDnsItems = listOf( CustomDnsItem(address = DUMMY_DNS_ADDRESS, false), @@ -262,7 +234,7 @@ class VpnSettingsScreenTest { CustomDnsItem(address = DUMMY_DNS_ADDRESS_3, false) ) ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -285,7 +257,7 @@ class VpnSettingsScreenTest { isCustomDnsEnabled = false, customDnsItems = listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, false)) ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } composeTestRule @@ -304,11 +276,10 @@ class VpnSettingsScreenTest { uiState = VpnSettingsUiState.createDefault( isCustomDnsEnabled = true, - isAllowLanEnabled = true, customDnsItems = listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, isLocal = true)) ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -324,11 +295,10 @@ class VpnSettingsScreenTest { uiState = VpnSettingsUiState.createDefault( isCustomDnsEnabled = true, - isAllowLanEnabled = false, customDnsItems = listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, isLocal = false)) ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -344,11 +314,10 @@ class VpnSettingsScreenTest { uiState = VpnSettingsUiState.createDefault( isCustomDnsEnabled = true, - isAllowLanEnabled = true, customDnsItems = listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, isLocal = false)) ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -364,11 +333,10 @@ class VpnSettingsScreenTest { uiState = VpnSettingsUiState.createDefault( isCustomDnsEnabled = true, - isAllowLanEnabled = false, customDnsItems = listOf(CustomDnsItem(address = DUMMY_DNS_ADDRESS, isLocal = true)) ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -385,8 +353,7 @@ class VpnSettingsScreenTest { composeTestRule.setContentWithTheme { VpnSettingsScreen( uiState = VpnSettingsUiState.createDefault(isCustomDnsEnabled = true), - onDnsClick = mockedClickHandler, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow(), ) } @@ -402,17 +369,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.Dns( - stagedDns = - StagedDns.NewDns( - item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false) - ), - ) - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -425,18 +383,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.Dns( - stagedDns = - StagedDns.EditDns( - item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false), - index = 0 - ) - ) - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -449,19 +397,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.Dns( - stagedDns = - StagedDns.NewDns( - item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = true), - validationResult = StagedDns.ValidationResult.Success - ), - ), - isAllowLanEnabled = false - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -474,19 +411,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.Dns( - stagedDns = - StagedDns.NewDns( - item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = true), - validationResult = StagedDns.ValidationResult.Success - ), - ), - isAllowLanEnabled = true - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -499,19 +425,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.Dns( - stagedDns = - StagedDns.NewDns( - item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false), - validationResult = StagedDns.ValidationResult.Success - ), - ), - isAllowLanEnabled = true - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -524,19 +439,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.Dns( - stagedDns = - StagedDns.NewDns( - item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false), - validationResult = StagedDns.ValidationResult.Success - ), - ), - isAllowLanEnabled = false - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -549,18 +453,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.Dns( - stagedDns = - StagedDns.NewDns( - item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false), - validationResult = StagedDns.ValidationResult.InvalidAddress - ) - ) - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -573,19 +467,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.Dns( - stagedDns = - StagedDns.NewDns( - item = CustomDnsItem(DUMMY_DNS_ADDRESS, isLocal = false), - validationResult = - StagedDns.ValidationResult.DuplicateAddress - ) - ), - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -600,7 +483,7 @@ class VpnSettingsScreenTest { VpnSettingsScreen( uiState = VpnSettingsUiState.createDefault(quantumResistant = QuantumResistantState.On), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } composeTestRule @@ -624,8 +507,8 @@ class VpnSettingsScreenTest { VpnSettingsUiState.createDefault( quantumResistant = QuantumResistantState.Auto, ), - onSelectQuantumResistanceSetting = mockSelectQuantumResistantSettingListener, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow(), + onSelectQuantumResistanceSetting = mockSelectQuantumResistantSettingListener ) } composeTestRule @@ -646,11 +529,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = VpnSettingsDialog.QuantumResistanceInfo - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -667,7 +547,7 @@ class VpnSettingsScreenTest { VpnSettingsUiState.createDefault( selectedWireguardPort = Constraint.Only(Port(53)) ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -698,8 +578,8 @@ class VpnSettingsScreenTest { VpnSettingsUiState.createDefault( selectedWireguardPort = Constraint.Only(Port(53)) ), - onWireguardPortSelected = mockSelectWireguardPortSelectionListener, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow(), + onWireguardPortSelected = mockSelectWireguardPortSelectionListener ) } @@ -727,14 +607,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.WireguardPortInfo( - availablePortRanges = listOf(PortRange(53, 53), PortRange(120, 121)) - ) - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -751,14 +625,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.CustomPort( - availablePortRanges = listOf(PortRange(53, 53), PortRange(120, 121)) - ) - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -775,7 +643,7 @@ class VpnSettingsScreenTest { VpnSettingsUiState.createDefault( selectedWireguardPort = Constraint.Only(Port(4000)) ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -795,8 +663,7 @@ class VpnSettingsScreenTest { composeTestRule.setContentWithTheme { VpnSettingsScreen( uiState = VpnSettingsUiState.createDefault(), - onShowCustomPortDialog = mockOnShowCustomPortDialog, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow(), ) } @@ -820,8 +687,7 @@ class VpnSettingsScreenTest { VpnSettingsUiState.createDefault( selectedWireguardPort = Constraint.Only(Port(4000)) ), - onShowCustomPortDialog = mockOnShowCustomPortDialog, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow(), ) } @@ -847,8 +713,8 @@ class VpnSettingsScreenTest { VpnSettingsUiState.createDefault( selectedWireguardPort = Constraint.Only(Port(4000)) ), - onWireguardPortSelected = onWireguardPortSelected, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + sideEffect = MutableSharedFlow().asSharedFlow(), + onWireguardPortSelected = onWireguardPortSelected ) } @@ -872,14 +738,8 @@ class VpnSettingsScreenTest { // Arrange composeTestRule.setContentWithTheme { VpnSettingsScreen( - uiState = - VpnSettingsUiState.createDefault( - dialog = - VpnSettingsDialog.CustomPort( - availablePortRanges = listOf(PortRange(53, 53), PortRange(120, 121)) - ) - ), - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow() + uiState = VpnSettingsUiState.createDefault(), + sideEffect = MutableSharedFlow().asSharedFlow() ) } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt index a54c41c20d71..baa69ed678c1 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt @@ -48,6 +48,7 @@ class WelcomeScreenTest { onSettingsClick = {}, onAccountClick = {}, openConnectScreen = {}, + navigateToDeviceInfoDialog = {}, onPurchaseBillingProductClick = { _, _ -> }, onClosePurchaseResultDialog = {} ) @@ -73,6 +74,7 @@ class WelcomeScreenTest { onSettingsClick = {}, onAccountClick = {}, openConnectScreen = {}, + navigateToDeviceInfoDialog = {}, onPurchaseBillingProductClick = { _, _ -> }, onClosePurchaseResultDialog = {} ) @@ -105,7 +107,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -128,7 +131,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -151,7 +155,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = mockClickListener, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -174,7 +179,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -200,7 +206,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -228,7 +235,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -254,7 +262,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -282,7 +291,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -304,7 +314,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onClosePurchaseResultDialog = {}, - onPurchaseBillingProductClick = { _, _ -> } + onPurchaseBillingProductClick = { _, _ -> }, + navigateToDeviceInfoDialog = {} ) } @@ -333,7 +344,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -363,7 +375,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -393,7 +406,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -430,7 +444,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } @@ -461,7 +476,8 @@ class WelcomeScreenTest { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = clickHandler, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 432244d16f2c..b4a1fa8730fa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + - + @@ -49,11 +51,14 @@ however as it's protected by the bind vpn permission (android.permission.BIND_VPN_SERVICE) it's protected against third party apps/services. --> - + + @@ -72,12 +77,13 @@ Tile services must be exported and protected by the bind tile permission (android.permission.BIND_QUICK_SETTINGS_TILE). --> - + diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppData.kt index ec5912c24434..18e759d64ee3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppData.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/applist/AppData.kt @@ -1,8 +1,11 @@ package net.mullvad.mullvadvpn.applist +import android.graphics.Bitmap + data class AppData( val packageName: String, val iconRes: Int, val name: String, - val isSystemApp: Boolean = false + val isSystemApp: Boolean = false, + val icon: Bitmap? = null ) 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 8219aa998431..cd5a08edbfb4 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 4d6fb89834bb..2a0043842a64 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/component/CopyableObfuscationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt index a4352147329a..12e657d3ed38 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 @@ -11,24 +11,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter -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) { @@ -45,19 +46,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 11abdad19103..197ce53221af 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 = if (canScroll) scrollBehavior else null ) }, + 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 bd50d45c8621..3a8357c27a1e 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 @@ -104,6 +104,7 @@ fun MullvadTopBar( onSettingsClicked: (() -> Unit)?, onAccountClicked: (() -> Unit)?, modifier: Modifier = Modifier, + enabled: Boolean = true, iconTintColor: Color, isIconAndLogoVisible: Boolean = true ) { @@ -149,7 +150,7 @@ fun MullvadTopBar( }, actions = { if (onAccountClicked != null) { - IconButton(onClick = onAccountClicked) { + IconButton(enabled = enabled, onClick = onAccountClicked) { Icon( painter = painterResource(R.drawable.icon_account), tint = iconTintColor, @@ -159,7 +160,7 @@ fun MullvadTopBar( } if (onSettingsClicked != null) { - IconButton(onClick = onSettingsClicked) { + IconButton(enabled = enabled, onClick = onSettingsClicked) { Icon( painter = painterResource(R.drawable.icon_settings), tint = iconTintColor, @@ -273,6 +274,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 9ce21c6bac1c..755c13056830 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, version: String, onDismiss: () -> Unit) { +fun Changelog(navController: NavController, changeLog: ChangeLog) { + val viewModel = koinViewModel() + + 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, 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 29a57ed33175..145208ce165e 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 cf9233ec94ce..f58768d0c6ab 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 39e82bc57dd7..0e1c315959a4 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 527fcf8738f9..a950d86218c9 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, + index: Int?, + initialValue: String?, +) { + val viewModel = + koinViewModel(parameters = { parametersOf(initialValue, index) }) + + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + DnsDialogSideEffect.Complete -> resultNavigator.navigateBack(result = true) + } + } + } + val state by viewModel.uiState.collectAsState() + + DnsDialog( + state, + viewModel::onDnsInputChange, + onSaveDnsClick = viewModel::onSaveDnsClick, + onRemoveDnsClick = viewModel::onRemoveDnsClick, + onDismiss = { resultNavigator.navigateBack(false) } + ) +} + +@Composable +private 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 983d0c1e04be..ebe46b6050dd 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 378e95c98e61..1f627be040fa 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 bc28169bb23d..6b7693236834 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,17 @@ 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 kotlinx.coroutines.flow.collect import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.textfield.MtuTextField @@ -22,28 +28,33 @@ 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?, - onSave: (Int) -> Unit, - onRestoreDefaultValue: () -> Unit, - onDismiss: () -> Unit, -) { +fun MtuDialog(mtuInitial: Int?, navigator: DestinationsNavigator) { val mtu = remember { mutableStateOf(mtuInitial?.toString() ?: "") } + val viewModel = koinViewModel() + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + MtuDialogSideEffect.Complete -> navigator.navigateUp() + } + } + } val isValidMtu = mtu.value.toIntOrNull()?.isValidMtu() == true AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = navigator::navigateUp, title = { Text( text = stringResource(id = R.string.wireguard_mtu), @@ -59,7 +70,7 @@ fun MtuDialog( onSubmit = { newMtuValue -> val mtuInt = newMtuValue.toIntOrNull() if (mtuInt?.isValidMtu() == true) { - onSave(mtuInt) + viewModel.onSaveClick(mtuInt) } }, isEnabled = true, @@ -91,7 +102,7 @@ fun MtuDialog( onClick = { val mtuInt = mtu.value.toIntOrNull() if (mtuInt?.isValidMtu() == true) { - onSave(mtuInt) + viewModel.onSaveClick(mtuInt) } } ) @@ -99,13 +110,13 @@ fun MtuDialog( PrimaryButton( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.reset_to_default_button), - onClick = onRestoreDefaultValue + onClick = viewModel::onRestoreClick ) PrimaryButton( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.cancel), - onClick = onDismiss + onClick = navigator::navigateUp ) } }, 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 f54eabdbafb5..cf4db26e2e9b 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 3a20e9c80576..e7773ed0a382 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 c5b619a9cd2d..8d48a3ecbd94 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.res.painterResource @@ -22,6 +23,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 @@ -36,7 +40,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 @@ -90,6 +96,18 @@ private fun PreviewRedeemVoucherDialogSuccess() { } } +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun RedeemVoucher(resultBackNavigator: ResultBackNavigator) { + val vm = koinViewModel() + RedeemVoucherDialog( + uiState = vm.uiState.collectAsState().value, + onVoucherInputChange = { vm.onVoucherInputChange(it) }, + onRedeem = { vm.onRedeem(it) }, + onDismiss = { resultBackNavigator.navigateBack(result = it) } + ) +} + @Composable fun RedeemVoucherDialog( uiState: VoucherDialogUiState, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt similarity index 72% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt index e08a82d69491..574785dd81f8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt @@ -18,27 +18,34 @@ 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 PreviewShowDeviceRemovalDialog() { - ShowDeviceRemovalDialog( - onDismiss = {}, - onConfirm = {}, - device = Device("test", "test", byteArrayOf(), "test") - ) +private fun PreviewShowDefalseviceRemovalDialog() { + AppTheme { + RemoveDeviceConfirmationDialog( + EmptyResultBackNavigator(), + device = Device("test", "test", byteArrayOf(), "test") + ) + } } +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun ShowDeviceRemovalDialog(onDismiss: () -> Unit, onConfirm: () -> Unit, device: Device) { +fun RemoveDeviceConfirmationDialog(navigator: ResultBackNavigator, device: Device) { AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = { navigator.navigateBack() }, title = { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -59,14 +66,14 @@ fun ShowDeviceRemovalDialog(onDismiss: () -> Unit, onConfirm: () -> Unit, device }, dismissButton = { NegativeButton( - onClick = onConfirm, + onClick = { navigator.navigateBack(result = device.id) }, text = stringResource(id = R.string.confirm_removal) ) }, confirmButton = { PrimaryButton( modifier = Modifier.focusRequester(FocusRequester()), - onClick = onDismiss, + onClick = { navigator.navigateBack() }, text = stringResource(id = R.string.back) ) }, 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 8415acbf4bb5..80e2687c516d 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) { AlertDialog( - onDismissRequest = { onDismiss() }, + onDismissRequest = { resultBackNavigator.navigateBack() }, icon = { Icon( painter = painterResource(id = R.drawable.icon_alert), @@ -51,14 +51,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 f81412799063..1c5c4ccef636 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/CustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt similarity index 63% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt index a30e525e6d59..b2ad37b19557 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt @@ -1,5 +1,6 @@ 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 @@ -15,6 +16,11 @@ 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 @@ -29,29 +35,31 @@ import net.mullvad.mullvadvpn.util.isPortInValidRanges @Preview @Composable -private fun PreviewCustomPortDialog() { +private fun PreviewWireguardCustomPortDialog() { AppTheme { - CustomPortDialog( - onSave = {}, - onReset = {}, - customPort = "", - allowedPortRanges = listOf(PortRange(10, 10), PortRange(40, 50)), - showReset = true, - onDismissRequest = {} + WireguardCustomPortDialog( + WireguardCustomPortNavArgs( + customPort = null, + allowedPortRanges = listOf(PortRange(10, 10), PortRange(40, 50)), + ), + EmptyResultBackNavigator() ) } } +@Parcelize +data class WireguardCustomPortNavArgs( + val customPort: Int?, + val allowedPortRanges: List, +) : Parcelable + +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun CustomPortDialog( - customPort: String, - allowedPortRanges: List, - showReset: Boolean, - onSave: (customPortString: String) -> Unit, - onReset: () -> Unit, - onDismissRequest: () -> Unit +fun WireguardCustomPortDialog( + navArg: WireguardCustomPortNavArgs, + backNavigator: ResultBackNavigator ) { - val port = remember { mutableStateOf(customPort) } + val port = remember { mutableStateOf(navArg.customPort?.toString() ?: "") } AlertDialog( title = { @@ -64,20 +72,22 @@ fun CustomPortDialog( Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { PrimaryButton( text = stringResource(id = R.string.custom_port_dialog_submit), - onClick = { onSave(port.value) }, + onClick = { backNavigator.navigateBack(port.value.toInt()) }, isEnabled = port.value.isNotEmpty() && - allowedPortRanges.isPortInValidRanges(port.value.toIntOrNull() ?: 0) + navArg.allowedPortRanges.isPortInValidRanges( + port.value.toIntOrNull() ?: 0 + ) ) - if (showReset) { + if (navArg.customPort != null) { NegativeButton( text = stringResource(R.string.custom_port_dialog_remove), - onClick = onReset + onClick = { backNavigator.navigateBack(null) } ) } PrimaryButton( text = stringResource(id = R.string.cancel), - onClick = onDismissRequest + onClick = backNavigator::navigateBack ) } }, @@ -88,15 +98,19 @@ fun CustomPortDialog( onSubmit = { input -> if ( input.isNotEmpty() && - allowedPortRanges.isPortInValidRanges(input.toIntOrNull() ?: 0) + navArg.allowedPortRanges.isPortInValidRanges( + input.toIntOrNull() ?: 0 + ) ) { - onSave(input) + backNavigator.navigateBack(result = input.toIntOrNull()) } }, onValueChanged = { input -> port.value = input }, isValidValue = port.value.isNotEmpty() && - allowedPortRanges.isPortInValidRanges(port.value.toIntOrNull() ?: 0), + navArg.allowedPortRanges.isPortInValidRanges( + port.value.toIntOrNull() ?: 0 + ), maxCharLength = 5, modifier = Modifier.testTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).fillMaxWidth() ) @@ -105,7 +119,7 @@ fun CustomPortDialog( text = stringResource( id = R.string.custom_port_dialog_valid_ranges, - allowedPortRanges.asString() + navArg.allowedPortRanges.asString() ), color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaDescription), style = MaterialTheme.typography.bodySmall @@ -114,6 +128,6 @@ fun CustomPortDialog( }, containerColor = MaterialTheme.colorScheme.background, titleContentColor = MaterialTheme.colorScheme.onBackground, - onDismissRequest = onDismissRequest + onDismissRequest = backNavigator::navigateBack ) } 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 58ddb00e2033..a3329b1248b2 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) : Parcelable + +@Destination(style = DestinationStyle.Dialog::class) @Composable -fun WireguardPortInfoDialog(portRanges: List, 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/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index fecd23406a8f..9a9adab5717b 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,7 @@ 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 +14,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 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,12 +49,15 @@ 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.destinations.DeviceNameInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination 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.state.PaymentState +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromBottomTransition import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus @@ -58,6 +69,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,7 +77,6 @@ import org.joda.time.DateTime private fun PreviewAccountScreen() { AppTheme { AccountScreen( - showSitePayment = true, uiState = AccountUiState( deviceName = "Test Name", @@ -88,18 +99,46 @@ private fun PreviewAccountScreen() { ) ), uiSideEffect = MutableSharedFlow().asSharedFlow(), - enterTransitionEndAction = MutableSharedFlow() ) } } +@OptIn(ExperimentalMaterial3Api::class) +@Destination(style = SlideInFromBottomTransition::class) +@Composable +fun Account( + navigator: DestinationsNavigator, +) { + val vm = koinViewModel() + val state by vm.uiState.collectAsState() + + AccountScreen( + uiState = state, + uiSideEffect = vm.uiSideEffect, + onRedeemVoucherClick = { + navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true } + }, + onManageAccountClick = vm::onManageAccountClick, + onLogoutClick = vm::onLogoutClick, + navigateToLogin = { + navigator.navigate(LoginDestination(null)) { + popUpTo(NavGraphs.root) { inclusive = true } + } + }, + onCopyAccountNumber = vm::onCopyAccountNumber, + onBackClick = { navigator.navigateUp() }, + navigateToDeviceInfo = { + navigator.navigate(DeviceNameInfoDialogDestination) { launchSingleTop = true } + } + ) +} + @ExperimentalMaterial3Api @Composable fun AccountScreen( - showSitePayment: Boolean, uiState: AccountUiState, uiSideEffect: SharedFlow, - enterTransitionEndAction: SharedFlow, + onCopyAccountNumber: (String) -> Unit = {}, onRedeemVoucherClick: () -> Unit = {}, onManageAccountClick: () -> Unit = {}, onLogoutClick: () -> Unit = {}, @@ -108,34 +147,37 @@ fun AccountScreen( { _, _ -> }, onClosePurchaseResultDialog: (success: Boolean) -> Unit = {}, + navigateToLogin: () -> Unit = {}, + navigateToDeviceInfo: () -> 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() + val clipboardManager = LocalClipboardManager.current + val snackbarHostState = remember { SnackbarHostState() } + val copyTextString = stringResource(id = R.string.copied_mullvad_account_number) LaunchedEffect(Unit) { - uiSideEffect.collect { viewAction -> - if (viewAction is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) { - openAccountPage(viewAction.token) + uiSideEffect.collect { uiSideEffect -> + 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) + } + } } } } - if (showDeviceNameInfoDialog) { - DeviceNameInfoDialog { showDeviceNameInfoDialog = false } - } - + var showVerificationPendingDialog by remember { mutableStateOf(false) } if (showVerificationPendingDialog) { VerificationPendingDialog(onClose = { showVerificationPendingDialog = false }) } @@ -147,15 +189,6 @@ fun AccountScreen( onCloseDialog = onClosePurchaseResultDialog ) } - - LaunchedEffect(Unit) { - uiSideEffect.collect { uiSideEffect -> - if (uiSideEffect is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) { - context.openAccountPageInBrowser(uiSideEffect.token) - } - } - } - ScaffoldWithMediumTopBar( appBarTitle = stringResource(id = R.string.settings_account), navigationIcon = { NavigateBackDownIconButton(onBackClick) } @@ -165,9 +198,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) @@ -185,7 +218,7 @@ fun AccountScreen( ) } - if (showSitePayment) { + if (IS_PLAY_BUILD.not()) { ExternalButton( text = stringResource(id = R.string.manage_account), onClick = onManageAccountClick, @@ -230,7 +263,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 +271,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 7528b46e425e..037ec9d264a0 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.NoTransition 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().asSharedFlow() ) } } +@Destination(style = NoTransition::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, - 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 f5764f069eb4..a10b4599c80d 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 @@ -15,18 +15,27 @@ import androidx.compose.foundation.verticalScroll 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview 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 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.ListItem import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar -import net.mullvad.mullvadvpn.compose.dialog.ShowDeviceRemovalDialog +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 @@ -35,6 +44,8 @@ import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar 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 @@ -57,34 +68,61 @@ private fun PreviewDeviceListScreen() { ) ), isLoading = true, - stagedDevice = null ) ) } } +@Destination +@Composable +fun DeviceList( + navigator: DestinationsNavigator, + accountToken: String, + backResultBackNavigator: ResultRecipient +) { + val viewModel = koinViewModel() + val state by viewModel.uiState.collectAsState() + + backResultBackNavigator.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) { - ShowDeviceRemovalDialog( - 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, @@ -204,7 +242,7 @@ fun DeviceListScreen( isLoading = deviceUiState.isLoading, iconResourceId = R.drawable.icon_close ) { - onDeviceRemovalClicked(deviceUiState.device.id) + navigateToRemoveDeviceConfirmationDialog(deviceUiState.device) } } } 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 5ec6b9a64ba6..11e929c905e9 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() + + 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/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt index e7d6e40a60c6..06682ff5e32b 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 @@ -50,11 +52,19 @@ 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.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.* @@ -66,6 +76,9 @@ 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 @@ -99,9 +112,57 @@ private fun PreviewLoginSuccess() { AppTheme { LoginScreen(uiState = LoginUiState(loginState = Success)) } } +@Destination +@Composable +fun Login( + navigator: DestinationsNavigator, + accountToken: String? = null, + vm: LoginViewModel = koinViewModel() +) { + val state by vm.uiState.collectAsState() + + 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 } + } + } + LoginUiSideEffect.NavigateToConnect -> { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + } + is LoginUiSideEffect.TooManyDevices -> { + navigator.navigate(DeviceListDestination(it.accountToken.value)) { + launchSingleTop = true + } + } + } + } + } + LoginScreen( + state, + vm::login, + vm::createAccount, + vm::clearAccountHistory, + vm::onAccountNumberChange, + { navigator.navigate(SettingsDestination) } + ) +} + @OptIn(ExperimentalComposeUiApi::class) @Composable -fun LoginScreen( +private fun LoginScreen( uiState: LoginUiState, onLoginClick: (String) -> Unit = {}, onCreateAccountClick: () -> Unit = {}, @@ -112,11 +173,10 @@ fun LoginScreen( 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 000000000000..d2ab1ca2b6dc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -0,0 +1,48 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.rememberNavHostEngine +import com.ramcosta.composedestinations.utils.destination +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.OutOfTimeDestination +import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import org.koin.androidx.compose.koinViewModel + +private val changeLogDestinations = listOf(ConnectDestination, OutOfTimeDestination) + +@Composable +fun MullvadApp() { + val engine = rememberNavHostEngine() + val navController: NavHostController = engine.rememberNavController() + + DestinationsNavHost( + modifier = Modifier.fillMaxSize(), + engine = engine, + navController = navController, + navGraph = NavGraphs.root + ) + + // Dirty way to globally show the changelog + val changeLogsViewModel = koinViewModel() + + 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/OutOfTimeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt index a7fd6bae2fa0..959268748168 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 @@ -15,6 +15,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.mutableStateOf import androidx.compose.runtime.remember @@ -26,20 +27,32 @@ 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 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.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow 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.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination 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.state.OutOfTimeUiState +import net.mullvad.mullvadvpn.compose.transitions.NoTransition +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -50,6 +63,7 @@ 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 @@ -95,6 +109,45 @@ private fun PreviewOutOfTimeScreenError() { } } +@Destination(style = NoTransition::class) +@Composable +fun OutOfTime( + navigator: DestinationsNavigator, + resultRecipient: ResultRecipient +) { + val vm = koinViewModel() + val state = vm.uiState.collectAsState().value + resultRecipient.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 } + } + } + } + OutOfTimeScreen( + showSitePayment = IS_PLAY_BUILD.not(), + uiState = state, + uiSideEffect = vm.uiSideEffect, + onSitePaymentClick = vm::onSitePaymentClick, + onRedeemVoucherClick = { + navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true } + }, + onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } }, + openConnectScreen = { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + }, + onDisconnectClick = vm::onDisconnectClick, + onPurchaseBillingProductClick = vm::startBillingPayment, + onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog + ) +} + @Composable fun OutOfTimeScreen( showSitePayment: Boolean, @@ -143,13 +196,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 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 02250c36632f..3dab053ea3b9 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,23 @@ 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.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 +55,41 @@ private fun PreviewPrivacyDisclaimerScreen() { AppTheme { PrivacyDisclaimerScreen({}, {}) } } +@Destination +@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/RedeemVoucherDialogScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt deleted file mode 100644 index 1db18b01a31b..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt +++ /dev/null @@ -1,32 +0,0 @@ -package net.mullvad.mullvadvpn.compose.screen - -import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import net.mullvad.mullvadvpn.compose.dialog.RedeemVoucherDialog -import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState -import net.mullvad.mullvadvpn.lib.theme.AppTheme - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) -@Composable -private fun PreviewRedeemVoucherDialogScreen() { - AppTheme { - RedeemVoucherDialogScreen( - uiState = VoucherDialogUiState.INITIAL, - onVoucherInputChange = {}, - onRedeem = {}, - onDismiss = {} - ) - } -} - -@Composable -internal fun RedeemVoucherDialogScreen( - uiState: VoucherDialogUiState, - onVoucherInputChange: (String) -> Unit = {}, - onRedeem: (voucherCode: String) -> Unit, - onDismiss: (isTimeAdded: Boolean) -> Unit -) { - RedeemVoucherDialog(uiState, onVoucherInputChange, onRedeem, onDismiss) -} 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 fbfb632e2333..de99fea499b0 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,11 +14,10 @@ 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 -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 @@ -29,19 +28,28 @@ 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.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 @@ -53,22 +61,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")), ) } } @@ -80,38 +85,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 ReportProblemScreen( +fun ReportProblem( + navigator: DestinationsNavigator, + resultRecipient: ResultRecipient +) { + val vm = koinViewModel() + val uiState by vm.uiState.collectAsState() + + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { + when (it) { + is ReportProblemSideEffect.ShowConfirmNoEmail -> { + navigator.navigate(ReportProblemNoEmailDialogDestination()) + } + } + } + } + + resultRecipient.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::onEmailChanged, + onDescriptionChanged = vm::onDescriptionChanged, + onBackClick = navigator::navigateUp, + ) +} + +@Composable +private fun ReportProblemScreen( uiState: ReportProblemUiState, - onSendReport: (String, String) -> Unit = { _, _ -> }, - onDismissNoEmailDialog: () -> Unit = {}, + onSendReport: () -> Unit = {}, onClearSendResult: () -> Unit = {}, onNavigateToViewLogs: () -> Unit = {}, - onBackClick: () -> Unit = {} + onEmailChanged: (String) -> Unit = {}, + onDescriptionChanged: (String) -> Unit = {}, + onBackClick: () -> Unit = {}, ) { - var email by rememberSaveable { mutableStateOf("") } - var description by rememberSaveable { mutableStateOf("") } - - // Dialog to show confirm if no email was added - if (uiState.showConfirmNoEmail) { - ReportProblemNoEmailDialog( - onDismiss = onDismissNoEmailDialog, - onConfirm = { onSendReport(email, description) } - ) - } ScaffoldWithMediumTopBar( appBarTitle = stringResource(id = R.string.report_a_problem), navigationIcon = { NavigateBackIconButton(onBackClick) } ) { modifier -> - // Show sending states if (uiState.sendingState != null) { Column( @@ -123,8 +157,7 @@ fun ReportProblemScreen( ) { when (uiState.sendingState) { SendingReportUiState.Sending -> SendingContent() - is SendingReportUiState.Error -> - ErrorContent({ onSendReport(email, description) }, onClearSendResult) + is SendingReportUiState.Error -> ErrorContent(onSendReport, onClearSendResult) is SendingReportUiState.Success -> SentContent(uiState.sendingState) } return@ScaffoldWithMediumTopBar @@ -146,8 +179,8 @@ fun ReportProblemScreen( TextField( modifier = Modifier.fillMaxWidth(), - value = email, - onValueChange = { email = it }, + value = uiState.email, + onValueChange = onEmailChanged, maxLines = 1, singleLine = true, placeholder = { Text(text = stringResource(id = R.string.user_email_hint)) }, @@ -156,8 +189,8 @@ fun ReportProblemScreen( TextField( modifier = Modifier.fillMaxWidth().weight(1f), - value = description, - onValueChange = { description = it }, + value = uiState.description, + onValueChange = onDescriptionChanged, placeholder = { Text(stringResource(R.string.user_message_hint)) }, colors = mullvadWhiteTextFieldColors() ) @@ -169,8 +202,8 @@ fun ReportProblemScreen( ) Spacer(modifier = Modifier.height(Dimens.buttonSpacing)) VariantButton( - onClick = { onSendReport(email, description) }, - isEnabled = description.isNotEmpty(), + 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 c09a0b986a61..7e780a035189 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 @@ -16,9 +16,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState 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.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -36,7 +38,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import androidx.core.text.HtmlCompat -import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import net.mullvad.mullvadvpn.R @@ -49,11 +52,14 @@ 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.SlideInFromBottomTransition 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.SelectLocationViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -67,135 +73,148 @@ private fun PreviewSelectLocationScreen() { SelectLocationScreen( uiState = state, uiCloseAction = MutableSharedFlow(), - enterTransitionEndAction = MutableSharedFlow() ) } } +@Destination(style = SlideInFromBottomTransition::class) +@Composable +fun SelectLocation(navigator: DestinationsNavigator) { + val vm = koinViewModel() + val state = vm.uiState.collectAsState().value + SelectLocationScreen( + uiState = state, + uiCloseAction = vm.uiCloseAction, + onSelectRelay = vm::selectRelay, + onSearchTermInput = vm::onSearchTermInput, + onBackClick = navigator::navigateUp, + ) +} + @OptIn(ExperimentalComposeUiApi::class) @Composable fun SelectLocationScreen( uiState: SelectLocationUiState, uiCloseAction: SharedFlow, - enterTransitionEndAction: SharedFlow, onSelectRelay: (item: RelayItem) -> Unit = {}, onSearchTermInput: (searchTerm: String) -> Unit = {}, onBackClick: () -> 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( + Scaffold { + Column( modifier = - Modifier.padding( - horizontal = Dimens.selectLocationTitlePadding, - vertical = Dimens.selectLocationTitlePadding - ) - .fillMaxWidth() + Modifier.padding(it).background(backgroundColor).fillMaxWidth().fillMaxHeight() ) { - Image( - painter = painterResource(id = R.drawable.icon_back), - contentDescription = null, - modifier = - Modifier.focusRequester(backFocus) - .focusProperties { next = listFocus } - .focusProperties { - down = listFocus - right = searchBarFocus - } - .size(Dimens.titleIconSize) - .rotate(270f) - .clickable { onBackClick() } - ) - Text( - text = stringResource(id = R.string.select_location), + Row( modifier = - Modifier.align(Alignment.CenterVertically) - .weight(weight = 1f) - .padding(end = Dimens.titleIconSize), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp), - color = MaterialTheme.colorScheme.onPrimary - ) - } - 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) + Modifier.padding( + horizontal = Dimens.selectLocationTitlePadding, + vertical = Dimens.selectLocationTitlePadding ) + .fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_back), + contentDescription = null, + modifier = + Modifier.focusRequester(backFocus) + .focusProperties { next = listFocus } + .focusProperties { + down = listFocus + right = searchBarFocus + } + .size(Dimens.titleIconSize) + .rotate(270f) + .clickable { onBackClick() } + ) + 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.headlineSmall.copy(fontSize = 20.sp), + color = MaterialTheme.colorScheme.onPrimary + ) + } + 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) + ) + } } - } - is SelectLocationUiState.ShowData -> { - 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() - ) + is SelectLocationUiState.ShowData -> { + 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() + ) + } } - } - is SelectLocationUiState.NoSearchResultFound -> { - 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) - Text( - text = - buildAnnotatedString { - append(firstRow) - appendLine() - append( + is SelectLocationUiState.NoSearchResultFound -> { + item(contentType = ContentType.EMPTY_TEXT) { + val firstRow = + HtmlCompat.fromHtml( textResource( - id = R.string.select_location_empty_text_second_row - ) + id = R.string.select_location_empty_text_first_row, + uiState.searchTerm + ), + HtmlCompat.FROM_HTML_MODE_COMPACT ) - }, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center - ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + Text( + text = + buildAnnotatedString { + append(firstRow) + appendLine() + append( + textResource( + id = R.string.select_location_empty_text_second_row + ) + ) + }, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center + ) + } } } } 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 b092ed981b5f..67590232b179 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() + 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, 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/LoadingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt similarity index 54% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt index d27b4196aa21..d95cb00cc90d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt @@ -1,5 +1,6 @@ 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 @@ -12,6 +13,7 @@ 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 @@ -20,26 +22,79 @@ 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.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.lib.theme.AppTheme 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 { LoadingScreen() } + AppTheme { SplashScreen() } } +// Set this as the start destination of the default nav graph +@RootNavGraph(start = true) +@Destination @Composable -fun LoadingScreen(onSettingsCogClicked: () -> Unit = {}) { +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 +private fun SplashScreen() { + val backgroundColor = MaterialTheme.colorScheme.primary ScaffoldWithTopBar( topBarColor = backgroundColor, - statusBarColor = backgroundColor, - navigationBarColor = backgroundColor, - onSettingsClicked = onSettingsCogClicked, + onSettingsClicked = {}, onAccountClicked = null, isIconAndLogoVisible = false, content = { 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 a1f9bd8a97fd..339673949178 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() + 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 cbe1f6d0b38a..7084ef5f5080 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 @@ -17,20 +17,26 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold 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.LocalContext 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 net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium import net.mullvad.mullvadvpn.compose.component.MullvadMediumTopBar 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.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.viewmodel.ViewLogsUiState +import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -44,6 +50,14 @@ private fun PreviewViewLogsLoadingScreen() { AppTheme { ViewLogsScreen(uiState = ViewLogsUiState()) } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun ViewLogs(navigator: DestinationsNavigator) { + val vm = koinViewModel() + 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 7290b9600fd3..72f3f516e9bb 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 @@ -30,10 +31,14 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +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.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.distinctUntilChanged +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 +55,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 +77,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,167 +106,195 @@ private fun PreviewVpnSettings() { isCustomDnsEnabled = true, customDnsItems = listOf(CustomDnsItem("0.0.0.0", false)), ), - onMtuCellClick = {}, - onSaveMtuClick = {}, - onRestoreMtuClick = {}, - onCancelMtuDialogClick = {}, - onToggleAutoConnect = {}, - onToggleLocalNetworkSharing = {}, - onToggleDnsClick = {}, - onToggleBlockAds = {}, + sideEffect = MutableSharedFlow().asSharedFlow(), onToggleBlockTrackers = {}, + onToggleBlockAds = {}, onToggleBlockMalware = {}, + onToggleAutoConnect = {}, + onToggleLocalNetworkSharing = {}, onToggleBlockAdultContent = {}, onToggleBlockGambling = {}, onToggleBlockSocialMedia = {}, - onDnsClick = {}, - onDnsInputChange = {}, - onSaveDnsClick = {}, - onRemoveDnsClick = {}, - onCancelDnsDialogClick = {}, - onLocalNetworkSharingInfoClick = {}, - onContentsBlockersInfoClick = {}, - onMalwareInfoClick = {}, - onCustomDnsInfoClick = {}, - onDismissInfoClick = {}, + navigateToMtuDialog = {}, + navigateToDns = { _, _ -> }, + onToggleDnsClick = {}, onBackClick = {}, - toastMessagesSharedFlow = MutableSharedFlow().asSharedFlow(), onStopEvent = {}, onSelectObfuscationSetting = {}, - onObfuscationInfoClick = {}, onSelectQuantumResistanceSetting = {}, - onQuantumResistanceInfoClicked = {}, onWireguardPortSelected = {}, - onWireguardPortInfoClicked = {}, - onShowCustomPortDialog = {}, - onCancelCustomPortDialogClick = {}, - onCloseCustomPortDialog = {} ) } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun VpnSettings( + navigator: DestinationsNavigator, + dnsDialogResult: ResultRecipient, + customWgPortResult: ResultRecipient +) { + val vm = koinViewModel() + val state = vm.uiState.collectAsState().value + + dnsDialogResult.onNavResult { + when (it) { + NavResult.Canceled -> { + vm.onDnsDialogDismiss() + } + is NavResult.Value -> {} + } + } + + customWgPortResult.onNavResult { + when (it) { + NavResult.Canceled -> {} + is NavResult.Value -> { + val port = it.value + + if (port != null) { + vm.onWireguardPortSelected(Constraint.Only(Port(port))) + } else { + vm.resetCustomPort() + } + } + } + } + + VpnSettingsScreen( + uiState = state, + sideEffect = vm.uiSideEffect, + 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::onToggleDnsClick, + onBackClick = navigator::navigateUp, + onStopEvent = vm::onStopEvent, + onSelectObfuscationSetting = vm::onSelectObfuscationSetting, + onSelectQuantumResistanceSetting = vm::onSelectQuantumResistanceSetting, + onWireguardPortSelected = vm::onWireguardPortSelected, + ) +} + @OptIn(ExperimentalFoundationApi::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 = {}, + sideEffect: SharedFlow, + navigateToContentBlockersInfo: () -> Unit = {}, + navigateToCustomDnsInfo: () -> Unit = {}, + navigateToMalwareInfo: () -> Unit = {}, + navigateToObfuscationInfo: () -> Unit = {}, + navigateToQuantumResistanceInfo: () -> Unit = {}, + navigateUdp2TcpInfo: () -> Unit = {}, + navigateToWireguardPortInfo: (availablePortRanges: List) -> 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 = {}, - onDnsClick: (index: Int?) -> Unit = {}, - onDnsInputChange: (String) -> Unit = {}, - onSaveDnsClick: () -> Unit = {}, - onRemoveDnsClick: () -> Unit = {}, - onCancelDnsDialogClick: () -> Unit = {}, - onLocalNetworkSharingInfoClick: () -> Unit = {}, - onContentsBlockersInfoClick: () -> Unit = {}, - onMalwareInfoClick: () -> Unit = {}, - onCustomDnsInfoClick: () -> Unit = {}, - onDismissInfoClick: () -> Unit = {}, + navigateToMtuDialog: (mtu: Int?) -> Unit = {}, + navigateToDns: (index: Int?, address: String?) -> Unit = { _, _ -> }, + onToggleDnsClick: (Boolean) -> Unit = {}, onBackClick: () -> Unit = {}, onStopEvent: () -> Unit = {}, - toastMessagesSharedFlow: SharedFlow, onSelectObfuscationSetting: (selectedObfuscation: SelectedObfuscation) -> Unit = {}, - onObfuscationInfoClick: () -> Unit = {}, onSelectQuantumResistanceSetting: (quantumResistant: QuantumResistantState) -> Unit = {}, - onQuantumResistanceInfoClicked: () -> Unit = {}, onWireguardPortSelected: (port: Constraint) -> Unit = {}, - onWireguardPortInfoClicked: () -> Unit = {}, - onShowCustomPortDialog: () -> Unit = {}, - onCancelCustomPortDialogClick: () -> Unit = {}, - onCloseCustomPortDialog: () -> Unit = {} ) { - val savedCustomPort = rememberSaveable { mutableStateOf>(Constraint.Any()) } - - 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()) + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + sideEffect.collect { + when (it) { + is VpnSettingsSideEffect.ShowToast -> + launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar(message = it.message) } - savedCustomPort.value = Constraint.Any() - onCloseCustomPortDialog() - }, - showReset = savedCustomPort.value is Constraint.Only, - onDismissRequest = { onCancelCustomPortDialogClick() } - ) + VpnSettingsSideEffect.NavigateToDnsDialog -> navigateToDns(null, null) + } } } + // when (val dialog = uiState.dialog) { + // 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, + // ) + // } + // } + var expandContentBlockersState by rememberSaveable { mutableStateOf(false) } val biggerPadding = 54.dp val topPadding = 6.dp - LaunchedEffect(uiState.selectedWireguardPort) { - if ( - uiState.selectedWireguardPort.isCustom() && - uiState.selectedWireguardPort != savedCustomPort.value - ) { - savedCustomPort.value = uiState.selectedWireguardPort - } - } - - val context = LocalContext.current - LaunchedEffect(Unit) { - toastMessagesSharedFlow.distinctUntilChanged().collect { message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { @@ -267,6 +307,7 @@ fun VpnSettingsScreen( 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 +329,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 +342,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 +374,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 +432,7 @@ fun VpnSettingsScreen( isToggled = uiState.isCustomDnsEnabled, isEnabled = uiState.contentBlockersOptions.isAnyBlockerEnabled().not(), onCellClicked = { newValue -> onToggleDnsClick(newValue) }, - onInfoClicked = { onCustomDnsInfoClick() } + onInfoClicked = { navigateToCustomDnsInfo() } ) } @@ -400,8 +441,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 +450,7 @@ fun VpnSettingsScreen( itemWithDivider { BaseCell( - onCellClicked = { onDnsClick(null) }, + onCellClicked = { navigateToDns(null, null) }, title = { Text( text = stringResource(id = R.string.add_a_server), @@ -441,7 +482,7 @@ fun VpnSettingsScreen( Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) InformationComposeCell( title = stringResource(id = R.string.wireguard_port_title), - onInfoClicked = onWireguardPortInfoClicked + onInfoClicked = { navigateToWireguardPortInfo(uiState.availablePortRanges) } ) } @@ -468,20 +509,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 +527,7 @@ fun VpnSettingsScreen( Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) InformationComposeCell( title = stringResource(R.string.obfuscation_title), - onInfoClicked = { onObfuscationInfoClick() } + onInfoClicked = navigateToObfuscationInfo ) } itemWithDivider { @@ -505,7 +541,7 @@ fun VpnSettingsScreen( SelectableCell( title = stringResource(id = R.string.obfuscation_on_udp_over_tcp), isSelected = uiState.selectedObfuscation == SelectedObfuscation.Udp2Tcp, - onCellClicked = { onSelectObfuscationSetting(SelectedObfuscation.Udp2Tcp) } + onCellClicked = navigateUdp2TcpInfo ) } itemWithDivider { @@ -520,7 +556,7 @@ fun VpnSettingsScreen( Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) InformationComposeCell( title = stringResource(R.string.quantum_resistant_title), - onInfoClicked = { onQuantumResistanceInfoClicked() } + onInfoClicked = navigateToQuantumResistanceInfo ) } itemWithDivider { @@ -548,7 +584,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 d26e8c826539..f9a02c265d1c 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 @@ -19,6 +19,7 @@ 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 @@ -30,22 +31,34 @@ 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 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.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow 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.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.destinations.DeviceNameInfoDialogDestination +import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState +import net.mullvad.mullvadvpn.compose.transitions.NoTransition import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct @@ -57,6 +70,7 @@ 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 @@ -83,11 +97,51 @@ private fun PreviewWelcomeScreen() { onAccountClick = {}, openConnectScreen = {}, onPurchaseBillingProductClick = { _, _ -> }, - onClosePurchaseResultDialog = {} + onClosePurchaseResultDialog = {}, + navigateToDeviceInfoDialog = {} ) } } +@Destination(style = NoTransition::class) +@Composable +fun Welcome( + navigator: DestinationsNavigator, + resultRecipient: ResultRecipient +) { + val vm = koinViewModel() + val state by vm.uiState.collectAsState() + + resultRecipient.onNavResult { + // If we successfully redeemed a voucher, navigate to Connect screen + if (it is NavResult.Value && it.value) { + navigator.navigate(ConnectDestination) { popUpTo(NavGraphs.root) { inclusive = true } } + } + } + WelcomeScreen( + showSitePayment = IS_PLAY_BUILD.not(), + uiState = state, + uiSideEffect = vm.uiSideEffect, + onSitePaymentClick = vm::onSitePaymentClick, + onRedeemVoucherClick = { + navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true } + }, + onSettingsClick = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + onAccountClick = { navigator.navigate(AccountDestination) { launchSingleTop = true } }, + openConnectScreen = { + navigator.navigate(ConnectDestination) { + launchSingleTop = true + popUpTo(NavGraphs.root) { inclusive = true } + } + }, + navigateToDeviceInfoDialog = { + navigator.navigate(DeviceNameInfoDialogDestination) { launchSingleTop = true } + }, + onPurchaseBillingProductClick = vm::startBillingPayment, + onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog + ) +} + @Composable fun WelcomeScreen( showSitePayment: Boolean, @@ -99,7 +153,8 @@ fun WelcomeScreen( onAccountClick: () -> Unit, openConnectScreen: () -> Unit, onPurchaseBillingProductClick: (productId: ProductId, activityProvider: () -> Activity) -> Unit, - onClosePurchaseResultDialog: (success: Boolean) -> Unit + onClosePurchaseResultDialog: (success: Boolean) -> Unit, + navigateToDeviceInfoDialog: () -> Unit ) { val context = LocalContext.current LaunchedEffect(Unit) { @@ -135,13 +190,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,7 +213,7 @@ fun WelcomeScreen( .background(color = MaterialTheme.colorScheme.primary) ) { // Welcome info area - WelcomeInfo(snackbarHostState, uiState, showSitePayment) + WelcomeInfo(snackbarHostState, uiState, showSitePayment, navigateToDeviceInfoDialog) Spacer(modifier = Modifier.weight(1f)) @@ -186,7 +234,8 @@ fun WelcomeScreen( private fun WelcomeInfo( snackbarHostState: SnackbarHostState, uiState: WelcomeUiState, - showSitePayment: Boolean + showSitePayment: Boolean, + navigateToDeviceInfoDialog: () -> Unit ) { Column { Text( @@ -217,7 +266,7 @@ private fun WelcomeInfo( AccountNumberRow(snackbarHostState, uiState) - DeviceNameRow(deviceName = uiState.deviceName) + DeviceNameRow(deviceName = uiState.deviceName, navigateToDeviceInfoDialog) Text( text = @@ -269,7 +318,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 +337,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 +347,6 @@ fun DeviceNameRow(deviceName: String?) { tint = MullvadWhite ) } - if (showDeviceNameDialog) { - DeviceNameInfoDialog { showDeviceNameDialog = false } - } } } 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 e22aaffde2e0..e539dbafc6bb 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, 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/VpnSettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt index e78d2e9f436f..5525dee8ce88 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, val contentBlockersOptions: DefaultDnsOptions, - val isAllowLanEnabled: Boolean, val selectedObfuscation: SelectedObfuscation, val quantumResistant: QuantumResistantState, val selectedWireguardPort: Constraint, + val customWireguardPort: Constraint?, val availablePortRanges: List, - val dialog: VpnSettingsDialog? ) { companion object { @@ -32,12 +30,11 @@ data class VpnSettingsUiState( isCustomDnsEnabled: Boolean = false, customDnsItems: List = emptyList(), contentBlockersOptions: DefaultDnsOptions = DefaultDnsOptions(), - isAllowLanEnabled: Boolean = false, selectedObfuscation: SelectedObfuscation = SelectedObfuscation.Off, quantumResistant: QuantumResistantState = QuantumResistantState.Off, selectedWireguardPort: Constraint = Constraint.Any(), + customWireguardPort: Constraint.Only? = null, availablePortRanges: List = 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 = emptyList()) : - VpnSettingsDialog - - data class CustomPort(val availablePortRanges: List = emptyList()) : - VpnSettingsDialog -} 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 d7aec9e417c7..388bec98bf39 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/NoTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/NoTransition.kt new file mode 100644 index 000000000000..487ec90ea0ac --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/NoTransition.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeOut +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object NoTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + EnterTransition.None + + // TODO temporary hack until we have a proper solution. + // https://issuetracker.google.com/issues/309506799 + override fun AnimatedContentTransitionScope.exitTransition() = + fadeOut(snap(700)) + + override fun AnimatedContentTransitionScope.popEnterTransition() = + EnterTransition.None + + override fun AnimatedContentTransitionScope.popExitTransition() = + ExitTransition.None +} 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 000000000000..8f59476e5068 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SettingsTransition.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +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 + +object SettingsTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + slideInVertically(initialOffsetY = { it }) + + override fun AnimatedContentTransitionScope.exitTransition() = + slideOutHorizontally(targetOffsetX = { -it }) + + override fun AnimatedContentTransitionScope.popEnterTransition() = + slideInHorizontally(initialOffsetX = { -it }) + + override fun AnimatedContentTransitionScope.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 000000000000..6b404c2b9248 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object SlideInFromBottomTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition(): + EnterTransition { + return slideInVertically(initialOffsetY = { it }) + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = null + + override fun AnimatedContentTransitionScope.popExitTransition(): + ExitTransition { + return 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 000000000000..9a18a2e5de52 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object SlideInFromRightTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition() = + slideInHorizontally(initialOffsetX = { it }) + + override fun AnimatedContentTransitionScope.exitTransition() = + slideOutHorizontally(targetOffsetX = { -it }) + + override fun AnimatedContentTransitionScope.popEnterTransition() = + slideInHorizontally(initialOffsetX = { -it }) + + override fun AnimatedContentTransitionScope.popExitTransition() = + slideOutHorizontally(targetOffsetX = { it }) +} 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 5be527ac0cc9..e3712746faee 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 @@ -25,6 +25,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase 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 @@ -38,12 +39,15 @@ 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.LoginViewModel +import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel 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 @@ -66,7 +70,7 @@ val uiModule = module { single { androidContext().packageManager } single(named(SELF_PACKAGE_NAME)) { androidContext().packageName } - viewModel { SplitTunnelingViewModel(get(), get(), Dispatchers.Default) } + viewModel { SplitTunnelingViewModel(get(), get(), get(), Dispatchers.Default) } single { ApplicationsIconManager(get()) } onClose { it?.dispose() } single { ApplicationsProvider(get(), get(named(SELF_PACKAGE_NAME))) } @@ -97,6 +101,7 @@ val uiModule = module { single { NewDeviceNotificationUseCase(get()) } single { PortRangeUseCase(get()) } single { RelayListUseCase(get(), get()) } + single { OutOfTimeUseCase(get(), get()) } single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } @@ -121,14 +126,21 @@ val uiModule = module { viewModel { ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG) } - viewModel { ConnectViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { ConnectViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { DeviceListViewModel(get(), get()) } viewModel { DeviceRevokedViewModel(get(), get()) } viewModel { LoginViewModel(get(), get(), get()) } + viewModel { MtuDialogViewModel(get()) } + viewModel { parameters -> + DnsDialogViewModel(get(), get(), parameters.getOrNull(), parameters.getOrNull()) + } viewModel { PrivacyDisclaimerViewModel(get()) } viewModel { SelectLocationViewModel(get(), get()) } viewModel { SettingsViewModel(get(), get()) } + viewModel { SplashViewModel(get(), get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } + viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } + viewModel { WelcomeViewModel(get(), get(), get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get(), get()) } viewModel { ReportProblemViewModel(get()) } 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 ac9637c68342..557eb7f72da2 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 @@ -32,7 +32,7 @@ class SettingsRepository( callbackFlowFromNotifier(state.container.settingsListener.settingsNotifier) } .onStart { serviceConnectionManager.settingsListener()?.settingsNotifier?.latestEvent } - .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), null) + .stateIn(CoroutineScope(dispatcher), SharingStarted.Lazily, null) fun setDnsOptions( isCustomDnsEnabled: Boolean, @@ -51,6 +51,31 @@ class SettingsRepository( ) } + fun setDnsState( + state: DnsState, + ) { + updateDnsSettings { it.copy(state = state) } + } + + fun updateCustomDnsList(update: (List) -> List) { + 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?) { serviceConnectionManager.settingsListener()?.wireguardMtu = value } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt deleted file mode 100644 index cece17826792..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -enum class LoginState { - Initial, - InProgress, - Success, - Failure, -} 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 f299b8c956bd..4af608579133 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,86 +2,47 @@ 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.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 org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules -import org.koin.dsl.bind -open class MainActivity : FragmentActivity() { +open 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 - 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() @@ -90,39 +51,16 @@ open class MainActivity : FragmentActivity() { changelogViewModel = get() } - requestedOrientation = - if (deviceIsTv) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - super.onCreate(savedInstanceState) - setContentView(R.layout.main) + setContent { AppTheme { MullvadApp() } } } - override fun onStart() { - Log.d("mullvad", "Starting main activity") - super.onStart() - - if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { - initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() - ) - } else { - openPrivacyDisclaimerFragment() - } - } - - fun initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration: ApiEndpointConfiguration? - ) { - deviceStateJob = launchDeviceStateHandler() + fun initializeStateHandlerAndServiceConnection() { checkForNotificationPermission() serviceConnectionManager.bind( vpnPermissionRequestHandler = ::requestVpnPermission, - apiEndpointConfiguration = apiEndpointConfiguration + apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() ) } @@ -130,6 +68,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,8 +83,6 @@ 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() { @@ -146,88 +90,6 @@ open class MainActivity : FragmentActivity() { 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() - } - } - - 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(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) @@ -235,97 +97,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/extension/ContextExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt index e2e2f5c44c63..f747ae169dd7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt @@ -3,18 +3,6 @@ package net.mullvad.mullvadvpn.ui.extension import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.ui.MainActivity - -fun Fragment.requireMainActivity(): MainActivity { - return if (this.activity is MainActivity) { - this.activity as MainActivity - } else { - throw IllegalStateException( - "Fragment $this not attached to ${MainActivity::class.simpleName}." - ) - } -} fun Context.copyToClipboard(content: String, clipboardLabel: String) { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt index 5225368dacbb..8b137891791f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt @@ -1,56 +1 @@ -package net.mullvad.mullvadvpn.ui.fragment -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.AccountScreen -import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.AccountViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class AccountFragment : BaseFragment() { - private val vm by viewModel() - - @OptIn(ExperimentalMaterial3Api::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - AccountScreen( - showSitePayment = IS_PLAY_BUILD.not(), - uiState = state, - uiSideEffect = vm.uiSideEffect, - enterTransitionEndAction = vm.enterTransitionEndAction, - onRedeemVoucherClick = { openRedeemVoucherFragment() }, - onManageAccountClick = vm::onManageAccountClick, - onLogoutClick = vm::onLogoutClick, - onPurchaseBillingProductClick = vm::startBillingPayment, - onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() } - ) - } - } - } - } - - override fun onEnterTransitionAnimationEnd() { - vm.onTransitionAnimationEnd() - } - - private fun openRedeemVoucherFragment() { - val transaction = parentFragmentManager.beginTransaction() - transaction.addToBackStack(null) - RedeemVoucherDialogFragment().show(transaction, null) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/BaseFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/BaseFragment.kt deleted file mode 100644 index 99b9b42f0982..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/BaseFragment.kt +++ /dev/null @@ -1,71 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import androidx.annotation.LayoutRes -import androidx.core.view.ViewCompat -import androidx.fragment.app.Fragment -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.util.transitionFinished - -abstract class BaseFragment : Fragment { - constructor() : super() - - constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) - - protected var transitionFinishedFlow: Flow = emptyFlow() - private set - - override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { - val zAdjustment = - if (animationsToAdjustZorder.contains(nextAnim)) { - 1f - } else { - 0f - } - ViewCompat.setTranslationZ(requireView(), zAdjustment) - val anim = - if (nextAnim != 0 && enter) { - AnimationUtils.loadAnimation(context, nextAnim)?.apply { - transitionFinishedFlow = transitionFinished() - } - } else { - super.onCreateAnimation(transit, enter, nextAnim) - } - anim?.let { - anim.setAnimationListener( - object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - if (enter) { - onEnterTransitionAnimationEnd() - } - } - }, - ) - } - ?: run { - if (enter) { - onEnterTransitionAnimationEnd() - } - } - return anim - } - - open fun onEnterTransitionAnimationEnd() {} - - companion object { - private val animationsToAdjustZorder = - listOf( - R.anim.fragment_enter_from_right, - R.anim.fragment_exit_to_right, - R.anim.fragment_enter_from_bottom, - R.anim.fragment_exit_to_bottom - ) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt deleted file mode 100644 index 532787ff4f44..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt +++ /dev/null @@ -1,111 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import kotlinx.coroutines.flow.MutableStateFlow -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.ConnectScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild -import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class ConnectFragment : BaseFragment() { - - // Injected dependencies - private val connectViewModel: ConnectViewModel by viewModel() - private val _setNavigationBar = MutableStateFlow(false) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_compose, container, false) - - view.findViewById(R.id.compose_view).setContent { - AppTheme { - val state = connectViewModel.uiState.collectAsState().value - val drawNavbar = _setNavigationBar.collectAsState() - ConnectScreen( - uiState = state, - uiSideEffect = connectViewModel.uiSideEffect, - drawNavigationBar = drawNavbar.value, - onDisconnectClick = connectViewModel::onDisconnectClick, - onReconnectClick = connectViewModel::onReconnectClick, - onConnectClick = connectViewModel::onConnectClick, - onCancelClick = connectViewModel::onCancelClick, - onSwitchLocationClick = ::openSwitchLocationScreen, - onToggleTunnelInfo = connectViewModel::toggleTunnelInfoExpansion, - onUpdateVersionClick = { openDownloadUrl() }, - onManageAccountClick = connectViewModel::onManageAccountClick, - onOpenOutOfTimeScreen = ::openOutOfTimeScreen, - onSettingsClick = ::openSettingsView, - onAccountClick = ::openAccountView, - onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, - ) - } - } - - return view - } - - private fun openDownloadUrl() { - val intent = - Intent( - Intent.ACTION_VIEW, - Uri.parse( - requireContext().getString(R.string.download_url).appendHideNavOnPlayBuild() - ) - ) - .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } - requireContext().startActivity(intent) - } - - private fun openSwitchLocationScreen() { - parentFragmentManager.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, SelectLocationFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - private fun openOutOfTimeScreen() { - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, OutOfTimeFragment()) - commitAllowingStateLoss() - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } - - private fun openAccountView() { - (context as? MainActivity)?.openAccount() - } - - override fun onPause() { - super.onPause() - _setNavigationBar.value = false - } - - // TODO Temporary fix for handling in & out animations until we have Compose Navigation - override fun onEnterTransitionAnimationEnd() { - super.onEnterTransitionAnimationEnd() - _setNavigationBar.value = true - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt deleted file mode 100644 index ab6de40dc2c3..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt +++ /dev/null @@ -1,91 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.DeviceListScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class DeviceListFragment : Fragment() { - - private val deviceListViewModel by viewModel() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launchUiSubscriptionsOnResume() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - deviceListViewModel.accountToken = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY) - - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = deviceListViewModel.uiState.collectAsState().value - DeviceListScreen( - state = state, - onBackClick = { openLoginView(doTriggerAutoLogin = false) }, - onContinueWithLogin = { openLoginView(doTriggerAutoLogin = true) }, - onSettingsClicked = this@DeviceListFragment::openSettings, - onDeviceRemovalClicked = deviceListViewModel::stageDeviceForRemoval, - onDismissDeviceRemovalDialog = deviceListViewModel::clearStagedDevice, - onConfirmDeviceRemovalDialog = - deviceListViewModel::confirmRemovalOfStagedDevice - ) - } - } - } - } - - override fun onResume() { - super.onResume() - deviceListViewModel.clearStagedDevice() - } - - private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { - deviceListViewModel.toastMessages - .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) - .collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } - } - - private fun openLoginView(doTriggerAutoLogin: Boolean) { - parentActivity()?.clearBackStack() - val loginFragment = - LoginFragment().apply { - if (doTriggerAutoLogin && deviceListViewModel.accountToken != null) { - arguments = - Bundle().apply { - putString(ACCOUNT_TOKEN_ARGUMENT_KEY, deviceListViewModel.accountToken) - } - } - } - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, loginFragment) - commitAllowingStateLoss() - } - } - - private fun parentActivity(): MainActivity? { - return (context as? MainActivity) - } - - private fun openSettings() = parentActivity()?.openSettings() -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceRevokedFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceRevokedFragment.kt deleted file mode 100644 index 4b744d568a00..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceRevokedFragment.kt +++ /dev/null @@ -1,42 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.DeviceRevokedScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class DeviceRevokedFragment : Fragment() { - private val deviceRevokedViewModel: DeviceRevokedViewModel by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = deviceRevokedViewModel.uiState.collectAsState().value - DeviceRevokedScreen( - state = state, - onSettingsClicked = this@DeviceRevokedFragment::openSettingsView, - onGoToLoginClicked = deviceRevokedViewModel::onGoToLoginClicked - ) - } - } - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FragmentArgumentConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FragmentArgumentConstant.kt deleted file mode 100644 index 7b066b12462c..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/FragmentArgumentConstant.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -const val ACCOUNT_TOKEN_ARGUMENT_KEY = "accountToken" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt deleted file mode 100644 index d2f0cbfb6e89..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt +++ /dev/null @@ -1,30 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.LoadingScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity - -class LoadingFragment : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { LoadingScreen(this@LoadingFragment::openSettings) } - } - } - } - - private fun openSettings() { - (context as? MainActivity)?.openSettings() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt deleted file mode 100644 index 92d58066ee0b..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt +++ /dev/null @@ -1,89 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.LoginScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.model.AccountToken -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.LoginUiSideEffect -import net.mullvad.mullvadvpn.viewmodel.LoginViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class LoginFragment : BaseFragment() { - private val vm: LoginViewModel by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - - // TODO: Remove this when we have a better solution for login after clearing max devices - val accountTokenArgument = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY) - if (accountTokenArgument != null) { - // Login and set initial TextField value - vm.onAccountNumberChange(accountTokenArgument) - vm.login(accountTokenArgument) - } - - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val uiState by vm.uiState.collectAsState() - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { - when (it) { - LoginUiSideEffect.NavigateToWelcome, - LoginUiSideEffect - .NavigateToConnect -> {} // TODO Fix when we redo navigation - is LoginUiSideEffect.TooManyDevices -> { - navigateToDeviceListFragment(it.accountToken) - } - } - } - } - LoginScreen( - uiState, - vm::login, - vm::createAccount, - vm::clearAccountHistory, - vm::onAccountNumberChange, - ::openSettingsView - ) - } - } - } - } - - private fun navigateToDeviceListFragment(accountToken: AccountToken) { - val deviceFragment = - DeviceListFragment().apply { - arguments = - Bundle().apply { putString(ACCOUNT_TOKEN_ARGUMENT_KEY, accountToken.value) } - } - - parentFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_right, - R.anim.fragment_exit_to_left, - R.anim.fragment_half_enter_from_left, - R.anim.fragment_exit_to_right - ) - replace(R.id.main_fragment, deviceFragment) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt deleted file mode 100644 index 5a1ae49e1a19..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt +++ /dev/null @@ -1,69 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.OutOfTimeScreen -import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class OutOfTimeFragment : BaseFragment() { - - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - OutOfTimeScreen( - showSitePayment = IS_PLAY_BUILD.not(), - uiState = state, - uiSideEffect = vm.uiSideEffect, - onSitePaymentClick = vm::onSitePaymentClick, - onRedeemVoucherClick = ::openRedeemVoucherFragment, - onSettingsClick = ::openSettingsView, - onAccountClick = ::openAccountView, - openConnectScreen = ::advanceToConnectScreen, - onDisconnectClick = vm::onDisconnectClick, - onPurchaseBillingProductClick = vm::startBillingPayment, - onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog - ) - } - } - } - } - - private fun openRedeemVoucherFragment() { - val transaction = parentFragmentManager.beginTransaction() - transaction.addToBackStack(null) - RedeemVoucherDialogFragment { wasSuccessful -> if (wasSuccessful) advanceToConnectScreen() } - .show(transaction, null) - } - - private fun advanceToConnectScreen() { - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, ConnectFragment()) - commitAllowingStateLoss() - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } - - private fun openAccountView() { - (context as? MainActivity)?.openAccount() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt deleted file mode 100644 index ed4538201363..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.PrivacyDisclaimerScreen -import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild -import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel -import org.koin.android.ext.android.inject - -class PrivacyDisclaimerFragment : Fragment() { - - private val privacyDisclaimerViewModel: PrivacyDisclaimerViewModel by inject() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - PrivacyDisclaimerScreen( - onPrivacyPolicyLinkClicked = { openPrivacyPolicy() }, - onAcceptClicked = { handleAcceptedPrivacyDisclaimer() } - ) - } - } - } - } - - private fun handleAcceptedPrivacyDisclaimer() { - privacyDisclaimerViewModel.setPrivacyDisclosureAccepted() - (activity as? MainActivity)?.initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration = activity?.intent?.getApiEndpointConfigurationExtras() - ) - } - - private fun openPrivacyPolicy() { - val privacyPolicyUrlIntent = - Intent( - Intent.ACTION_VIEW, - Uri.parse(getString(R.string.privacy_policy_url).appendHideNavOnPlayBuild()) - ) - context?.startActivity(privacyPolicyUrlIntent) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt deleted file mode 100644 index 397039719c0e..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.ReportProblemScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class ProblemReportFragment : BaseFragment() { - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val uiState = vm.uiState.collectAsState().value - ReportProblemScreen( - uiState, - onSendReport = { email, description -> vm.sendReport(email, description) }, - onDismissNoEmailDialog = vm::dismissConfirmNoEmail, - onClearSendResult = vm::clearSendResult, - onNavigateToViewLogs = { showLogs() } - ) { - activity?.onBackPressed() - } - } - } - } - } - - private fun showLogs() { - parentFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_right, - R.anim.fragment_half_exit_to_left, - R.anim.fragment_half_enter_from_left, - R.anim.fragment_exit_to_right - ) - replace(R.id.main_fragment, ViewLogsFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt deleted file mode 100644 index 2730fde5479d..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.DialogFragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.RedeemVoucherDialogScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class RedeemVoucherDialogFragment(val onDialogDismiss: (Boolean) -> Unit = {}) : DialogFragment() { - - private val vm by viewModel() - private lateinit var voucherDialog: Dialog - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - RedeemVoucherDialogScreen( - uiState = vm.uiState.collectAsState().value, - onVoucherInputChange = { vm.onVoucherInputChange(it) }, - onRedeem = { vm.onRedeem(it) }, - onDismiss = { - onDismiss(voucherDialog) - onDialogDismiss(it) - } - ) - } - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - voucherDialog = super.onCreateDialog(savedInstanceState) - return voucherDialog - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt deleted file mode 100644 index d1c4ac72bfbf..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SelectLocationFragment.kt +++ /dev/null @@ -1,44 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.SelectLocationScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class SelectLocationFragment : BaseFragment() { - - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - SelectLocationScreen( - uiState = state, - uiCloseAction = vm.uiCloseAction, - enterTransitionEndAction = vm.enterTransitionEndAction, - onSelectRelay = vm::selectRelay, - onSearchTermInput = vm::onSearchTermInput, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, - ) - } - } - } - } - - override fun onEnterTransitionAnimationEnd() { - vm.onTransitionAnimationEnd() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt deleted file mode 100644 index e5faf6bb1171..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SettingsFragment.kt +++ /dev/null @@ -1,72 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.SettingsScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class SettingsFragment : BaseFragment() { - private val vm by viewModel() - - @OptIn(ExperimentalMaterial3Api::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - SettingsScreen( - uiState = state, - enterTransitionEndAction = vm.enterTransitionEndAction, - onVpnSettingCellClick = { openVpnSettingsFragment() }, - onSplitTunnelingCellClick = { openSplitTunnelingFragment() }, - onReportProblemCellClick = { openReportProblemFragment() }, - onBackClick = { activity?.onBackPressed() } - ) - } - } - } - } - - override fun onEnterTransitionAnimationEnd() { - vm.onTransitionAnimationEnd() - } - - private fun openFragment(fragment: Fragment) { - parentFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_right, - R.anim.fragment_exit_to_left, - R.anim.fragment_half_enter_from_left, - R.anim.fragment_exit_to_right - ) - replace(R.id.main_fragment, fragment) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - private fun openVpnSettingsFragment() { - openFragment(VpnSettingsFragment()) - } - - private fun openSplitTunnelingFragment() { - openFragment(SplitTunnelingFragment()) - } - - private fun openReportProblemFragment() { - openFragment(ProblemReportFragment()) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt deleted file mode 100644 index 7004303ae8c1..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/SplitTunnelingFragment.kt +++ /dev/null @@ -1,44 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.applist.ApplicationsIconManager -import net.mullvad.mullvadvpn.compose.screen.SplitTunnelingScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel - -class SplitTunnelingFragment : BaseFragment() { - private val viewModel: SplitTunnelingViewModel by viewModel() - private val applicationsIconManager: ApplicationsIconManager by inject() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = viewModel.uiState.collectAsState().value - SplitTunnelingScreen( - uiState = state, - onShowSystemAppsClick = viewModel::onShowSystemAppsClick, - onExcludeAppClick = viewModel::onExcludeAppClick, - onIncludeAppClick = viewModel::onIncludeAppClick, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, - onResolveIcon = { packageName -> - applicationsIconManager.getAppIcon(packageName) - } - ) - } - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt deleted file mode 100644 index 21931ab87638..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt +++ /dev/null @@ -1,36 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.ViewLogsScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class ViewLogsFragment : BaseFragment() { - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val uiState = vm.uiState.collectAsState() - ViewLogsScreen( - uiState = uiState.value, - onBackClick = { activity?.onBackPressed() } - ) - } - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/VpnSettingsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/VpnSettingsFragment.kt deleted file mode 100644 index 49d43e6b2770..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/VpnSettingsFragment.kt +++ /dev/null @@ -1,69 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.VpnSettingsScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class VpnSettingsFragment : BaseFragment() { - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - VpnSettingsScreen( - uiState = state, - onMtuCellClick = vm::onMtuCellClick, - onSaveMtuClick = vm::onSaveMtuClick, - onRestoreMtuClick = vm::onRestoreMtuClick, - onCancelMtuDialogClick = vm::onCancelDialogClick, - onToggleAutoConnect = vm::onToggleAutoConnect, - onToggleLocalNetworkSharing = vm::onToggleLocalNetworkSharing, - onToggleDnsClick = vm::onToggleDnsClick, - onToggleBlockAds = vm::onToggleBlockAds, - onToggleBlockTrackers = vm::onToggleBlockTrackers, - onToggleBlockMalware = vm::onToggleBlockMalware, - onToggleBlockAdultContent = vm::onToggleBlockAdultContent, - onToggleBlockGambling = vm::onToggleBlockGambling, - onToggleBlockSocialMedia = vm::onToggleBlockSocialMedia, - onDnsClick = vm::onDnsClick, - onDnsInputChange = vm::onDnsInputChange, - onSaveDnsClick = vm::onSaveDnsClick, - onRemoveDnsClick = vm::onRemoveDnsClick, - onCancelDnsDialogClick = vm::onCancelDns, - onLocalNetworkSharingInfoClick = vm::onLocalNetworkSharingInfoClick, - onContentsBlockersInfoClick = vm::onContentsBlockerInfoClick, - onCustomDnsInfoClick = vm::onCustomDnsInfoClick, - onMalwareInfoClick = vm::onMalwareInfoClick, - onDismissInfoClick = vm::onDismissInfoClick, - onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }, - onStopEvent = vm::onStopEvent, - toastMessagesSharedFlow = vm.toastMessages, - onSelectObfuscationSetting = vm::onSelectObfuscationSetting, - onObfuscationInfoClick = vm::onObfuscationInfoClick, - onSelectQuantumResistanceSetting = vm::onSelectQuantumResistanceSetting, - onQuantumResistanceInfoClicked = vm::onQuantumResistanceInfoClicked, - onWireguardPortSelected = vm::onWireguardPortSelected, - onWireguardPortInfoClicked = vm::onWireguardPortInfoClicked, - onShowCustomPortDialog = vm::onShowCustomPortDialog, - onCancelCustomPortDialogClick = vm::onCancelDialogClick, - onCloseCustomPortDialog = vm::onCancelDialogClick - ) - } - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt deleted file mode 100644 index 5c5e0c83f85b..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt +++ /dev/null @@ -1,68 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.WelcomeScreen -import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class WelcomeFragment : BaseFragment() { - - private val vm by viewModel() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = vm.uiState.collectAsState().value - WelcomeScreen( - showSitePayment = IS_PLAY_BUILD.not(), - uiState = state, - uiSideEffect = vm.uiSideEffect, - onSitePaymentClick = vm::onSitePaymentClick, - onRedeemVoucherClick = ::openRedeemVoucherFragment, - onSettingsClick = ::openSettingsView, - onAccountClick = ::openAccountView, - openConnectScreen = ::advanceToConnectScreen, - onPurchaseBillingProductClick = vm::startBillingPayment, - onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog - ) - } - } - } - } - - private fun openRedeemVoucherFragment() { - val transaction = parentFragmentManager.beginTransaction() - transaction.addToBackStack(null) - RedeemVoucherDialogFragment { wasSuccessful -> if (wasSuccessful) advanceToConnectScreen() } - .show(transaction, null) - } - - private fun advanceToConnectScreen() { - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, ConnectFragment()) - commitAllowingStateLoss() - } - } - - private fun openSettingsView() { - (context as? MainActivity)?.openSettings() - } - - private fun openAccountView() { - (context as? MainActivity)?.openAccount() - } -} 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 7f44b0c7d486..4e1d773f1e42 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/usecase/OutOfTimeUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt new file mode 100644 index 000000000000..f1b19e890db9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt @@ -0,0 +1,55 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +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.AccountExpiry +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.talpid.tunnel.ErrorStateCause +import org.joda.time.DateTime + +const val accountRefreshInterval = 1000L * 60L // 1 minute + +class OutOfTimeUseCase(val repository: AccountRepository, val messageHandler: MessageHandler) { + + operator fun invoke() = + combine(pastAccountExpiry(), isTunnelBlockedBecauseOutOfTime()) { + pastAccountExpiry, + tunnelOutOfTime -> + pastAccountExpiry or tunnelOutOfTime + } + + private fun isTunnelBlockedBecauseOutOfTime() = + messageHandler.events().map { + it.tunnelState.isTunnelErrorStateDueToExpiredAccount() + } + + private fun TunnelState.isTunnelErrorStateDueToExpiredAccount(): Boolean { + return ((this as? TunnelState.Error)?.errorState?.cause as? ErrorStateCause.AuthFailed) + ?.isCausedByExpiredAccount() + ?: false + } + + private fun pastAccountExpiry(): Flow = + combine(repository.accountExpiryState, timeFlow()) { accountExpiryState, time -> + when (accountExpiryState) { + is AccountExpiry.Available -> { + accountExpiryState.date()?.isBefore(time) ?: false + } + AccountExpiry.Missing -> false + } + } + + private fun timeFlow() = flow { + while (true) { + emit(DateTime.now()) + delay(accountRefreshInterval) + } + } +} 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 e782f6f43937..5bb8f5cad937 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,36 +1,12 @@ package net.mullvad.mullvadvpn.util -import android.view.animation.Animation -import kotlin.coroutines.EmptyCoroutineContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.take -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 = - 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 Flow.flatMapReadyConnectionOrDefault( default: Flow, transform: (value: ServiceConnectionState.ConnectedReady) -> Flow 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 0a5167da2ed5..0f0708707eb8 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.isCustom() = is Constraint.Only -> !WIREGUARD_PRESET_PORTS.contains(this.value.value) } -fun Constraint.toDisplayCustomPort() = +fun Constraint.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 5f721674990a..4f4c41b9016d 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 @@ -32,8 +32,6 @@ class AccountViewModel( ) : ViewModel() { private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) - private val _enterTransitionEndAction = MutableSharedFlow() - val uiSideEffect = _uiSideEffect.asSharedFlow() val uiState: StateFlow = @@ -53,9 +51,6 @@ class AccountViewModel( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AccountUiState.default()) - @Suppress("konsist.ensure public properties use permitted names") - val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() - init { updateAccountExpiry() verifyPurchases() @@ -74,10 +69,11 @@ class AccountViewModel( fun onLogoutClick() { accountRepository.logout() + viewModelScope.launch { _uiSideEffect.emit(UiSideEffect.NavigateToLogin) } } - fun onTransitionAnimationEnd() { - viewModelScope.launch { _enterTransitionEndAction.emit(Unit) } + fun onCopyAccountNumber(accountNumber: String) { + viewModelScope.launch { _uiSideEffect.emit(UiSideEffect.CopyAccountNumber(accountNumber)) } } fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { @@ -115,7 +111,11 @@ class AccountViewModel( } sealed class UiSideEffect { + data object NavigateToLogin : UiSideEffect() + data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect() + + data class CopyAccountNumber(val accountNumber: String) : UiSideEffect() } } 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 f6549cded6a9..542a5e048f9c 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.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(replay = 1, extraBufferCapacity = 1) + val uiSideEffect: SharedFlow = _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) : 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) : 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 9f6cec8391f1..03bd0001eb0a 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 @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -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.RelayListUseCase import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import net.mullvad.mullvadvpn.util.combine @@ -40,7 +43,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( @@ -49,7 +51,8 @@ class ConnectViewModel( private val deviceRepository: DeviceRepository, private val inAppNotificationController: InAppNotificationController, private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase, - private val relayListUseCase: RelayListUseCase + private val relayListUseCase: RelayListUseCase, + private val outOfTimeUseCase: OutOfTimeUseCase ) : ViewModel() { private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) val uiSideEffect = _uiSideEffect.asSharedFlow() @@ -88,9 +91,6 @@ class ConnectViewModel( accountExpiry, isTunnelInfoExpanded, deviceName -> - if (tunnelRealState.isTunnelErrorStateDueToExpiredAccount()) { - _uiSideEffect.tryEmit(UiSideEffect.OpenOutOfTimeView) - } ConnectUiState( location = when (tunnelRealState) { @@ -133,6 +133,13 @@ class ConnectViewModel( .debounce(UI_STATE_DEBOUNCE_DURATION_MILLIS) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ConnectUiState.INITIAL) + init { + viewModelScope.launch { + outOfTimeUseCase().filter { it }.first() + _uiSideEffect.emit(UiSideEffect.OutOfTime) + } + } + private fun LocationInfoCache.locationCallbackFlow() = callbackFlow { onNewLocation = { this.trySend(it) } awaitClose { onNewLocation = null } @@ -144,12 +151,6 @@ class ConnectViewModel( private fun ConnectionProxy.tunnelRealStateFlow(): Flow = 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() } @@ -187,7 +188,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 98648e0015bb..19a458114abf 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 @@ -7,8 +7,8 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow 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 @@ -33,22 +33,15 @@ class DeviceListViewModel( private val resources: Resources, private val dispatcher: CoroutineDispatcher = Dispatchers.Default ) : ViewModel() { - private val _stagedDeviceId = MutableStateFlow(null) private val _loadingDevices = MutableStateFlow>(emptyList()) - private val _toastMessages = MutableSharedFlow(extraBufferCapacity = 1) - @Suppress("konsist.ensure public properties use permitted names") - val toastMessages = _toastMessages.asSharedFlow() + private val _uiSideEffect: MutableSharedFlow = MutableSharedFlow() + val uiSideEffect: SharedFlow = _uiSideEffect - @Suppress("konsist.ensure public properties use permitted names") - var accountToken: String? = null private var cachedDeviceList: List? = 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 +58,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 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() - } + fun removeDevice(accountToken: String, deviceIdToRemove: DeviceId) { + + 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.emit( + 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 +108,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 000000000000..eb068f710ad8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt @@ -0,0 +1,177 @@ +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.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import net.mullvad.mullvadvpn.model.DnsOptions +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 dnsOptions: DnsOptions, + val customDnsList: List, + val isAllowLanEnabled: Boolean +) + +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 = runBlocking { + repository.settingsUpdates + .filterNotNull() + .map { + val dnsState = it.tunnelOptions.dnsOptions + val customDnsList = it.addresses() + val isAllowLanEnabled = it.allowLan + + DnsDialogViewModelState( + dnsState, + customDnsList, + isAllowLanEnabled = isAllowLanEnabled + ) + } + .stateIn(viewModelScope) + } + + // TODO Discuss runBlocking, when do we assume we have a value? + val uiState: StateFlow = runBlocking { + combine(_ipAddressInput, vmState) { ipAddress, vmState -> + DnsDialogViewState( + ipAddress, + ipAddress.validateDnsEntry(index, vmState.customDnsList), + ipAddress.isLocalAddress(), + isAllowLanEnabled = vmState.isAllowLanEnabled, + index == null + ) + } + .stateIn(viewModelScope) + } + + private val _uiSideEffect = MutableSharedFlow() + val uiSideEffect: SharedFlow = _uiSideEffect + + private fun String.validateDnsEntry( + index: Int?, + dnsList: List + ): 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.emit(DnsDialogSideEffect.Complete) + } + + fun onRemoveDnsClick() = + viewModelScope.launch(dispatcher) { + repository.updateCustomDnsList { + it.filter { it.hostAddress != uiState.value.ipAddress } + } + _uiSideEffect.emit(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 + ): Boolean = + dnsList.withIndex().any { (index, entry) -> + if (index == currentIndex) { + // Ignore current index, it may be the same + false + } else { + entry == this + } + } + + private fun List.asStringAddressList(): List { + return map { + CustomDnsItem(address = it.hostAddress ?: EMPTY_STRING, isLocal = it.isLocalAddress()) + } + } + + 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/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt index b31478ce1a95..05e2827f078c 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 @@ -103,10 +103,11 @@ class LoginViewModel( if (refreshResult.isAvailable()) { // Navigate to device list + _uiSideEffect.emit( LoginUiSideEffect.TooManyDevices(AccountToken(accountToken)) ) - return@launch + Idle() } else { // Failed to fetch devices list Idle(LoginError.Unknown(result.toString())) 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 000000000000..067a26c79fed --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +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 = MutableSharedFlow() + val uiSideEffect: SharedFlow = _uiSideEffect + + fun onSaveClick(mtuValue: Int) = + viewModelScope.launch(dispatcher) { + if (mtuValue.isValidMtu()) { + repository.setWireguardMtu(mtuValue) + } + _uiSideEffect.emit(MtuDialogSideEffect.Complete) + } + + fun onRestoreClick() = + viewModelScope.launch(dispatcher) { + repository.setWireguardMtu(null) + _uiSideEffect.emit(MtuDialogSideEffect.Complete) + } +} + +sealed interface MtuDialogSideEffect { + data object Complete : MtuDialogSideEffect +} 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 c3b63bb818e4..5ddb172d7fb7 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,25 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository class PrivacyDisclaimerViewModel( private val privacyDisclaimerRepository: PrivacyDisclaimerRepository ) : ViewModel() { - fun setPrivacyDisclosureAccepted() = privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + private val _uiSideEffect = + MutableSharedFlow(extraBufferCapacity = 1) + val uiSideEffect = _uiSideEffect.asSharedFlow() + + fun setPrivacyDisclosureAccepted() { + privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + viewModelScope.launch { _uiSideEffect.emit(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 a7daf9e8d9d2..6bcec35ece6a 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 @@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -14,7 +16,8 @@ import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult import net.mullvad.mullvadvpn.dataproxy.UserReport data class ReportProblemUiState( - val showConfirmNoEmail: Boolean = false, + val email: String = "", + val description: String = "", val sendingState: SendingReportUiState? = null ) @@ -26,25 +29,28 @@ sealed interface SendingReportUiState { data class Error(val error: SendProblemReportResult.Error) : SendingReportUiState } +sealed interface ReportProblemSideEffect { + data class ShowConfirmNoEmail(val email: String, val description: String) : + ReportProblemSideEffect +} + class ReportProblemViewModel(private val mullvadProblemReporter: MullvadProblemReport) : ViewModel() { private val _uiState = MutableStateFlow(ReportProblemUiState()) val uiState = _uiState.asStateFlow() - fun sendReport( - email: String, - description: String, - ) { + private val _uiSideEffect = MutableSharedFlow() + val uiSideEffect = _uiSideEffect.asSharedFlow() + + 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)) { - _uiState.update { it.copy(showConfirmNoEmail = true) } + if (!skipEmptyEmailCheck && shouldShowConfirmNoEmail(nullableEmail)) { + _uiSideEffect.emit(ReportProblemSideEffect.ShowConfirmNoEmail(email, description)) } else { - _uiState.update { - it.copy(sendingState = SendingReportUiState.Sending, showConfirmNoEmail = false) - } + _uiState.update { it.copy(sendingState = SendingReportUiState.Sending) } // Ensure we show loading for at least MINIMUM_LOADING_TIME_MILLIS val deferredResult = async { @@ -63,14 +69,8 @@ class ReportProblemViewModel(private val mullvadProblemReporter: MullvadProblemR _uiState.update { it.copy(sendingState = null) } } - fun dismissConfirmNoEmail() { - _uiState.update { it.copy(showConfirmNoEmail = false) } - } - 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) { @@ -87,4 +87,9 @@ class ReportProblemViewModel(private val mullvadProblemReporter: MullvadProblemR // Delete any logs if user leaves the screen mullvadProblemReporter.deleteLogs() } + + fun onEmailChanged(email: String) = _uiState.update { it.copy(email = email) } + + fun onDescriptionChanged(description: String) = + _uiState.update { it.copy(description = description) } } 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 5e95674e0a6a..3bbe18a7fb77 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 @@ -21,7 +21,6 @@ class SelectLocationViewModel( private val relayListUseCase: RelayListUseCase ) : ViewModel() { private val _closeAction = MutableSharedFlow() - private val _enterTransitionEndAction = MutableSharedFlow() private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) val uiState = @@ -47,8 +46,6 @@ class SelectLocationViewModel( @Suppress("konsist.ensure public properties use permitted names") val uiCloseAction = _closeAction.asSharedFlow() - @Suppress("konsist.ensure public properties use permitted names") - val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow() fun selectRelay(relayItem: RelayItem) { relayListUseCase.updateSelectedRelayLocation(relayItem.location) @@ -56,10 +53,6 @@ class SelectLocationViewModel( viewModelScope.launch { _closeAction.emit(Unit) } } - fun onTransitionAnimationEnd() { - viewModelScope.launch { _enterTransitionEndAction.emit(Unit) } - } - fun onSearchTermInput(searchTerm: String) { viewModelScope.launch { _searchTerm.emit(searchTerm) } } 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 fb357dfe2a3a..8ef85cfca820 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() private val vmState: StateFlow = 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 000000000000..29b1262a537e --- /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.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +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 = + MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val uiSideEffect = _uiSideEffect.asSharedFlow() + + fun start() { + viewModelScope.launch { + if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + _uiSideEffect.emit(getStartDestination()) + } else { + _uiSideEffect.emit(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().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/SplitTunnelingViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt index bb543d85cfa4..994d31d6feef 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.applist.AppData +import net.mullvad.mullvadvpn.applist.ApplicationsIconManager import net.mullvad.mullvadvpn.applist.ApplicationsProvider import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer @@ -28,7 +29,8 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.splitTunneling class SplitTunnelingViewModel( private val appsProvider: ApplicationsProvider, private val serviceConnectionManager: ServiceConnectionManager, - private val dispatcher: CoroutineDispatcher + private val applicationsIconManager: ApplicationsIconManager, + private val dispatcher: CoroutineDispatcher, ) : ViewModel() { private val allApps = MutableStateFlow?>(null) 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 07c56ff954c4..ea1219609155 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 { serviceConnection -> voucherRedeemer = serviceConnection.voucherRedeemer 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 dfae3df53956..25444458a504 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 @@ -12,6 +12,8 @@ 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.stateIn import kotlinx.coroutines.flow.update @@ -32,29 +34,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(extraBufferCapacity = 1) - @Suppress("konsist.ensure public properties use permitted names") - val toastMessages = _toastMessages.asSharedFlow() + private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) + val uiSideEffect = _uiSideEffect.asSharedFlow() - private val dialogState = MutableStateFlow(null) + private val customPort = MutableStateFlow?>(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 +68,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 +91,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 +113,19 @@ class VpnSettingsViewModel( viewModelScope.launch(dispatcher) { repository.setLocalNetworkSharing(isEnabled) } } - fun onToggleDnsClick(isEnabled: Boolean) { - updateCustomDnsState(isEnabled) - if (isEnabled && vmState.value.customDnsList.isEmpty()) { - onDnsClick(null) + fun onDnsDialogDismiss() { + if (vmState.value.customDnsList.isEmpty()) { + onToggleDnsClick(false) + } + } + + fun onToggleDnsClick(enable: Boolean) { + repository.setDnsState(if (enable) DnsState.Custom else DnsState.Default) + if (enable && vmState.value.customDnsList.isEmpty()) { + viewModelScope.launch { _uiSideEffect.emit(VpnSettingsSideEffect.NavigateToDnsDialog) } + } else { + showApplySettingChangesWarningToast() } - showApplySettingChangesWarningToast() } fun onToggleBlockAds(isEnabled: Boolean) { @@ -281,29 +170,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 +187,24 @@ 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) { + 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 } + relayListUseCase.updateSelectedWireguardConstraints( + WireguardConstraints(port = Constraint.Any()) + ) } private fun updateDefaultDnsOptionsViaRepository(contentBlockersOption: DefaultDnsOptions) = @@ -354,26 +216,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.asInetAddressList(): List { return try { map { InetAddress.getByName(it) } @@ -408,32 +250,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.emit( + 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 2ebc2b397cac..fd236e840573 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, val contentBlockersOptions: DefaultDnsOptions, val selectedObfuscation: SelectedObfuscation, val quantumResistant: QuantumResistantState, val selectedWireguardPort: Constraint, + val customWireguardPort: Constraint?, val availablePortRanges: List, - 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/res/layout/fragment_compose.xml b/android/app/src/main/res/layout/fragment_compose.xml deleted file mode 100644 index a3a147d9971f..000000000000 --- a/android/app/src/main/res/layout/fragment_compose.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/android/app/src/main/res/layout/main.xml b/android/app/src/main/res/layout/main.xml deleted file mode 100644 index 8e7356e766ee..000000000000 --- a/android/app/src/main/res/layout/main.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt index e223a1253980..742f28e77d88 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt @@ -8,27 +8,30 @@ import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.mockkStatic import io.mockk.unmockkAll -import io.mockk.verify -import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.repository.ChangelogRepository import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test class ChangelogViewModelTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() @MockK private lateinit var mockedChangelogRepository: ChangelogRepository private lateinit var viewModel: ChangelogViewModel + private val buildVersionCode = 10 + @Before fun setup() { MockKAnnotations.init(this) mockkStatic(EVENT_NOTIFIER_EXTENSION_CLASS) every { mockedChangelogRepository.setVersionCodeOfMostRecentChangelogShowed(any()) } just Runs - viewModel = ChangelogViewModel(mockedChangelogRepository, 1, false) } @After @@ -37,50 +40,36 @@ class ChangelogViewModelTest { } @Test - fun testInitialState() = runTest { - // Arrange, Act, Assert - viewModel.uiState.test { assertEquals(ChangelogDialogUiState.Hide, awaitItem()) } + fun testUpToDateVersionCode() = runTest { + // Arrange + every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns + buildVersionCode + viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false) + + // If we have the most up to date version code, we should not show the changelog dialog + viewModel.uiSideEffect.test { expectNoEvents() } } @Test - fun testShowAndDismissChangelogDialog() = runTest { - viewModel.uiState.test { - // Arrange - val fakeList = listOf("test") - every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns - -1 - every { mockedChangelogRepository.getLastVersionChanges() } returns fakeList - - // Assert initial ui state - assertEquals(ChangelogDialogUiState.Hide, awaitItem()) + fun testNotUpToDateVersionCode() = runTest { + // Arrange + every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns -1 + every { mockedChangelogRepository.getLastVersionChanges() } returns listOf("bla", "bla") - // Refresh and verify that the dialog should be shown - viewModel.refreshChangelogDialogUiState() - assertEquals(ChangelogDialogUiState.Show(fakeList), awaitItem()) - - // Dismiss dialog and verify that the dialog should be hidden - viewModel.dismissChangelogDialog() - assertEquals(ChangelogDialogUiState.Hide, awaitItem()) - verify { mockedChangelogRepository.setVersionCodeOfMostRecentChangelogShowed(1) } - } + viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false) + // Given a new version with a change log we should return it + viewModel.uiSideEffect.test { assertNotNull(awaitItem()) } } @Test - fun testShowCaseChangelogWithEmptyListDialog() = runTest { - viewModel.uiState.test { - // Arrange - val fakeEmptyList = emptyList() - every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns - -1 - every { mockedChangelogRepository.getLastVersionChanges() } returns fakeEmptyList - - // Assert initial ui state - assertEquals(ChangelogDialogUiState.Hide, awaitItem()) + fun testNotUpToDateVersionCodeWithEmptyChangeLog() = runTest { + // Arrange + every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns -1 + every { mockedChangelogRepository.getLastVersionChanges() } returns emptyList() - // Refresh and verify that the Ui state remain same due list being empty - viewModel.refreshChangelogDialogUiState() - expectNoEvents() - } + viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false) + // Given a new version with a change log we should not return it + viewModel.uiSideEffect.test { expectNoEvents() } } companion object { diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index 1b2e262b3dd4..948007a07ce3 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt @@ -352,7 +352,7 @@ class ConnectViewModelTest { } // Assert - assertIs(deferred.await()) + assertIs(deferred.await()) } companion object { diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt index 4f91189d0a42..b680eb47cfe6 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt @@ -79,16 +79,21 @@ class ReportProblemModelTest { coEvery { mockMullvadProblemReport.sendReport(any()) } returns SendProblemReportResult.Success val email = "" + val description = "My description" // Act, Assert viewModel.uiState.test { - assertEquals(ReportProblemUiState(false, null), awaitItem()) - viewModel.sendReport(email, "My description") - assertEquals(ReportProblemUiState(true, null), awaitItem()) - viewModel.sendReport(email, "My description") - assertEquals(ReportProblemUiState(false, SendingReportUiState.Sending), awaitItem()) + assertEquals(ReportProblemUiState(), awaitItem()) + viewModel.onDescriptionChanged(description) + assertEquals(ReportProblemUiState(description = description), awaitItem()) + + viewModel.sendReport(email, description, true) + assertEquals( + ReportProblemUiState(email, description, SendingReportUiState.Sending), + awaitItem() + ) assertEquals( - ReportProblemUiState(false, SendingReportUiState.Success(null)), + ReportProblemUiState(email, description, SendingReportUiState.Success(null)), awaitItem() ) } @@ -101,16 +106,25 @@ class ReportProblemModelTest { coEvery { mockMullvadProblemReport.sendReport(any()) } returns SendProblemReportResult.Success val email = "my@email.com" + val description = "My description" // Act, Assert viewModel.uiState.test { - assertEquals(awaitItem(), ReportProblemUiState(false, null)) - viewModel.sendReport(email, "My description") + assertEquals(awaitItem(), ReportProblemUiState("", "", null)) + viewModel.onEmailChanged(email) + awaitItem() + viewModel.onDescriptionChanged(description) + awaitItem() + + viewModel.sendReport(email, description) - assertEquals(awaitItem(), ReportProblemUiState(false, SendingReportUiState.Sending)) assertEquals( - awaitItem(), - ReportProblemUiState(false, SendingReportUiState.Success(email)) + ReportProblemUiState(email, description, SendingReportUiState.Sending), + awaitItem() + ) + assertEquals( + ReportProblemUiState(email, description, SendingReportUiState.Success(email)), + awaitItem() ) } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt index 5409b7f736ac..e2bf08300577 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelTest.kt @@ -204,6 +204,7 @@ class SplitTunnelingViewModelTest { SplitTunnelingViewModel( mockedApplicationsProvider, mockedServiceConnectionManager, + applicationsIconManager = mockk(), testCoroutineRule.testDispatcher ) } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt index f8736eb823ea..0ac13777cd84 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt @@ -9,15 +9,11 @@ import io.mockk.unmockkAll import io.mockk.verify import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertTrue import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.VpnSettingsDialog -import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.model.Constraint import net.mullvad.mullvadvpn.model.Port import net.mullvad.mullvadvpn.model.PortRange @@ -31,7 +27,6 @@ import net.mullvad.mullvadvpn.model.WireguardTunnelOptions import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.usecase.PortRangeUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase -import org.apache.commons.validator.routines.InetAddressValidator import org.junit.After import org.junit.Before import org.junit.Rule @@ -41,7 +36,6 @@ class VpnSettingsViewModelTest { @get:Rule val testCoroutineRule = TestCoroutineRule() private val mockSettingsRepository: SettingsRepository = mockk() - private val mockInetAddressValidator: InetAddressValidator = mockk() private val mockResources: Resources = mockk() private val mockPortRangeUseCase: PortRangeUseCase = mockk() private val mockRelayListUseCase: RelayListUseCase = mockk() @@ -59,7 +53,6 @@ class VpnSettingsViewModelTest { viewModel = VpnSettingsViewModel( repository = mockSettingsRepository, - inetAddressValidator = mockInetAddressValidator, resources = mockResources, portRangeUseCase = mockPortRangeUseCase, relayListUseCase = mockRelayListUseCase, @@ -133,6 +126,7 @@ class VpnSettingsViewModelTest { viewModel.uiState.test { assertIs>(awaitItem().selectedWireguardPort) mockSettingsUpdate.value = mockSettings + assertEquals(expectedPort, awaitItem().customWireguardPort) assertEquals(expectedPort, awaitItem().selectedWireguardPort) } } @@ -152,23 +146,4 @@ class VpnSettingsViewModelTest { mockRelayListUseCase.updateSelectedWireguardConstraints(wireguardConstraints) } } - - @Test - fun test_update_port_range_state() = runTest { - // Arrange - val expectedPortRange = listOf(mockk(), mockk()) - val mockSettings: Settings = mockk(relaxed = true) - - every { mockSettings.relaySettings } returns mockk(relaxed = true) - portRangeFlow.value = expectedPortRange - - // Act, Assert - viewModel.uiState.test { - assertIs(awaitItem()) - viewModel.onWireguardPortInfoClicked() - val state = awaitItem() - assertTrue { state.dialog is VpnSettingsDialog.WireguardPortInfo } - assertLists(expectedPortRange, state.availablePortRanges) - } - } } diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index 57af45997b17..216690677537 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -45,6 +45,7 @@ object Dependencies { } object Compose { + const val destinations = "io.github.raamcosta.compose-destinations:core:${Versions.Compose.destinations}" const val constrainLayout = "androidx.constraintlayout:constraintlayout-compose:${Versions.Compose.constrainLayout}" const val foundation = @@ -130,5 +131,6 @@ object Dependencies { const val dependencyCheckId = "org.owasp.dependencycheck" const val gradleVersionsId = "com.github.ben-manes.versions" const val ktfmtId = "com.ncorti.ktfmt.gradle" + const val ksp = "com.google.devtools.ksp" } } diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index 4ceb4f787f52..9ab76de3d39e 100644 --- a/android/buildSrc/src/main/kotlin/Versions.kt +++ b/android/buildSrc/src/main/kotlin/Versions.kt @@ -14,10 +14,10 @@ object Versions { const val billingClient = "6.0.1" object Android { - const val compileSdkVersion = 33 + const val compileSdkVersion = 34 const val material = "1.9.0" const val minSdkVersion = 26 - const val targetSdkVersion = 33 + const val targetSdkVersion = 34 const val volley = "1.2.1" } @@ -39,6 +39,7 @@ object Versions { } object Compose { + const val destinations = "1.9.54" const val base = "1.5.1" const val constrainLayout = "1.0.1" const val foundation = base @@ -48,6 +49,7 @@ object Versions { } object Plugin { + // The androidAapt plugin version must be in sync with the android plugin version. // Required for Gradle metadata verification to work properly, see: // https://github.com/gradle/gradle/issues/19228 @@ -57,6 +59,9 @@ object Versions { const val dependencyCheck = "8.3.1" const val gradleVersions = "0.47.0" const val ktfmt = "0.13.0" + // Ksp version is linked with kotlin version, find matching release here: + // https://github.com/google/ksp/releases + const val ksp = "${kotlin}-1.0.13" } object Koin { diff --git a/android/docs/diagrams/nav_graph.dot b/android/docs/diagrams/nav_graph.dot new file mode 100644 index 000000000000..7925bb533d52 --- /dev/null +++ b/android/docs/diagrams/nav_graph.dot @@ -0,0 +1,48 @@ +digraph { + { + node [shape=rectangle] + splash [label="Splash"] + login [label="Login"] + connect [label="Connect"] + too_many_devices [label="Too many devices"] + welcome [label="Welcome"] + revoked [label="Revoked"] + privacy_policy [label="Privacy policy"] + account [label="Account"] + settings [label = "Settings"] + report_problem [label = "Report problem"] + view_logs [label = "View logs"] + split_tunneling [label = "Split tunneling"] + vpn_settings [label = "VPN settings"] + switch_location [label = "Switch location"] + } + + splash -> privacy_policy + splash -> login + splash -> connect + splash -> revoked + + + revoked -> login + privacy_policy -> login + + login -> welcome + login -> too_many_devices + login -> settings + login -> connect + + too_many_devices -> login + + welcome -> connect + + connect -> revoked + connect -> settings + connect -> account + connect -> switch_location + + settings -> vpn_settings + settings -> split_tunneling + settings -> report_problem + + report_problem -> view_logs +} diff --git a/android/docs/diagrams/nav_graph.svg b/android/docs/diagrams/nav_graph.svg new file mode 100644 index 000000000000..f223a1f6bde5 --- /dev/null +++ b/android/docs/diagrams/nav_graph.svg @@ -0,0 +1,216 @@ + + + + + + + + + +splash + +Splash + + + +login + +Login + + + +splash->login + + + + + +connect + +Connect + + + +splash->connect + + + + + +revoked + +Revoked + + + +splash->revoked + + + + + +privacy_policy + +Privacy policy + + + +splash->privacy_policy + + + + + +login->connect + + + + + +too_many_devices + +Too many devices + + + +login->too_many_devices + + + + + +welcome + +Welcome + + + +login->welcome + + + + + +settings + +Settings + + + +login->settings + + + + + +connect->revoked + + + + + +account + +Account + + + +connect->account + + + + + +connect->settings + + + + + +switch_location + +Switch location + + + +connect->switch_location + + + + + +too_many_devices->login + + + + + +welcome->connect + + + + + +revoked->login + + + + + +privacy_policy->login + + + + + +report_problem + +Report problem + + + +settings->report_problem + + + + + +split_tunneling + +Split tunneling + + + +settings->split_tunneling + + + + + +vpn_settings + +VPN settings + + + +settings->vpn_settings + + + + + +view_logs + +View logs + + + +report_problem->view_logs + + + + + diff --git a/android/docs/diagrams/update_graph.sh b/android/docs/diagrams/update_graph.sh new file mode 100755 index 000000000000..8b42e5a1ddb4 --- /dev/null +++ b/android/docs/diagrams/update_graph.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +dot $SCRIPT_DIR/nav_graph.dot -Tsvg -o $SCRIPT_DIR/nav_graph.svg diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 074e8a71a28e..7408b6545cdb 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -1023,6 +1023,14 @@ + + + + + + + + @@ -1031,6 +1039,14 @@ + + + + + + + + @@ -1044,6 +1060,14 @@ + + + + + + + + @@ -1070,6 +1094,14 @@ + + + + + + + + @@ -1083,6 +1115,14 @@ + + + + + + + + @@ -1099,6 +1139,14 @@ + + + + + + + + @@ -1130,6 +1178,14 @@ + + + + + + + + @@ -1143,6 +1199,14 @@ + + + + + + + + @@ -1174,14 +1238,27 @@ - - - + + + + + + + + + + + + + + + + @@ -1190,6 +1267,14 @@ + + + + + + + + @@ -1216,6 +1301,14 @@ + + + + + + + + @@ -1226,6 +1319,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2072,6 +2205,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2552,6 +2711,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/SdkUtils.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/SdkUtils.kt index 37447483c2e1..eeaa395b6928 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/SdkUtils.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/SdkUtils.kt @@ -2,16 +2,21 @@ package net.mullvad.mullvadvpn.lib.common.util import android.Manifest import android.app.PendingIntent +import android.app.PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import android.service.quicksettings.Tile -import android.widget.Toast object SdkUtils { + // TODO Rework how pending intents work fun getSupportedPendingIntentFlags(): Int { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_MUTABLE or + FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE } else { PendingIntent.FLAG_UPDATE_CURRENT @@ -36,10 +41,4 @@ object SdkUtils { } else { @Suppress("DEPRECATION") getInstalledPackages(flags) } - - fun showCopyToastIfNeeded(context: Context, message: String) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } } diff --git a/android/lib/resource/src/main/res/anim/do_nothing.xml b/android/lib/resource/src/main/res/anim/do_nothing.xml deleted file mode 100644 index 8cb6866d6db7..000000000000 --- a/android/lib/resource/src/main/res/anim/do_nothing.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_enter_from_bottom.xml b/android/lib/resource/src/main/res/anim/fragment_enter_from_bottom.xml deleted file mode 100644 index 337392e881b4..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_enter_from_bottom.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_enter_from_right.xml b/android/lib/resource/src/main/res/anim/fragment_enter_from_right.xml deleted file mode 100644 index 5ba3b5c3f8f6..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_enter_from_right.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_exit_to_bottom.xml b/android/lib/resource/src/main/res/anim/fragment_exit_to_bottom.xml deleted file mode 100644 index dc1261114ae4..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_exit_to_bottom.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_exit_to_left.xml b/android/lib/resource/src/main/res/anim/fragment_exit_to_left.xml deleted file mode 100644 index 9ffa2c987758..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_exit_to_left.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_exit_to_right.xml b/android/lib/resource/src/main/res/anim/fragment_exit_to_right.xml deleted file mode 100644 index d79420098292..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_exit_to_right.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_half_enter_from_left.xml b/android/lib/resource/src/main/res/anim/fragment_half_enter_from_left.xml deleted file mode 100644 index 67e7b7364e37..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_half_enter_from_left.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/anim/fragment_half_exit_to_left.xml b/android/lib/resource/src/main/res/anim/fragment_half_exit_to_left.xml deleted file mode 100644 index bfac81df2ecf..000000000000 --- a/android/lib/resource/src/main/res/anim/fragment_half_exit_to_left.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/android/lib/resource/src/main/res/values/styles.xml b/android/lib/resource/src/main/res/values/styles.xml index 69ee118cac15..bd1355dbf2b8 100644 --- a/android/lib/resource/src/main/res/values/styles.xml +++ b/android/lib/resource/src/main/res/values/styles.xml @@ -1,10 +1,10 @@ diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt index eacd2d4bc983..9729d3eb93f5 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt @@ -1,6 +1,8 @@ package net.mullvad.mullvadvpn.service import android.app.Service +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED +import android.os.Build import kotlin.properties.Delegates.observable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -90,10 +92,18 @@ class ForegroundNotificationManager( } fun showOnForeground() { - service.startForeground( - TunnelStateNotification.NOTIFICATION_ID, - tunnelStateNotification.build() - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.startForeground( + TunnelStateNotification.NOTIFICATION_ID, + tunnelStateNotification.build(), + FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED + ) + } else { + service.startForeground( + TunnelStateNotification.NOTIFICATION_ID, + tunnelStateNotification.build() + ) + } onForeground = true } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt index c7b8992292f2..024522e94a64 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt @@ -69,7 +69,7 @@ class CaptureScreenshotOnFailedTestRule(private val testTag: String) : TestWatch if (uri != null) { contentResolver.openOutputStream(uri).use { try { - this.compress(Bitmap.CompressFormat.JPEG, 50, it) + this.compress(Bitmap.CompressFormat.JPEG, 50, it!!) } catch (e: IOException) { Log.e(testTag, "Unable to store screenshot: ${e.message}") }