diff options
| author | David Göransson <david.goransson@mullvad.net> | 2024-05-29 17:18:29 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2024-05-29 17:18:29 +0200 |
| commit | ad90145a5d86d8c1e6e70f2238f11edf5e50f8d8 (patch) | |
| tree | 9d085bc81caed9409e3a4360490c06c2da4fbba8 /android/app/src | |
| parent | 8e14a8d4287af66a57a98db79d3ac320c2dad4a1 (diff) | |
| parent | 767b97eda756f4ec4e67fb5fa2ae664277291e8f (diff) | |
| download | mullvadvpn-ad90145a5d86d8c1e6e70f2238f11edf5e50f8d8.tar.xz mullvadvpn-ad90145a5d86d8c1e6e70f2238f11edf5e50f8d8.zip | |
Merge branch 'android-grpc'
Diffstat (limited to 'android/app/src')
219 files changed, 4282 insertions, 5938 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt index 2adfa22220..0218e06afd 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt @@ -1,61 +1,108 @@ package net.mullvad.mullvadvpn.compose.data -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.model.PortRange -import net.mullvad.mullvadvpn.model.RelayEndpointData -import net.mullvad.mullvadvpn.model.RelayList -import net.mullvad.mullvadvpn.model.RelayListCity -import net.mullvad.mullvadvpn.model.RelayListCountry -import net.mullvad.mullvadvpn.model.WireguardEndpointData -import net.mullvad.mullvadvpn.model.WireguardRelayEndpointData -import net.mullvad.mullvadvpn.relaylist.RelayItem -import net.mullvad.mullvadvpn.relaylist.toRelayCountries +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.PortRange +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.ProviderId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayList +import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData private val DUMMY_RELAY_1 = - net.mullvad.mullvadvpn.model.Relay( - hostname = "Relay host 1", + RelayItem.Location.Relay( + id = + GeoLocationId.Hostname( + city = GeoLocationId.City(GeoLocationId.Country("RCo1"), "Relay City 1"), + "Relay host 1" + ), active = true, - endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData), - owned = true, - provider = "PROVIDER" + provider = + Provider( + providerId = ProviderId("PROVIDER RENTED"), + ownership = Ownership.Rented, + ) ) private val DUMMY_RELAY_2 = - net.mullvad.mullvadvpn.model.Relay( - hostname = "Relay host 2", + RelayItem.Location.Relay( + id = + GeoLocationId.Hostname( + city = GeoLocationId.City(GeoLocationId.Country("RCo2"), "Relay City 2"), + "Relay host 2" + ), active = true, - endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData), - owned = true, - provider = "PROVIDER" + provider = + Provider(providerId = ProviderId("PROVIDER OWNED"), ownership = Ownership.MullvadOwned) + ) +private val DUMMY_RELAY_CITY_1 = + RelayItem.Location.City( + name = "Relay City 1", + id = GeoLocationId.City(countryCode = GeoLocationId.Country("RCo1"), cityCode = "RCi1"), + relays = listOf(DUMMY_RELAY_1), + expanded = false + ) +private val DUMMY_RELAY_CITY_2 = + RelayItem.Location.City( + name = "Relay City 2", + id = GeoLocationId.City(countryCode = GeoLocationId.Country("RCo2"), cityCode = "RCi2"), + relays = listOf(DUMMY_RELAY_2), + expanded = false ) -private val DUMMY_RELAY_CITY_1 = RelayListCity("Relay City 1", "RCi1", arrayListOf(DUMMY_RELAY_1)) -private val DUMMY_RELAY_CITY_2 = RelayListCity("Relay City 2", "RCi2", arrayListOf(DUMMY_RELAY_2)) private val DUMMY_RELAY_COUNTRY_1 = - RelayListCountry("Relay Country 1", "RCo1", arrayListOf(DUMMY_RELAY_CITY_1)) + RelayItem.Location.Country( + name = "Relay Country 1", + id = GeoLocationId.Country("RCo1"), + expanded = false, + cities = listOf(DUMMY_RELAY_CITY_1) + ) private val DUMMY_RELAY_COUNTRY_2 = - RelayListCountry("Relay Country 2", "RCo2", arrayListOf(DUMMY_RELAY_CITY_2)) + RelayItem.Location.Country( + name = "Relay Country 2", + id = GeoLocationId.Country("RCo2"), + expanded = false, + cities = listOf(DUMMY_RELAY_CITY_2) + ) private val DUMMY_WIREGUARD_PORT_RANGES = ArrayList<PortRange>() private val DUMMY_WIREGUARD_ENDPOINT_DATA = WireguardEndpointData(DUMMY_WIREGUARD_PORT_RANGES) -val DUMMY_RELAY_COUNTRIES = +val DUMMY_RELAY_COUNTRIES = listOf(DUMMY_RELAY_COUNTRY_1, DUMMY_RELAY_COUNTRY_2) + +val DUMMY_RELAY_LIST = RelayList( - arrayListOf(DUMMY_RELAY_COUNTRY_1, DUMMY_RELAY_COUNTRY_2), - DUMMY_WIREGUARD_ENDPOINT_DATA, - ) - .toRelayCountries() + DUMMY_RELAY_COUNTRIES, + DUMMY_WIREGUARD_ENDPOINT_DATA, + ) -val DUMMY_CUSTOM_LISTS = +val DUMMY_RELAY_ITEM_CUSTOM_LISTS = listOf( RelayItem.CustomList( - CustomListName.fromString("First list"), - false, - "1", + customListName = CustomListName.fromString("First list"), + expanded = false, + id = CustomListId("1"), locations = DUMMY_RELAY_COUNTRIES ), RelayItem.CustomList( - CustomListName.fromString("Empty list"), + customListName = CustomListName.fromString("Empty list"), expanded = false, - "2", + id = CustomListId("2"), + locations = emptyList() + ) + ) + +val DUMMY_CUSTOM_LISTS = + listOf( + CustomList( + name = CustomListName.fromString("First list"), + id = CustomListId("1"), + locations = DUMMY_RELAY_COUNTRIES.map { it.id } + ), + CustomList( + name = CustomListName.fromString("Empty list"), + id = CustomListId("2"), locations = emptyList() ) ) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialogTest.kt index baeb5902d7..915db82438 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialogTest.kt @@ -12,7 +12,9 @@ import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState import net.mullvad.mullvadvpn.compose.test.CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG -import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.lib.model.CustomListAlreadyExists +import net.mullvad.mullvadvpn.lib.model.UnknownCustomListError +import net.mullvad.mullvadvpn.usecase.customlists.CreateWithLocationsError import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -44,7 +46,10 @@ class CreateCustomListDialogTest { fun givenCustomListExistsShouldShowCustomListExitsErrorText() = composeExtension.use { // Arrange - val state = CreateCustomListUiState(error = CustomListsError.CustomListExists) + val state = + CreateCustomListUiState( + error = CreateWithLocationsError.Create(CustomListAlreadyExists) + ) setContentWithTheme { CreateCustomListDialog(state = state) } // Assert @@ -56,7 +61,10 @@ class CreateCustomListDialogTest { fun givenOtherCustomListErrorShouldShowAnErrorOccurredErrorText() = composeExtension.use { // Arrange - val state = CreateCustomListUiState(error = CustomListsError.OtherError) + val state = + CreateCustomListUiState( + error = CreateWithLocationsError.Create(UnknownCustomListError(Throwable())) + ) setContentWithTheme { CreateCustomListDialog(state = state) } // Assert diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt index f9c7ec2143..bcb3908fae 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CustomPortDialogTest.kt @@ -9,7 +9,8 @@ import io.mockk.MockKAnnotations import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG -import net.mullvad.mullvadvpn.model.PortRange +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange import net.mullvad.mullvadvpn.onNodeWithTagAndText import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -29,9 +30,9 @@ class CustomPortDialogTest { @SuppressLint("ComposableNaming") @Composable private fun testWireguardCustomPortDialog( - initialPort: Int? = null, + initialPort: Port? = null, allowedPortRanges: List<PortRange> = emptyList(), - onSave: (Int?) -> Unit = { _ -> }, + onSave: (Port?) -> Unit = { _ -> }, onDismiss: () -> Unit = {}, ) { @@ -47,21 +48,20 @@ class CustomPortDialogTest { fun testShowWireguardCustomPortDialogInvalidInt() = composeExtension.use { // Input a number to make sure that a too long number does not show and it does not - // crash - // the app + // crash the app // Arrange setContentWithTheme { testWireguardCustomPortDialog() } // Act - onNodeWithTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).performTextInput(invalidCustomPort) + onNodeWithTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).performTextInput(INVALID_CUSTOM_PORT) // Assert - onNodeWithTagAndText(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG, invalidCustomPort) + onNodeWithTagAndText(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG, INVALID_CUSTOM_PORT) .assertDoesNotExist() } companion object { - const val invalidCustomPort = "21474836471" + const val INVALID_CUSTOM_PORT = "21474836471" } } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialogTest.kt index e79c5a2fe7..ee347c246a 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialogTest.kt @@ -8,6 +8,8 @@ import io.mockk.mockk import io.mockk.verify import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.DeleteCustomListUiState +import net.mullvad.mullvadvpn.lib.model.CustomListName import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -27,8 +29,13 @@ class DeleteCustomListConfirmationDialogTest { fun givenNameShouldShowDeleteNameTitle() = composeExtension.use { // Arrange - val name = "List should be deleted" - setContentWithTheme { DeleteCustomListConfirmationDialog(name = name) } + val name = CustomListName.fromString("List should be deleted") + setContentWithTheme { + DeleteCustomListConfirmationDialog( + name = name, + state = DeleteCustomListUiState(null) + ) + } // Assert onNodeWithText(DELETE_TITLE.format(name)).assertExists() @@ -38,10 +45,14 @@ class DeleteCustomListConfirmationDialogTest { fun whenDeleteIsClickedShouldCallOnDelete() = composeExtension.use { // Arrange - val name = "List should be deleted" + val name = CustomListName.fromString("List should be deleted") val mockedOnDelete: () -> Unit = mockk(relaxed = true) setContentWithTheme { - DeleteCustomListConfirmationDialog(name = name, onDelete = mockedOnDelete) + DeleteCustomListConfirmationDialog( + name = name, + state = DeleteCustomListUiState(null), + onDelete = mockedOnDelete + ) } // Act @@ -55,10 +66,14 @@ class DeleteCustomListConfirmationDialogTest { fun whenCancelIsClickedShouldCallOnBack() = composeExtension.use { // Arrange - val name = "List should be deleted" + val name = CustomListName.fromString("List should be deleted") val mockedOnBack: () -> Unit = mockk(relaxed = true) setContentWithTheme { - DeleteCustomListConfirmationDialog(name = name, onBack = mockedOnBack) + DeleteCustomListConfirmationDialog( + name = name, + state = DeleteCustomListUiState(null), + onBack = mockedOnBack + ) } // Act diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialogTest.kt index cbd6ae09d7..3128bbc508 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialogTest.kt @@ -10,9 +10,11 @@ import io.mockk.mockk import io.mockk.verify import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.UpdateCustomListUiState +import net.mullvad.mullvadvpn.compose.state.EditCustomListNameUiState import net.mullvad.mullvadvpn.compose.test.EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG -import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.lib.model.NameAlreadyExists +import net.mullvad.mullvadvpn.lib.model.UnknownCustomListError +import net.mullvad.mullvadvpn.usecase.customlists.RenameError import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -32,7 +34,7 @@ class EditCustomListNameDialogTest { fun givenNoErrorShouldShowNoErrorMessage() = composeExtension.use { // Arrange - val state = UpdateCustomListUiState(error = null) + val state = EditCustomListNameUiState(error = null) setContentWithTheme { EditCustomListNameDialog(state = state) } // Assert @@ -44,7 +46,7 @@ class EditCustomListNameDialogTest { fun givenCustomListExistsShouldShowCustomListExitsErrorText() = composeExtension.use { // Arrange - val state = UpdateCustomListUiState(error = CustomListsError.CustomListExists) + val state = EditCustomListNameUiState(error = RenameError(NameAlreadyExists("name"))) setContentWithTheme { EditCustomListNameDialog(state = state) } // Assert @@ -56,7 +58,10 @@ class EditCustomListNameDialogTest { fun givenOtherCustomListErrorShouldShowAnErrorOccurredErrorText() = composeExtension.use { // Arrange - val state = UpdateCustomListUiState(error = CustomListsError.OtherError) + val state = + EditCustomListNameUiState( + error = RenameError(UnknownCustomListError(RuntimeException(""))) + ) setContentWithTheme { EditCustomListNameDialog(state = state) } // Assert @@ -69,7 +74,7 @@ class EditCustomListNameDialogTest { composeExtension.use { // Arrange val mockedOnDismiss: () -> Unit = mockk(relaxed = true) - val state = UpdateCustomListUiState() + val state = EditCustomListNameUiState() setContentWithTheme { EditCustomListNameDialog(state = state, onDismiss = mockedOnDismiss) } @@ -86,7 +91,7 @@ class EditCustomListNameDialogTest { composeExtension.use { // Arrange val mockedUpdateName: (String) -> Unit = mockk(relaxed = true) - val state = UpdateCustomListUiState() + val state = EditCustomListNameUiState() setContentWithTheme { EditCustomListNameDialog(state = state, updateName = mockedUpdateName) } @@ -104,7 +109,7 @@ class EditCustomListNameDialogTest { // Arrange val mockedUpdateName: (String) -> Unit = mockk(relaxed = true) val inputText = "NEW NAME" - val state = UpdateCustomListUiState() + val state = EditCustomListNameUiState() setContentWithTheme { EditCustomListNameDialog(state = state, updateName = mockedUpdateName) } @@ -123,7 +128,7 @@ class EditCustomListNameDialogTest { // Arrange val mockedOnInputChanged: () -> Unit = mockk(relaxed = true) val inputText = "NEW NAME" - val state = UpdateCustomListUiState() + val state = EditCustomListNameUiState() setContentWithTheme { EditCustomListNameDialog(state = state, onInputChanged = mockedOnInputChanged) } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt index 0641998f9b..5fe812cd44 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialogTest.kt @@ -7,12 +7,12 @@ import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput import io.mockk.MockKAnnotations import io.mockk.mockk import io.mockk.verify import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.viewmodel.MtuDialogUiState import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -31,13 +31,17 @@ class MtuDialogTest { @SuppressLint("ComposableNaming") @Composable private fun testMtuDialog( - mtuInitial: Int? = null, - onSaveMtu: (Int) -> Unit = { _ -> }, + mtuInput: String = "", + isValidInput: Boolean = true, + showResetButton: Boolean = true, + onInputChanged: (String) -> Unit = { _ -> }, + onSaveMtu: (String) -> Unit = { _ -> }, onResetMtu: () -> Unit = {}, onDismiss: () -> Unit = {}, ) { MtuDialog( - mtuInitial = mtuInitial, + MtuDialogUiState(mtuInput, isValidInput, showResetButton), + onInputChanged = onInputChanged, onSaveMtu = onSaveMtu, onResetMtu = onResetMtu, onDismiss = onDismiss @@ -60,36 +64,19 @@ class MtuDialogTest { // Arrange setContentWithTheme { testMtuDialog( - mtuInitial = VALID_DUMMY_MTU_VALUE, + mtuInput = VALID_DUMMY_MTU_VALUE, ) } // Assert - onNodeWithText(VALID_DUMMY_MTU_VALUE.toString()).assertExists() - } - - @Test - fun testMtuDialogTextInput() = - composeExtension.use { - // Arrange - setContentWithTheme { - testMtuDialog( - null, - ) - } - - // Act - onNodeWithText(EMPTY_STRING).performTextInput(VALID_DUMMY_MTU_VALUE.toString()) - - // Assert - onNodeWithText(VALID_DUMMY_MTU_VALUE.toString()).assertExists() + onNodeWithText(VALID_DUMMY_MTU_VALUE).assertExists() } @Test fun testMtuDialogSubmitOfValidValue() = composeExtension.use { // Arrange - val mockedSubmitHandler: (Int) -> Unit = mockk(relaxed = true) + val mockedSubmitHandler: (String) -> Unit = mockk(relaxed = true) setContentWithTheme { testMtuDialog( VALID_DUMMY_MTU_VALUE, @@ -108,11 +95,7 @@ class MtuDialogTest { fun testMtuDialogSubmitButtonDisabledWhenInvalidInput() = composeExtension.use { // Arrange - setContentWithTheme { - testMtuDialog( - INVALID_DUMMY_MTU_VALUE, - ) - } + setContentWithTheme { testMtuDialog(INVALID_DUMMY_MTU_VALUE, false) } // Assert onNodeWithText("Submit").assertIsNotEnabled() @@ -125,7 +108,7 @@ class MtuDialogTest { val mockedClickHandler: () -> Unit = mockk(relaxed = true) setContentWithTheme { testMtuDialog( - mtuInitial = VALID_DUMMY_MTU_VALUE, + mtuInput = VALID_DUMMY_MTU_VALUE, onResetMtu = mockedClickHandler, ) } @@ -157,7 +140,7 @@ class MtuDialogTest { companion object { private const val EMPTY_STRING = "" - private const val VALID_DUMMY_MTU_VALUE = 1337 - private const val INVALID_DUMMY_MTU_VALUE = 1111 + private const val VALID_DUMMY_MTU_VALUE = "1337" + private const val INVALID_DUMMY_MTU_VALUE = "1111" } } 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 851866818b..98c87114fb 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 @@ -20,16 +20,15 @@ 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.test.TOP_BAR_ACCOUNT_BUTTON -import net.mullvad.mullvadvpn.model.GeoIpLocation -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.ErrorState +import net.mullvad.mullvadvpn.lib.model.ErrorStateCause +import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.TransportProtocol +import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint +import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.talpid.net.TransportProtocol -import net.mullvad.talpid.net.TunnelEndpoint -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.tunnel.ErrorState -import net.mullvad.talpid.tunnel.ErrorStateCause import org.joda.time.DateTime import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -78,9 +77,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connecting(null, null), - tunnelRealState = TunnelState.Connecting(null, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, @@ -112,10 +110,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = - TunnelState.Connecting(endpoint = mockTunnelEndpoint, null), - tunnelRealState = + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(endpoint = mockTunnelEndpoint, null), inAddress = null, outAddress = "", @@ -147,9 +143,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), - tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connected(mockTunnelEndpoint, null), inAddress = null, outAddress = "", showLocation = false, @@ -179,9 +174,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), - tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connected(mockTunnelEndpoint, null), inAddress = null, outAddress = "", showLocation = false, @@ -204,19 +198,14 @@ class ConnectScreenTest { fun testDisconnectingState() { composeExtension.use { // Arrange - val mockSelectedLocation: RelayItem = mockk(relaxed = true) val mockLocationName = "Home" - every { mockSelectedLocation.locationName } returns mockLocationName setContentWithTheme { ConnectScreen( state = ConnectUiState( location = null, - selectedRelayItem = mockSelectedLocation, - tunnelUiState = - TunnelState.Disconnecting(ActionAfterDisconnect.Nothing), - tunnelRealState = - TunnelState.Disconnecting(ActionAfterDisconnect.Nothing), + selectedRelayItemTitle = mockLocationName, + tunnelState = TunnelState.Disconnecting(ActionAfterDisconnect.Nothing), inAddress = null, outAddress = "", showLocation = true, @@ -239,17 +228,14 @@ class ConnectScreenTest { fun testDisconnectedState() { composeExtension.use { // Arrange - val mockSelectedLocation: RelayItem = mockk(relaxed = true) val mockLocationName = "Home" - every { mockSelectedLocation.locationName } returns mockLocationName setContentWithTheme { ConnectScreen( state = ConnectUiState( location = null, - selectedRelayItem = mockSelectedLocation, - tunnelUiState = TunnelState.Disconnected(), - tunnelRealState = TunnelState.Disconnected(), + selectedRelayItemTitle = mockLocationName, + tunnelState = TunnelState.Disconnected(), inAddress = null, outAddress = "", showLocation = true, @@ -272,20 +258,14 @@ class ConnectScreenTest { fun testErrorStateBlocked() { composeExtension.use { // Arrange - val mockSelectedLocation: RelayItem = mockk(relaxed = true) val mockLocationName = "Home" - every { mockSelectedLocation.locationName } returns mockLocationName setContentWithTheme { ConnectScreen( state = ConnectUiState( location = null, - selectedRelayItem = mockSelectedLocation, - tunnelUiState = - TunnelState.Error( - ErrorState(ErrorStateCause.StartTunnelError, true) - ), - tunnelRealState = + selectedRelayItemTitle = mockLocationName, + tunnelState = TunnelState.Error( ErrorState(ErrorStateCause.StartTunnelError, true) ), @@ -315,20 +295,14 @@ class ConnectScreenTest { fun testErrorStateNotBlocked() { composeExtension.use { // Arrange - val mockSelectedLocation: RelayItem = mockk(relaxed = true) val mockLocationName = "Home" - every { mockSelectedLocation.locationName } returns mockLocationName setContentWithTheme { ConnectScreen( state = ConnectUiState( location = null, - selectedRelayItem = mockSelectedLocation, - tunnelUiState = - TunnelState.Error( - ErrorState(ErrorStateCause.StartTunnelError, false) - ), - tunnelRealState = + selectedRelayItemTitle = mockLocationName, + tunnelState = TunnelState.Error( ErrorState(ErrorStateCause.StartTunnelError, false) ), @@ -364,10 +338,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = - TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect), - tunnelRealState = + selectedRelayItemTitle = null, + tunnelState = TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect), inAddress = null, outAddress = "", @@ -393,18 +365,14 @@ class ConnectScreenTest { fun testDisconnectingBlockState() { composeExtension.use { // Arrange - val mockSelectedLocation: RelayItem = mockk(relaxed = true) val mockLocationName = "Home" - every { mockSelectedLocation.locationName } returns mockLocationName setContentWithTheme { ConnectScreen( state = ConnectUiState( location = null, - selectedRelayItem = mockSelectedLocation, - tunnelUiState = TunnelState.Disconnecting(ActionAfterDisconnect.Block), - tunnelRealState = - TunnelState.Disconnecting(ActionAfterDisconnect.Block), + selectedRelayItemTitle = mockLocationName, + tunnelState = TunnelState.Disconnecting(ActionAfterDisconnect.Block), inAddress = null, outAddress = "", showLocation = true, @@ -428,18 +396,15 @@ class ConnectScreenTest { fun testClickSelectLocationButton() { composeExtension.use { // Arrange - val mockSelectedLocation: RelayItem = mockk(relaxed = true) val mockLocationName = "Home" - every { mockSelectedLocation.name } returns mockLocationName val mockedClickHandler: () -> Unit = mockk(relaxed = true) setContentWithTheme { ConnectScreen( state = ConnectUiState( location = null, - selectedRelayItem = mockSelectedLocation, - tunnelUiState = TunnelState.Disconnected(), - tunnelRealState = TunnelState.Disconnected(), + selectedRelayItemTitle = mockLocationName, + tunnelState = TunnelState.Disconnected(), inAddress = null, outAddress = "", showLocation = false, @@ -471,9 +436,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), - tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connected(mockTunnelEndpoint, null), inAddress = null, outAddress = "", showLocation = false, @@ -505,9 +469,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), - tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connected(mockTunnelEndpoint, null), inAddress = null, outAddress = "", showLocation = false, @@ -538,9 +501,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Disconnected(), - tunnelRealState = TunnelState.Disconnected(), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Disconnected(), inAddress = null, outAddress = "", showLocation = false, @@ -571,9 +533,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connecting(null, null), - tunnelRealState = TunnelState.Connecting(null, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, @@ -612,9 +573,8 @@ class ConnectScreenTest { state = ConnectUiState( location = mockLocation, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connected(mockTunnelEndpoint, null), - tunnelRealState = TunnelState.Connected(mockTunnelEndpoint, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connected(mockTunnelEndpoint, null), inAddress = mockInAddress, outAddress = mockOutAddress, showLocation = false, @@ -644,18 +604,16 @@ class ConnectScreenTest { val versionInfo = VersionInfo( currentVersion = "1.0", - upgradeVersion = "1.1", - isOutdated = true, - isSupported = true + isSupported = true, + suggestedUpgradeVersion = "1.1" ) setContentWithTheme { ConnectScreen( state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connecting(null, null), - tunnelRealState = TunnelState.Connecting(null, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, @@ -680,18 +638,16 @@ class ConnectScreenTest { val versionInfo = VersionInfo( currentVersion = "1.0", - upgradeVersion = "1.1", - isOutdated = true, - isSupported = false + isSupported = false, + suggestedUpgradeVersion = "1.1" ) setContentWithTheme { ConnectScreen( state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connecting(null, null), - tunnelRealState = TunnelState.Connecting(null, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, @@ -722,9 +678,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connecting(null, null), - tunnelRealState = TunnelState.Connecting(null, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, @@ -749,10 +704,9 @@ class ConnectScreenTest { val mockedClickHandler: () -> Unit = mockk(relaxed = true) val versionInfo = VersionInfo( - currentVersion = "1.0", - upgradeVersion = "1.1", - isOutdated = true, - isSupported = false + isSupported = false, + currentVersion = "", + suggestedUpgradeVersion = "1.1" ) setContentWithTheme { ConnectScreen( @@ -760,9 +714,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connecting(null, null), - tunnelRealState = TunnelState.Connecting(null, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, @@ -794,9 +747,8 @@ class ConnectScreenTest { state = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Connecting(null, null), - tunnelRealState = TunnelState.Connecting(null, null), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Connecting(null, null), inAddress = null, outAddress = "", showLocation = false, diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt index 5951550550..4f4db0a529 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt @@ -14,7 +14,7 @@ import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.SAVE_BUTTON_TEST_TAG -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItem import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreenTest.kt index da9ed60997..bdcb796997 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreenTest.kt @@ -14,7 +14,7 @@ import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.CustomListsUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.NEW_LIST_BUTTON_TEST_TAG -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.CustomList import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -56,8 +56,8 @@ class CustomListsScreenTest { } // Assert - onNodeWithText(customLists[0].name).assertExists() - onNodeWithText(customLists[1].name).assertExists() + onNodeWithText(customLists[0].name.value).assertExists() + onNodeWithText(customLists[1].name.value).assertExists() } @Test @@ -87,7 +87,7 @@ class CustomListsScreenTest { // Arrange val customLists = DUMMY_CUSTOM_LISTS val clickedList = DUMMY_CUSTOM_LISTS[0] - val mockedOpenCustomList: (RelayItem.CustomList) -> Unit = mockk(relaxed = true) + val mockedOpenCustomList: (CustomList) -> Unit = mockk(relaxed = true) setContentWithTheme { CustomListsScreen( state = CustomListsUiState.Content(customLists = customLists), @@ -97,7 +97,7 @@ class CustomListsScreenTest { } // Act - onNodeWithText(clickedList.name).performClick() + onNodeWithText(clickedList.name.value).performClick() // Assert verify { mockedOpenCustomList(clickedList) } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreenTest.kt index f44441b536..5e57309777 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreenTest.kt @@ -14,6 +14,8 @@ import net.mullvad.mullvadvpn.compose.state.EditCustomListState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.DELETE_DROPDOWN_MENU_ITEM_TEST_TAG import net.mullvad.mullvadvpn.compose.test.TOP_BAR_DROPDOWN_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -64,7 +66,7 @@ class EditCustomListScreenTest { } // Assert - onNodeWithText(customList.name) + onNodeWithText(customList.name.value) } @Test @@ -91,7 +93,7 @@ class EditCustomListScreenTest { fun whenClickingOnDeleteDropdownShouldCallOnDeleteList() = composeExtension.use { // Arrange - val mockedOnDelete: (String) -> Unit = mockk(relaxed = true) + val mockedOnDelete: (CustomListName) -> Unit = mockk(relaxed = true) val customList = DUMMY_CUSTOM_LISTS[0] setContentWithTheme { EditCustomListScreen( @@ -117,7 +119,7 @@ class EditCustomListScreenTest { fun whenClickingOnNameCellShouldCallOnNameClicked() = composeExtension.use { // Arrange - val mockedOnNameClicked: (String, String) -> Unit = mockk(relaxed = true) + val mockedOnNameClicked: (CustomListId, CustomListName) -> Unit = mockk(relaxed = true) val customList = DUMMY_CUSTOM_LISTS[0] setContentWithTheme { EditCustomListScreen( @@ -132,7 +134,7 @@ class EditCustomListScreenTest { } // Act - onNodeWithText(customList.name).performClick() + onNodeWithText(customList.name.value).performClick() // Assert verify { mockedOnNameClicked(customList.id, customList.name) } @@ -142,7 +144,7 @@ class EditCustomListScreenTest { fun whenClickingOnLocationCellShouldCallOnLocationsClicked() = composeExtension.use { // Arrange - val mockedOnLocationsClicked: (String) -> Unit = mockk(relaxed = true) + val mockedOnLocationsClicked: (CustomListId) -> Unit = mockk(relaxed = true) val customList = DUMMY_CUSTOM_LISTS[0] setContentWithTheme { EditCustomListScreen( diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt index c57c5c3f62..b3cfd7972f 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreenTest.kt @@ -9,8 +9,9 @@ import io.mockk.verify import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.RelayFilterState -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.relaylist.Provider +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.ProviderId import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -124,7 +125,8 @@ class FilterScreenTest { RelayFilterState( allProviders = listOf(), selectedOwnership = null, - selectedProviders = listOf(Provider("31173", true)) + selectedProviders = + listOf(Provider(ProviderId("31173"), Ownership.MullvadOwned)) ), onSelectedProvider = { _, _ -> }, onApplyClick = mockClickListener @@ -135,47 +137,46 @@ class FilterScreenTest { } companion object { - private val DUMMY_RELAY_ALL_PROVIDERS = listOf( - Provider("31173", true), - Provider("100TB", false), - Provider("Blix", true), - Provider("Creanova", true), - Provider("DataPacket", false), - Provider("HostRoyale", false), - Provider("hostuniversal", false), - Provider("iRegister", false), - Provider("M247", false), - Provider("Makonix", false), - Provider("PrivateLayer", false), - Provider("ptisp", false), - Provider("Qnax", false), - Provider("Quadranet", false), - Provider("techfutures", false), - Provider("Tzulo", false), - Provider("xtom", false) + Provider(ProviderId("31173"), Ownership.MullvadOwned), + Provider(ProviderId("100TB"), Ownership.Rented), + Provider(ProviderId("Blix"), Ownership.MullvadOwned), + Provider(ProviderId("Creanova"), Ownership.MullvadOwned), + Provider(ProviderId("DataPacket"), Ownership.Rented), + Provider(ProviderId("HostRoyale"), Ownership.Rented), + Provider(ProviderId("hostuniversal"), Ownership.Rented), + Provider(ProviderId("iRegister"), Ownership.Rented), + Provider(ProviderId("M247"), Ownership.Rented), + Provider(ProviderId("Makonix"), Ownership.Rented), + Provider(ProviderId("PrivateLayer"), Ownership.Rented), + Provider(ProviderId("ptisp"), Ownership.Rented), + Provider(ProviderId("Qnax"), Ownership.Rented), + Provider(ProviderId("Quadranet"), Ownership.Rented), + Provider(ProviderId("techfutures"), Ownership.Rented), + Provider(ProviderId("Tzulo"), Ownership.Rented), + Provider(ProviderId("xtom"), Ownership.Rented) ) private val DUMMY_SELECTED_PROVIDERS = listOf( - Provider("31173", true), - Provider("100TB", false), - Provider("Blix", true), - Provider("Creanova", true), - Provider("DataPacket", false), - Provider("HostRoyale", false), - Provider("hostuniversal", false), - Provider("iRegister", false), - Provider("M247", false), - Provider("Makonix", false), - Provider("PrivateLayer", false), - Provider("ptisp", false), - Provider("Qnax", false), - Provider("Quadranet", false), - Provider("techfutures", false), - Provider("Tzulo", false), - Provider("xtom", false) + Provider(ProviderId("31173"), Ownership.MullvadOwned), + Provider(ProviderId("100TB"), Ownership.Rented), + Provider(ProviderId("Blix"), Ownership.MullvadOwned), + Provider(ProviderId("Creanova"), Ownership.MullvadOwned), + Provider(ProviderId("DataPacket"), Ownership.Rented), + Provider(ProviderId("HostRoyale"), Ownership.Rented), + Provider(ProviderId("hostuniversal"), Ownership.Rented), + Provider(ProviderId("iRegister"), Ownership.Rented), + Provider(ProviderId("M247"), Ownership.Rented), + Provider(ProviderId("Makonix"), Ownership.Rented), + Provider(ProviderId("PrivateLayer"), Ownership.Rented), + Provider(ProviderId("ptisp"), Ownership.Rented), + Provider(ProviderId("Qnax"), Ownership.Rented), + Provider(ProviderId("Quadranet"), Ownership.Rented), + Provider(ProviderId("techfutures"), Ownership.Rented), + Provider(ProviderId("Tzulo"), Ownership.Rented), + Provider(ProviderId("xtom"), Ownership.Rented) ) } } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt index f54944356f..7dc378261d 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt @@ -13,11 +13,11 @@ import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.test.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice -import net.mullvad.mullvadvpn.model.TunnelState import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogTest.kt index b3ac57d95c..d8159dafd0 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogTest.kt @@ -14,6 +14,7 @@ import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.VoucherDialogState import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState import net.mullvad.mullvadvpn.compose.test.VOUCHER_INPUT_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.RedeemVoucherError import net.mullvad.mullvadvpn.util.VoucherRegexHelper import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -133,7 +134,8 @@ class RedeemVoucherDialogTest { RedeemVoucherDialog( state = VoucherDialogUiState( - voucherState = VoucherDialogState.Error(ERROR_MESSAGE) + voucherState = + VoucherDialogState.Error(RedeemVoucherError.InvalidVoucher) ), onVoucherInputChange = {}, onRedeem = {}, @@ -142,13 +144,13 @@ class RedeemVoucherDialogTest { } // Assert - onNodeWithText(ERROR_MESSAGE).assertExists() + onNodeWithText(VOUCHER_CODE_INVALID_ERROR_MESSAGE).assertExists() } companion object { private const val CANCEL_BUTTON_TEXT = "Cancel" private const val GOT_IT_BUTTON_TEXT = "Got it!" private const val DUMMY_VOUCHER = "DUMMY____VOUCHER" - private const val ERROR_MESSAGE = "error_message" + private const val VOUCHER_CODE_INVALID_ERROR_MESSAGE = "Voucher code is invalid." } } 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 28651c3852..4fcee479d6 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 @@ -9,16 +9,16 @@ import io.mockk.MockKAnnotations import io.mockk.mockk import io.mockk.verify import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension -import net.mullvad.mullvadvpn.compose.data.DUMMY_CUSTOM_LISTS import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES +import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_ITEM_CUSTOM_LISTS import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.performLongClick -import net.mullvad.mullvadvpn.relaylist.RelayItem import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -96,7 +96,7 @@ class SelectLocationScreenTest { customLists = emptyList(), filteredCustomLists = emptyList(), countries = updatedDummyList, - selectedItem = updatedDummyList[0].cities[0].relays[0], + selectedItem = updatedDummyList[0].cities[0].relays[0].id, selectedOwnership = null, selectedProvidersCount = 0, searchTerm = "" @@ -202,7 +202,7 @@ class SelectLocationScreenTest { SelectLocationScreen( state = SelectLocationUiState.Content( - customLists = DUMMY_CUSTOM_LISTS, + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, filteredCustomLists = emptyList(), countries = emptyList(), selectedItem = null, @@ -222,14 +222,14 @@ class SelectLocationScreenTest { fun whenCustomListIsClickedShouldCallOnSelectRelay() = composeExtension.use { // Arrange - val customList = DUMMY_CUSTOM_LISTS[0] + val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0] val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) setContentWithTheme { SelectLocationScreen( state = SelectLocationUiState.Content( - customLists = DUMMY_CUSTOM_LISTS, - filteredCustomLists = DUMMY_CUSTOM_LISTS, + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + filteredCustomLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, countries = emptyList(), selectedItem = null, selectedOwnership = null, @@ -251,14 +251,14 @@ class SelectLocationScreenTest { fun whenCustomListIsLongClickedShouldShowBottomSheet() = composeExtension.use { // Arrange - val customList = DUMMY_CUSTOM_LISTS[0] + val customList = DUMMY_RELAY_ITEM_CUSTOM_LISTS[0] val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true) setContentWithTheme { SelectLocationScreen( state = SelectLocationUiState.Content( - customLists = DUMMY_CUSTOM_LISTS, - filteredCustomLists = DUMMY_CUSTOM_LISTS, + customLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, + filteredCustomLists = DUMMY_RELAY_ITEM_CUSTOM_LISTS, countries = emptyList(), selectedItem = null, selectedOwnership = null, 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 471e39c38f..ca7a01a0a9 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 @@ -20,10 +20,11 @@ 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.model.Constraint -import net.mullvad.mullvadvpn.model.Port -import net.mullvad.mullvadvpn.model.PortRange -import net.mullvad.mullvadvpn.model.QuantumResistantState +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange +import net.mullvad.mullvadvpn.lib.model.QuantumResistantState import net.mullvad.mullvadvpn.onNodeWithTagAndText import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem import org.junit.jupiter.api.BeforeEach @@ -49,7 +50,7 @@ class VpnSettingsScreenTest { ) } - apply { onNodeWithText("Auto-connect").assertExists() } + onNodeWithText("Auto-connect").assertExists() onNodeWithTag(LAZY_LIST_TEST_TAG) .performScrollToNode(hasTestTag(LAZY_LIST_LAST_ITEM_TEST_TAG)) @@ -67,7 +68,10 @@ class VpnSettingsScreenTest { // Arrange setContentWithTheme { VpnSettingsScreen( - state = VpnSettingsUiState.createDefault(mtu = VALID_DUMMY_MTU_VALUE), + state = + VpnSettingsUiState.createDefault( + mtu = Mtu.fromString(VALID_DUMMY_MTU_VALUE).getOrNull()!! + ), ) } @@ -360,7 +364,7 @@ class VpnSettingsScreenTest { fun testMtuClick() = composeExtension.use { // Arrange - val mockedClickHandler: (Int?) -> Unit = mockk(relaxed = true) + val mockedClickHandler: (Mtu?) -> Unit = mockk(relaxed = true) setContentWithTheme { VpnSettingsScreen( state = VpnSettingsUiState.createDefault(), 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 d8711b4b61..d60a7b100b 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 @@ -13,6 +13,7 @@ import net.mullvad.mullvadvpn.compose.setContentWithTheme import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.compose.test.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.AccountToken import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus import net.mullvad.mullvadvpn.lib.payment.model.ProductId @@ -82,7 +83,7 @@ class WelcomeScreenTest { fun testShowAccountNumber() = composeExtension.use { // Arrange - val rawAccountNumber = "1111222233334444" + val rawAccountNumber = AccountToken("1111222233334444") val expectedAccountNumber = "1111 2222 3333 4444" setContentWithTheme { WelcomeScreen( diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0337d1200d..f6f60cdd1f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,14 +19,14 @@ android:required="false" /> <uses-feature android:glEsVersion="0x00020000" android:required="false" /> - <application android:label="@string/app_name" + <application android:name=".MullvadApplication" + android:allowBackup="false" + android:banner="@drawable/banner" + android:extractNativeLibs="true" android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher" android:theme="@style/AppTheme" - android:extractNativeLibs="true" - android:allowBackup="false" - android:banner="@drawable/banner" - android:name=".MullvadApplication" tools:ignore="GoogleAppIndexingWarning"> <!-- MainActivity @@ -37,9 +37,9 @@ since after that it has been patched on a OS level. --> <activity android:name="net.mullvad.mullvadvpn.ui.MainActivity" + android:configChanges="orientation|screenSize|screenLayout" android:exported="true" android:launchMode="singleInstance" - android:configChanges="orientation|screenSize|screenLayout" android:screenOrientation="fullUser" android:windowSoftInputMode="adjustResize" tools:ignore="DiscouragedApi"> @@ -51,6 +51,9 @@ <intent-filter> <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" /> </intent-filter> + <intent-filter> + <action android:name="net.mullvad.mullvadvpn.request_vpn_permission" /> + </intent-filter> </activity> <!-- MullvadVpnService @@ -64,10 +67,9 @@ --> <service android:name="net.mullvad.mullvadvpn.service.MullvadVpnService" android:exported="true" + android:foregroundServiceType="systemExempted" android:permission="android.permission.BIND_VPN_SERVICE" - android:process=":mullvadvpn_daemon" android:stopWithTask="false" - android:foregroundServiceType="systemExempted" tools:ignore="ForegroundServicePermission"> <intent-filter> <action android:name="android.net.VpnService" /> @@ -89,10 +91,9 @@ --> <service android:name="net.mullvad.mullvadvpn.tile.MullvadTileService" android:exported="true" - android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" - android:label="@string/toggle_vpn" android:icon="@drawable/small_logo_black" - android:process=":mullvadvpn_tile"> + android:label="@string/toggle_vpn" + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> <intent-filter> <action android:name="android.service.quicksettings.action.QS_TILE" /> </intent-filter> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt index 4b34886c34..04ccb1cddc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/MullvadApplication.kt @@ -1,7 +1,9 @@ package net.mullvad.mullvadvpn import android.app.Application +import net.mullvad.mullvadvpn.di.appModule import org.koin.android.ext.koin.androidContext +import org.koin.core.context.loadKoinModules import org.koin.core.context.startKoin /** @@ -13,5 +15,6 @@ class MullvadApplication : Application() { super.onCreate() // Used to create/start separate DI graphs for each process. Avoid non-common classes etc. startKoin { androidContext(this@MullvadApplication) } + loadKoinModules(listOf(appModule)) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt index 3815a3bb46..8ca896cd73 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ConnectionButton.kt @@ -33,12 +33,12 @@ import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisconnectButton import net.mullvad.mullvadvpn.lib.theme.color.onVariant import net.mullvad.mullvadvpn.lib.theme.color.variant -import net.mullvad.mullvadvpn.model.TunnelState @Composable fun ConnectionButton( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt index 65a3399631..529a310919 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt @@ -23,13 +23,13 @@ import net.mullvad.mullvadvpn.lib.theme.Dimens @Preview @Composable private fun PreviewCheckboxCell() { - AppTheme { CheckboxCell(providerName = "Provider 1", checked = false, onCheckedChange = {}) } + AppTheme { CheckboxCell(title = "1337", checked = false, onCheckedChange = {}) } } @Composable internal fun CheckboxCell( modifier: Modifier = Modifier, - providerName: String, + title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, background: Color = MaterialTheme.colorScheme.secondaryContainer, @@ -52,7 +52,7 @@ internal fun CheckboxCell( Spacer(modifier = Modifier.size(Dimens.mediumPadding)) Text( - text = providerName, + text = title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onBackground, modifier = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt deleted file mode 100644 index 1029cfada0..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt +++ /dev/null @@ -1,31 +0,0 @@ -package net.mullvad.mullvadvpn.compose.cell - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import net.mullvad.mullvadvpn.relaylist.RelayItem - -@Composable -fun CustomListCell( - customList: RelayItem.CustomList, - modifier: Modifier = Modifier, - onCellClicked: (RelayItem.CustomList) -> Unit = {}, - textStyle: TextStyle = MaterialTheme.typography.titleMedium, - textColor: Color = MaterialTheme.colorScheme.onPrimary, - background: Color = MaterialTheme.colorScheme.primary, -) { - BaseCell( - headlineContent = { - BaseCellTitle( - title = customList.name, - style = textStyle, - color = textColor, - ) - }, - modifier = modifier, - background = background, - onCellClicked = { onCellClicked(customList) } - ) -} 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 cd5a08edbf..cdb4825150 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.SpacedColumn +import net.mullvad.mullvadvpn.lib.model.Port import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible @@ -37,7 +38,7 @@ 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 = true, port = Port(444)) CustomPortCell(title = "Title", isSelected = false, port = null) } } @@ -47,7 +48,7 @@ private fun PreviewCustomPortCell() { fun CustomPortCell( title: String, isSelected: Boolean, - port: Int?, + port: Port?, mainTestTag: String = "", numberTestTag: String = "", onMainCellClicked: () -> Unit = {}, @@ -100,7 +101,7 @@ fun CustomPortCell( .testTag(numberTestTag) ) { Text( - text = port?.toString() ?: stringResource(id = R.string.port), + text = port?.value?.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/DropdownMenuCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt index 3d52aca80c..9c429757fb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt @@ -1,12 +1,9 @@ package net.mullvad.mullvadvpn.compose.cell -import androidx.compose.foundation.background import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt index d2dcf1e863..6dfd8f3eb1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/FilterCell.kt @@ -16,9 +16,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.MullvadFilterChip +import net.mullvad.mullvadvpn.lib.model.Ownership import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.model.Ownership @Preview @Composable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt index d949f2a708..0ea18d0b48 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt @@ -1,8 +1,6 @@ package net.mullvad.mullvadvpn.compose.cell import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth as wrapContentWidth1 import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -12,17 +10,18 @@ import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.constant.MTU_MAX_VALUE import net.mullvad.mullvadvpn.constant.MTU_MIN_VALUE +import net.mullvad.mullvadvpn.lib.model.Mtu import net.mullvad.mullvadvpn.lib.theme.AppTheme @Preview @Composable private fun PreviewMtuComposeCell() { - AppTheme { MtuComposeCell(mtuValue = "1300", onEditMtu = {}) } + AppTheme { MtuComposeCell(mtuValue = Mtu(1300), onEditMtu = {}) } } @Composable fun MtuComposeCell( - mtuValue: String, + mtuValue: Mtu?, onEditMtu: () -> Unit, ) { val titleModifier = Modifier @@ -45,10 +44,10 @@ private fun MtuTitle(modifier: Modifier) { } @Composable -private fun MtuBodyView(mtuValue: String, modifier: Modifier) { - Row(modifier = modifier.wrapContentWidth1().wrapContentHeight()) { +private fun MtuBodyView(mtuValue: Mtu?, modifier: Modifier) { + Row(modifier = modifier) { Text( - text = mtuValue.ifEmpty { stringResource(id = R.string.hint_default) }, + text = mtuValue?.value?.toString() ?: stringResource(id = R.string.hint_default), color = MaterialTheme.colorScheme.onPrimary ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt index a2d7cc74c1..d1903b75d5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt @@ -32,97 +32,45 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.Chevron import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox -import net.mullvad.mullvadvpn.compose.util.generateRelayItemCountry +import net.mullvad.mullvadvpn.compose.preview.RelayItemCheckableCellPreviewParameterProvider +import net.mullvad.mullvadvpn.compose.preview.RelayItemStatusCellPreviewParameterProvider +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.lib.theme.color.selected -import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.relaylist.children @Composable @Preview -private fun PreviewStatusRelayLocationCell() { +private fun PreviewStatusRelayLocationCell( + @PreviewParameter(RelayItemStatusCellPreviewParameterProvider::class) + relayItems: List<RelayItem.Location.Country> +) { AppTheme { Column(Modifier.background(color = MaterialTheme.colorScheme.background)) { - val countryActive = - generateRelayItemCountry( - name = "Relay country Active", - cityNames = listOf("Relay city 1", "Relay city 2"), - relaysPerCity = 2 - ) - val countryNotActive = - generateRelayItemCountry( - name = "Not Enabled Relay country", - cityNames = listOf("Not Enabled city"), - relaysPerCity = 1, - active = false - ) - val countryExpanded = - generateRelayItemCountry( - name = "Relay country Expanded", - cityNames = listOf("Normal city"), - relaysPerCity = 2, - expanded = true - ) - val countryAndCityExpanded = - generateRelayItemCountry( - name = "Country and city Expanded", - cityNames = listOf("Expanded city A", "Expanded city B"), - relaysPerCity = 2, - expanded = true, - expandChildren = true - ) - // Active relay list not expanded - StatusRelayLocationCell(countryActive) - // Not Active Relay - StatusRelayLocationCell(countryNotActive) - // Relay expanded country - StatusRelayLocationCell(countryExpanded) - // Relay expanded country and cities - StatusRelayLocationCell(countryAndCityExpanded) + relayItems.map { StatusRelayLocationCell(relay = it) } } } } @Composable @Preview -private fun PreviewCheckableRelayLocationCell() { +private fun PreviewCheckableRelayLocationCell( + @PreviewParameter(RelayItemCheckableCellPreviewParameterProvider::class) + relayItems: List<RelayItem.Location.Country> +) { AppTheme { Column(Modifier.background(color = MaterialTheme.colorScheme.background)) { - val countryActive = - generateRelayItemCountry( - name = "Relay country Active", - cityNames = listOf("Relay city 1", "Relay city 2"), - relaysPerCity = 2 - ) - val countryExpanded = - generateRelayItemCountry( - name = "Relay country Expanded", - cityNames = listOf("Normal city"), - relaysPerCity = 2, - expanded = true - ) - val countryAndCityExpanded = - generateRelayItemCountry( - name = "Country and city Expanded", - cityNames = listOf("Expanded city A", "Expanded city B"), - relaysPerCity = 2, - expanded = true, - expandChildren = true - ) - // Active relay list not expanded - CheckableRelayLocationCell(countryActive) - // Relay expanded country - CheckableRelayLocationCell(countryExpanded) - // Relay expanded country and cities - CheckableRelayLocationCell(countryAndCityExpanded) + relayItems.map { CheckableRelayLocationCell(relay = it) } } } } @@ -134,14 +82,14 @@ fun StatusRelayLocationCell( activeColor: Color = MaterialTheme.colorScheme.selected, inactiveColor: Color = MaterialTheme.colorScheme.error, disabledColor: Color = MaterialTheme.colorScheme.onSecondary, - selectedItem: RelayItem? = null, + selectedItem: RelayItemId? = null, onSelectRelay: (item: RelayItem) -> Unit = {}, onLongClick: (item: RelayItem) -> Unit = {}, ) { RelayLocationCell( relay = relay, leadingContent = { relayItem -> - val selected = selectedItem?.code == relayItem.code + val selected = selectedItem == relayItem.id Box( modifier = Modifier.align(Alignment.CenterStart) @@ -175,7 +123,7 @@ fun StatusRelayLocationCell( modifier = modifier, specialBackgroundColor = { relayItem -> when { - selectedItem?.code == relayItem.code -> MaterialTheme.colorScheme.selected + selectedItem == relayItem.id -> MaterialTheme.colorScheme.selected relayItem is RelayItem.CustomList && !relayItem.active -> MaterialTheme.colorScheme.surfaceTint else -> null diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListAction.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListAction.kt index 9ddee73e22..206c90ab7b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListAction.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListAction.kt @@ -2,36 +2,34 @@ package net.mullvad.mullvadvpn.compose.communication import android.os.Parcelable import kotlinx.parcelize.Parcelize -import net.mullvad.mullvadvpn.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId sealed interface CustomListAction : Parcelable { - @Parcelize - data class Rename( - val customListId: String, - val name: CustomListName, - val newName: CustomListName - ) : CustomListAction { + data class Rename(val id: CustomListId, val name: CustomListName, val newName: CustomListName) : + CustomListAction { fun not() = this.copy(name = newName, newName = name) } @Parcelize - data class Delete(val customListId: String) : CustomListAction { - fun not(name: CustomListName, locations: List<String>) = Create(name, locations) + data class Delete(val id: CustomListId) : CustomListAction { + fun not(name: CustomListName, locations: List<GeoLocationId>) = Create(name, locations) } @Parcelize - data class Create(val name: CustomListName, val locations: List<String> = emptyList()) : - CustomListAction, Parcelable { - fun not(customListId: String) = Delete(customListId) + data class Create(val name: CustomListName, val locations: List<GeoLocationId>) : + CustomListAction { + fun not(customListId: CustomListId) = Delete(customListId) } @Parcelize data class UpdateLocations( - val customListId: String, - val locations: List<String> = emptyList() + val id: CustomListId, + val locations: List<GeoLocationId> = emptyList() ) : CustomListAction { - fun not(locations: List<String>): UpdateLocations = - UpdateLocations(customListId = customListId, locations = locations) + fun not(locations: List<GeoLocationId>): UpdateLocations = + UpdateLocations(id = id, locations = locations) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListResult.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListResult.kt deleted file mode 100644 index 14cba09b44..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListResult.kt +++ /dev/null @@ -1,35 +0,0 @@ -package net.mullvad.mullvadvpn.compose.communication - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import net.mullvad.mullvadvpn.model.CustomListName - -sealed interface CustomListResult : Parcelable { - val undo: CustomListAction - - @Parcelize - data class Created( - val id: String, - val name: CustomListName, - val locationName: String?, - override val undo: CustomListAction.Delete - ) : CustomListResult - - @Parcelize - data class Deleted(override val undo: CustomListAction.Create) : CustomListResult { - val name: CustomListName - get() = undo.name - } - - @Parcelize - data class Renamed(override val undo: CustomListAction.Rename) : CustomListResult { - val name: CustomListName - get() = undo.name - } - - @Parcelize - data class LocationsChanged( - val name: CustomListName, - override val undo: CustomListAction.UpdateLocations - ) : CustomListResult -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListSuccess.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListSuccess.kt new file mode 100644 index 0000000000..d83cd4c76d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListSuccess.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.compose.communication + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName + +sealed interface CustomListSuccess : Parcelable { + val undo: CustomListAction +} + +@Parcelize +data class Created( + val id: CustomListId, + val name: CustomListName, + val locationNames: List<String>, + override val undo: CustomListAction.Delete +) : CustomListSuccess + +@Parcelize +data class Deleted(override val undo: CustomListAction.Create) : CustomListSuccess { + val name: CustomListName + get() = undo.name +} + +@Parcelize +data class Renamed(override val undo: CustomListAction.Rename) : CustomListSuccess { + val name: CustomListName + get() = undo.name +} + +@Parcelize +data class LocationsChanged( + val name: CustomListName, + override val undo: CustomListAction.UpdateLocations +) : CustomListSuccess diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/DnsDialogResult.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/DnsDialogResult.kt new file mode 100644 index 0000000000..45eb76cc85 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/DnsDialogResult.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.compose.communication + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +interface DnsDialogResult : Parcelable { + @Parcelize data object Success : DnsDialogResult + + @Parcelize data object Error : DnsDialogResult + + @Parcelize data object Cancel : DnsDialogResult +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt index a081b9f079..9774cc27fb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ConnectionStatusText.kt @@ -6,12 +6,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.ErrorState +import net.mullvad.mullvadvpn.lib.model.ErrorStateCause +import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.typeface.connectionStatus -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.tunnel.ErrorState -import net.mullvad.talpid.tunnel.ErrorStateCause @Preview @Composable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt index b3a0ece577..bb4339a1f7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt @@ -10,19 +10,16 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType -import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.textfield.CustomTextField -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.lib.model.CustomListName @Composable fun CustomListNameTextField( modifier: Modifier = Modifier, name: String, isValidName: Boolean, - error: CustomListsError?, + error: String?, onValueChanged: (String) -> Unit, onSubmit: (String) -> Unit ) { @@ -47,7 +44,7 @@ fun CustomListNameTextField( error?.let { { Text( - text = it.errorString(), + text = it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall ) @@ -63,12 +60,3 @@ fun CustomListNameTextField( LaunchedEffect(Unit) { focusRequester.requestFocus() } } - -@Composable -private fun CustomListsError.errorString() = - stringResource( - when (this) { - CustomListsError.CustomListExists -> R.string.custom_list_error_list_exists - CustomListsError.OtherError -> R.string.error_occurred - } - ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt index ed41d25f40..a913368de5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt @@ -16,13 +16,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_CONNECTION_OUT_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.TransportProtocol import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible -import net.mullvad.mullvadvpn.model.GeoIpLocation -import net.mullvad.talpid.net.TransportProtocol @Preview @Composable 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 585855cb1d..4e03ebf4ae 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 @@ -270,6 +270,7 @@ fun ScaffoldWithSmallTopBar( modifier: Modifier = Modifier, navigationIcon: @Composable () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, content: @Composable (modifier: Modifier) -> Unit ) { Scaffold( @@ -281,6 +282,12 @@ fun ScaffoldWithSmallTopBar( actions = actions ) }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) } + ) + }, content = { content(Modifier.fillMaxSize().padding(it)) } ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Text.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Text.kt index 18a88fdf79..79fdec7b9d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Text.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Text.kt @@ -11,59 +11,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp internal val DEFAULT_TEXT_STEP = 1.sp @Composable -fun CapsText( - text: String, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - fontSize: TextUnit = TextUnit.Unspecified, - fontStyle: androidx.compose.ui.text.font.FontStyle? = null, - fontWeight: FontWeight? = null, - fontFamily: FontFamily? = null, - letterSpacing: TextUnit = TextUnit.Unspecified, - textDecoration: TextDecoration? = null, - textAlign: TextAlign? = null, - lineHeight: TextUnit = TextUnit.Unspecified, - overflow: TextOverflow = TextOverflow.Clip, - softWrap: Boolean = true, - maxLines: Int = Int.MAX_VALUE, - onTextLayout: (TextLayoutResult) -> Unit = {}, - style: TextStyle = LocalTextStyle.current -) { - Text( - text = text.uppercase(), - modifier = modifier, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - onTextLayout = onTextLayout, - style = style, - ) -} - -@Composable fun AutoResizeText( text: String, minTextSize: TextUnit, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt index 94dc40a175..dbc510b009 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt @@ -29,15 +29,14 @@ import net.mullvad.mullvadvpn.compose.component.MullvadTopBar import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION import net.mullvad.mullvadvpn.compose.util.rememberPrevious +import net.mullvad.mullvadvpn.lib.model.ErrorState +import net.mullvad.mullvadvpn.lib.model.ErrorStateCause 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.repository.InAppNotification import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.notification.StatusLevel -import net.mullvad.talpid.tunnel.ErrorState -import net.mullvad.talpid.tunnel.ErrorStateCause -import net.mullvad.talpid.tunnel.FirewallPolicyError import org.joda.time.DateTime @Preview @@ -52,23 +51,16 @@ private fun PreviewNotificationBanner() { InAppNotification.UnsupportedVersion( versionInfo = VersionInfo( - currentVersion = null, - upgradeVersion = null, - isOutdated = true, - isSupported = false + currentVersion = "1.0", + isSupported = false, + suggestedUpgradeVersion = null ), ), InAppNotification.AccountExpiry(expiry = DateTime.now()), InAppNotification.TunnelStateBlocked, InAppNotification.NewDevice("Courageous Turtle"), InAppNotification.TunnelStateError( - error = - ErrorState( - ErrorStateCause.SetFirewallPolicyError( - FirewallPolicyError.Generic - ), - true - ) + error = ErrorState(ErrorStateCause.FirewallPolicyError.Generic, true) ) ) .map { it.toNotificationData(false, {}, {}, {}) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt index 99501d1f4d..b8ea96fc72 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt @@ -13,9 +13,9 @@ import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.extensions.getExpiryQuantityString import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString import net.mullvad.mullvadvpn.lib.common.util.getErrorNotificationResources +import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.ui.notification.StatusLevel -import net.mullvad.talpid.tunnel.ErrorState data class NotificationData( val title: String, @@ -99,7 +99,7 @@ fun InAppNotification.toNotificationData( message = stringResource( id = R.string.update_available_description, - versionInfo.upgradeVersion ?: "" // TODO Verify + versionInfo.suggestedUpgradeVersion ?: "" ), statusLevel = StatusLevel.Warning, action = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt index 98f2007bc0..90e82e1fbf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt @@ -20,13 +20,15 @@ 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.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.Created import net.mullvad.mullvadvpn.compose.component.CustomListNameTextField import net.mullvad.mullvadvpn.compose.destinations.CustomListLocationsDestination import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState import net.mullvad.mullvadvpn.compose.test.CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG +import net.mullvad.mullvadvpn.lib.model.CustomListAlreadyExists +import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.usecase.customlists.CreateWithLocationsError import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogSideEffect import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel import org.koin.androidx.compose.koinViewModel @@ -43,7 +45,10 @@ private fun PreviewCreateCustomListDialog() { private fun PreviewCreateCustomListDialogError() { AppTheme { CreateCustomListDialog( - state = CreateCustomListUiState(error = CustomListsError.CustomListExists) + state = + CreateCustomListUiState( + error = CreateWithLocationsError.Create(CustomListAlreadyExists) + ) ) } } @@ -52,8 +57,8 @@ private fun PreviewCreateCustomListDialogError() { @Destination(style = DestinationStyle.Dialog::class) fun CreateCustomList( navigator: DestinationsNavigator, - backNavigator: ResultBackNavigator<CustomListResult.Created>, - locationCode: String = "" + backNavigator: ResultBackNavigator<Created>, + locationCode: GeoLocationId? = null ) { val vm: CreateCustomListDialogViewModel = koinViewModel(parameters = { parametersOf(locationCode) }) @@ -106,7 +111,7 @@ fun CreateCustomListDialog( CustomListNameTextField( name = name.value, isValidName = isValidName, - error = state.error, + error = state.error?.errorString(), onSubmit = createCustomList, onValueChanged = { name.value = it @@ -130,3 +135,13 @@ fun CreateCustomListDialog( } ) } + +@Composable +private fun CreateWithLocationsError.errorString() = + stringResource( + if (this is CreateWithLocationsError.Create && this.error is CustomListAlreadyExists) { + R.string.custom_list_error_list_exists + } else { + R.string.error_occurred + } + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt index 236dedec6a..e9718d7c24 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt @@ -1,12 +1,15 @@ package net.mullvad.mullvadvpn.compose.dialog +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -14,14 +17,18 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle 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.NegativeButton import net.mullvad.mullvadvpn.compose.button.PrimaryButton -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.Deleted +import net.mullvad.mullvadvpn.compose.state.DeleteCustomListUiState import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationSideEffect @@ -32,18 +39,24 @@ import org.koin.core.parameter.parametersOf @Preview @Composable private fun PreviewRemoveDeviceConfirmationDialog() { - AppTheme { DeleteCustomListConfirmationDialog("My Custom List") } + AppTheme { + DeleteCustomListConfirmationDialog( + state = DeleteCustomListUiState(null), + name = CustomListName.fromString("My Custom List") + ) + } } @Composable @Destination(style = DestinationStyle.Dialog::class) fun DeleteCustomList( - navigator: ResultBackNavigator<CustomListResult.Deleted>, - customListId: String, - name: String + navigator: ResultBackNavigator<Deleted>, + customListId: CustomListId, + name: CustomListName ) { val viewModel: DeleteCustomListConfirmationViewModel = koinViewModel(parameters = { parametersOf(customListId) }) + val state = viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffectCollect(viewModel.uiSideEffect) { when (it) { @@ -53,6 +66,7 @@ fun DeleteCustomList( } DeleteCustomListConfirmationDialog( + state = state.value, name = name, onDelete = viewModel::deleteCustomList, onBack = navigator::navigateBack @@ -61,7 +75,8 @@ fun DeleteCustomList( @Composable fun DeleteCustomListConfirmationDialog( - name: String, + state: DeleteCustomListUiState, + name: CustomListName, onDelete: () -> Unit = {}, onBack: () -> Unit = {} ) { @@ -76,10 +91,23 @@ fun DeleteCustomListConfirmationDialog( ) }, title = { - Text( - text = - stringResource(id = R.string.delete_custom_list_confirmation_description, name) - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = + stringResource( + id = R.string.delete_custom_list_confirmation_description, + name.value + ) + ) + if (state.deleteError != null) { + Text( + text = stringResource(id = R.string.error_occurred), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = Dimens.smallPadding) + ) + } + } }, dismissButton = { PrimaryButton( 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 7ac1469f09..5b76023a7e 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 @@ -20,6 +20,7 @@ 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.communication.DnsDialogResult import net.mullvad.mullvadvpn.compose.textfield.DnsTextField import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -93,7 +94,7 @@ private fun PreviewDnsDialogEditAllowLanDisabled() { @Destination(style = DestinationStyle.Dialog::class) @Composable fun DnsDialog( - resultNavigator: ResultBackNavigator<Boolean>, + resultNavigator: ResultBackNavigator<DnsDialogResult>, index: Int?, initialValue: String?, ) { @@ -102,7 +103,10 @@ fun DnsDialog( LaunchedEffectCollect(viewModel.uiSideEffect) { when (it) { - DnsDialogSideEffect.Complete -> resultNavigator.navigateBack(result = true) + DnsDialogSideEffect.Complete -> + resultNavigator.navigateBack(result = DnsDialogResult.Success) + DnsDialogSideEffect.Error -> + resultNavigator.navigateBack(result = DnsDialogResult.Error) } } val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -112,7 +116,7 @@ fun DnsDialog( viewModel::onDnsInputChange, onSaveDnsClick = viewModel::onSaveDnsClick, onRemoveDnsClick = viewModel::onRemoveDnsClick, - onDismiss = { resultNavigator.navigateBack(result = false) } + onDismiss = { resultNavigator.navigateBack(result = DnsDialogResult.Cancel) } ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt index 9f46ee1d5a..c01ceab7f8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt @@ -18,12 +18,18 @@ 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.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.Renamed import net.mullvad.mullvadvpn.compose.component.CustomListNameTextField -import net.mullvad.mullvadvpn.compose.state.UpdateCustomListUiState +import net.mullvad.mullvadvpn.compose.state.EditCustomListNameUiState import net.mullvad.mullvadvpn.compose.test.EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GetCustomListError +import net.mullvad.mullvadvpn.lib.model.NameAlreadyExists +import net.mullvad.mullvadvpn.lib.model.UnknownCustomListError import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.usecase.customlists.RenameError import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogSideEffect import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel import org.koin.androidx.compose.koinViewModel @@ -32,15 +38,15 @@ import org.koin.core.parameter.parametersOf @Preview @Composable private fun PreviewEditCustomListNameDialog() { - AppTheme { EditCustomListNameDialog(UpdateCustomListUiState()) } + AppTheme { EditCustomListNameDialog(EditCustomListNameUiState()) } } @Composable @Destination(style = DestinationStyle.Dialog::class) fun EditCustomListName( - backNavigator: ResultBackNavigator<CustomListResult.Renamed>, - customListId: String, - initialName: String + backNavigator: ResultBackNavigator<Renamed>, + customListId: CustomListId, + initialName: CustomListName ) { val vm: EditCustomListNameDialogViewModel = koinViewModel(parameters = { parametersOf(customListId, initialName) }) @@ -63,7 +69,7 @@ fun EditCustomListName( @Composable fun EditCustomListNameDialog( - state: UpdateCustomListUiState, + state: EditCustomListNameUiState, updateName: (String) -> Unit = {}, onInputChanged: () -> Unit = {}, onDismiss: () -> Unit = {} @@ -81,7 +87,7 @@ fun EditCustomListNameDialog( CustomListNameTextField( name = name.value, isValidName = isValidName, - error = state.error, + error = state.error?.errorString(), onSubmit = updateName, onValueChanged = { name.value = it @@ -105,3 +111,13 @@ fun EditCustomListNameDialog( } ) } + +@Composable +private fun RenameError.errorString() = + stringResource( + when (error) { + is NameAlreadyExists -> R.string.custom_list_error_list_exists + is GetCustomListError, + is UnknownCustomListError -> R.string.error_occurred + } + ) 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 4644a1aa95..c9276c5c09 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,14 +8,14 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +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 @@ -24,49 +24,51 @@ import net.mullvad.mullvadvpn.compose.textfield.MtuTextField import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.constant.MTU_MAX_VALUE import net.mullvad.mullvadvpn.constant.MTU_MIN_VALUE +import net.mullvad.mullvadvpn.lib.model.Mtu 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.MtuDialogUiState import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf @Preview @Composable private fun PreviewMtuDialog() { - AppTheme { MtuDialog(mtuInitial = 1234, EmptyDestinationsNavigator) } + AppTheme { MtuDialog(mtuInitial = Mtu(1234), EmptyResultBackNavigator()) } } @Destination(style = DestinationStyle.Dialog::class) @Composable -fun MtuDialog(mtuInitial: Int?, navigator: DestinationsNavigator) { - val viewModel = koinViewModel<MtuDialogViewModel>() +fun MtuDialog(mtuInitial: Mtu?, navigator: ResultBackNavigator<Boolean>) { + val viewModel = koinViewModel<MtuDialogViewModel>(parameters = { parametersOf(mtuInitial) }) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffectCollect(viewModel.uiSideEffect) { when (it) { - MtuDialogSideEffect.Complete -> navigator.navigateUp() + MtuDialogSideEffect.Complete -> navigator.navigateBack(result = true) + MtuDialogSideEffect.Error -> navigator.navigateBack(result = false) } } MtuDialog( - mtuInitial = mtuInitial, + uiState, + onInputChanged = viewModel::onInputChanged, onSaveMtu = viewModel::onSaveClick, onResetMtu = viewModel::onRestoreClick, - onDismiss = navigator::navigateUp + onDismiss = { navigator.navigateBack(true) } ) } @Composable fun MtuDialog( - mtuInitial: Int?, - onSaveMtu: (Int) -> Unit, + state: MtuDialogUiState, + onInputChanged: (String) -> Unit, + onSaveMtu: (String) -> Unit, onResetMtu: () -> Unit, onDismiss: () -> Unit, ) { - - val mtu = remember { mutableStateOf(mtuInitial?.toString() ?: "") } - val isValidMtu = mtu.value.toIntOrNull()?.isValidMtu() == true - AlertDialog( onDismissRequest = onDismiss, title = { @@ -78,18 +80,13 @@ fun MtuDialog( text = { Column { MtuTextField( - value = mtu.value, - onValueChanged = { newMtuValue -> mtu.value = newMtuValue }, - onSubmit = { newMtuValue -> - val mtuInt = newMtuValue.toIntOrNull() - if (mtuInt?.isValidMtu() == true) { - onSaveMtu(mtuInt) - } - }, + value = state.mtuInput, + onValueChanged = onInputChanged, + onSubmit = onSaveMtu, isEnabled = true, placeholderText = stringResource(R.string.enter_value_placeholder), maxCharLength = 4, - isValidValue = isValidMtu, + isValidValue = state.isValidInput, modifier = Modifier.fillMaxWidth() ) @@ -110,17 +107,12 @@ fun MtuDialog( Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { PrimaryButton( modifier = Modifier.fillMaxWidth(), - isEnabled = isValidMtu, + isEnabled = state.isValidInput, text = stringResource(R.string.submit_button), - onClick = { - val mtuInt = mtu.value.toIntOrNull() - if (mtuInt?.isValidMtu() == true) { - onSaveMtu(mtuInt) - } - } + onClick = { onSaveMtu(state.mtuInput) } ) - if (mtuInitial != null) { + if (state.showResetToDefault) { NegativeButton( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.reset_to_default_button), 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 88eb682849..7034e67a91 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 @@ -40,6 +40,7 @@ import net.mullvad.mullvadvpn.compose.textfield.CustomTextField import net.mullvad.mullvadvpn.compose.util.MAX_VOUCHER_LENGTH import net.mullvad.mullvadvpn.compose.util.vouchersVisualTransformation import net.mullvad.mullvadvpn.constant.VOUCHER_LENGTH +import net.mullvad.mullvadvpn.lib.model.RedeemVoucherError import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription @@ -78,7 +79,11 @@ private fun PreviewRedeemVoucherDialogVerifying() { private fun PreviewRedeemVoucherDialogError() { AppTheme { RedeemVoucherDialog( - state = VoucherDialogUiState("", VoucherDialogState.Error("An Error message")), + state = + VoucherDialogUiState( + "", + VoucherDialogState.Error(RedeemVoucherError.InvalidVoucher) + ), onVoucherInputChange = {}, onRedeem = {}, onDismiss = {} @@ -263,10 +268,18 @@ private fun EnterVoucherBody( ) } else if (state.voucherState is VoucherDialogState.Error) { Text( - text = state.voucherState.errorMessage, + text = stringResource(id = state.voucherState.error.message()), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall ) } } } + +private fun RedeemVoucherError.message(): Int = + when (this) { + RedeemVoucherError.InvalidVoucher -> R.string.invalid_voucher + RedeemVoucherError.VoucherAlreadyUsed -> R.string.voucher_already_used + RedeemVoucherError.RpcError, + is RedeemVoucherError.Unknown -> R.string.error_occurred + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt index a0270989cf..b8592c1acb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt @@ -13,6 +13,7 @@ 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 androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.sp import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.result.EmptyResultBackNavigator @@ -23,24 +24,23 @@ 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.compose.preview.DevicePreviewParameterProvider +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.model.Device @Preview @Composable -private fun PreviewRemoveDeviceConfirmationDialog() { - AppTheme { - RemoveDeviceConfirmationDialog( - EmptyResultBackNavigator(), - device = Device("test", "test", byteArrayOf(), "test") - ) - } +private fun PreviewRemoveDeviceConfirmationDialog( + @PreviewParameter(DevicePreviewParameterProvider::class) device: Device +) { + AppTheme { RemoveDeviceConfirmationDialog(EmptyResultBackNavigator(), device = device) } } @Destination(style = DestinationStyle.Dialog::class) @Composable -fun RemoveDeviceConfirmationDialog(navigator: ResultBackNavigator<String>, device: Device) { +fun RemoveDeviceConfirmationDialog(navigator: ResultBackNavigator<DeviceId>, device: Device) { AlertDialog( onDismissRequest = navigator::navigateBack, icon = { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt index c90c22ead4..46111ebf8c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ResetServerIpOverridesConfirmationDialog.kt @@ -37,6 +37,8 @@ fun ResetServerIpOverridesConfirmation(resultBackNavigator: ResultBackNavigator< when (it) { ResetServerIpOverridesConfirmationUiSideEffect.OverridesCleared -> resultBackNavigator.navigateBack(result = true) + is ResetServerIpOverridesConfirmationUiSideEffect.OverridesError -> + resultBackNavigator.navigateBack(result = false) } } ResetServerIpOverridesConfirmationDialog( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt index 44901ce656..6640984a0f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/WireguardCustomPortDialog.kt @@ -26,12 +26,13 @@ import net.mullvad.mullvadvpn.compose.button.NegativeButton import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.test.CUSTOM_PORT_DIALOG_INPUT_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.CustomPortTextField +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription -import net.mullvad.mullvadvpn.model.PortRange import net.mullvad.mullvadvpn.util.asString -import net.mullvad.mullvadvpn.util.isPortInValidRanges +import net.mullvad.mullvadvpn.util.inAnyOf @Preview @Composable @@ -40,7 +41,7 @@ private fun PreviewWireguardCustomPortDialog() { WireguardCustomPortDialog( WireguardCustomPortNavArgs( customPort = null, - allowedPortRanges = listOf(PortRange(10, 10), PortRange(40, 50)), + allowedPortRanges = listOf(PortRange(10..10), PortRange(40..50)), ), EmptyResultBackNavigator() ) @@ -49,7 +50,7 @@ private fun PreviewWireguardCustomPortDialog() { @Parcelize data class WireguardCustomPortNavArgs( - val customPort: Int?, + val customPort: Port?, val allowedPortRanges: List<PortRange>, ) : Parcelable @@ -57,7 +58,7 @@ data class WireguardCustomPortNavArgs( @Composable fun WireguardCustomPortDialog( navArg: WireguardCustomPortNavArgs, - backNavigator: ResultBackNavigator<Int?>, + backNavigator: ResultBackNavigator<Port?>, ) { WireguardCustomPortDialog( initialPort = navArg.customPort, @@ -69,12 +70,14 @@ fun WireguardCustomPortDialog( @Composable fun WireguardCustomPortDialog( - initialPort: Int?, + initialPort: Port?, allowedPortRanges: List<PortRange>, - onSave: (Int?) -> Unit, + onSave: (Port?) -> Unit, onDismiss: () -> Unit ) { - val port = remember { mutableStateOf(initialPort?.toString() ?: "") } + val port = remember { mutableStateOf(initialPort?.value?.toString() ?: "") } + + val isValidPort = port.value.toPortOrNull()?.inAnyOf(allowedPortRanges) ?: false AlertDialog( title = { @@ -86,10 +89,8 @@ fun WireguardCustomPortDialog( Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { PrimaryButton( text = stringResource(id = R.string.custom_port_dialog_submit), - onClick = { onSave(port.value.toInt()) }, - isEnabled = - port.value.isNotEmpty() && - allowedPortRanges.isPortInValidRanges(port.value.toIntOrNull() ?: 0) + onClick = { onSave(port.value.toPortOrNull()) }, + isEnabled = isValidPort ) if (initialPort != null) { NegativeButton( @@ -105,17 +106,12 @@ fun WireguardCustomPortDialog( CustomPortTextField( value = port.value, onSubmit = { input -> - if ( - input.isNotEmpty() && - allowedPortRanges.isPortInValidRanges(input.toIntOrNull() ?: 0) - ) { - onSave(input.toIntOrNull()) + if (isValidPort) { + onSave(input.toPortOrNull()) } }, onValueChanged = { input -> port.value = input }, - isValidValue = - port.value.isNotEmpty() && - allowedPortRanges.isPortInValidRanges(port.value.toIntOrNull() ?: 0), + isValidValue = isValidPort, maxCharLength = 5, modifier = Modifier.testTag(CUSTOM_PORT_DIALOG_INPUT_TEST_TAG).fillMaxWidth() ) @@ -136,3 +132,5 @@ fun WireguardCustomPortDialog( onDismissRequest = onDismiss ) } + +private fun String.toPortOrNull() = toIntOrNull()?.let { Port(it) } 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 a3329b1248..7de2e97fbb 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 @@ -10,8 +10,8 @@ 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.model.PortRange import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.model.PortRange import net.mullvad.mullvadvpn.util.asString @Preview @@ -20,7 +20,7 @@ private fun PreviewWireguardPortInfoDialog() { AppTheme { WireguardPortInfoDialog( EmptyDestinationsNavigator, - argument = WireguardPortInfoDialogArgument(listOf(PortRange(1, 2))) + argument = WireguardPortInfoDialogArgument(listOf(PortRange(1..2))) ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt index 03e9434006..5af5e4305d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt @@ -126,10 +126,9 @@ fun Payment(productId: ProductId, resultBackNavigator: ResultBackNavigator<Boole val vm = koinViewModel<PaymentViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() - LaunchedEffectCollect(vm.uiSideEffect) { - when (it) { - is PaymentUiSideEffect.PaymentCancelled -> - resultBackNavigator.navigateBack(result = false) + LaunchedEffectCollect(vm.uiSideEffect) { sideEffect -> + when (sideEffect) { + PaymentUiSideEffect.PaymentCancelled -> resultBackNavigator.navigateBack(result = false) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt deleted file mode 100644 index 8a418c17aa..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt +++ /dev/null @@ -1,18 +0,0 @@ -package net.mullvad.mullvadvpn.compose.extensions - -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult - -suspend fun SnackbarHostState.showSnackbar( - message: String, - actionLabel: String, - duration: SnackbarDuration = SnackbarDuration.Indefinite, - onAction: (() -> Unit), - onDismiss: (() -> Unit) = {} -) { - when (showSnackbar(message = message, actionLabel = actionLabel, duration = duration)) { - SnackbarResult.ActionPerformed -> onAction() - SnackbarResult.Dismissed -> onDismiss() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt index e85939c51c..e8a3706b66 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt @@ -4,9 +4,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.stringResource import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.common.util.createAccountUri +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken @Composable -fun UriHandler.createOpenAccountPageHook(): (String) -> Unit { +fun UriHandler.createOpenAccountPageHook(): (WebsiteAuthToken) -> Unit { val accountUrl = stringResource(id = R.string.account_url) - return { token -> this.openUri("$accountUrl?token=$token") } + return { token -> this.openUri(createAccountUri(accountUrl, token).toString()) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DeviceListPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DeviceListPreviewParameterProvider.kt new file mode 100644 index 0000000000..405c2b1a4d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DeviceListPreviewParameterProvider.kt @@ -0,0 +1,16 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.preview.DevicePreviewData.generateDevices +import net.mullvad.mullvadvpn.compose.state.DeviceItemUiState + +class DeviceListPreviewParameterProvider : PreviewParameterProvider<List<DeviceItemUiState>> { + override val values = + sequenceOf( + generateDevices(NUMBER_OF_DEVICES_NORMAL), + generateDevices(NUMBER_OF_DEVICES_TOO_MANY) + ) +} + +private const val NUMBER_OF_DEVICES_NORMAL = 4 +private const val NUMBER_OF_DEVICES_TOO_MANY = 5 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt new file mode 100644 index 0000000000..8178431452 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt @@ -0,0 +1,29 @@ +package net.mullvad.mullvadvpn.compose.preview + +import net.mullvad.mullvadvpn.compose.state.DeviceItemUiState +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import org.joda.time.DateTime + +internal object DevicePreviewData { + fun generateDevices(count: Int) = + List(count) { index -> generateDevice(index) } + .mapIndexed { index, device -> + DeviceItemUiState(device = device, isLoading = index == 0) + } + + fun generateDevice( + index: Int = 0, + id: String = UUID, + name: String? = null, + ) = + Device( + id = DeviceId.fromString(id), + name = name ?: "Device $index-${id.take(DEVICE_SUFFIX_LENGTH)}", + creationDate = DEVICE_CREATION_DATE.plusMonths(index) + ) +} + +private const val DEVICE_SUFFIX_LENGTH = 4 +private const val UUID = "12345678-1234-5678-1234-567812345678" +private val DEVICE_CREATION_DATE = DateTime.parse("2024-05-27") diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewParameterProvider.kt new file mode 100644 index 0000000000..efc0da1fb5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewParameterProvider.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.preview.DevicePreviewData.generateDevice +import net.mullvad.mullvadvpn.lib.model.Device + +class DevicePreviewParameterProvider : PreviewParameterProvider<Device> { + override val values: Sequence<Device> = sequenceOf(generateDevice()) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt new file mode 100644 index 0000000000..c0cae0128f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemCheckableCellPreviewParameterProvider.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.preview.RelayItemPreviewData.generateRelayItemCountry +import net.mullvad.mullvadvpn.lib.model.RelayItem + +class RelayItemCheckableCellPreviewParameterProvider : + PreviewParameterProvider<List<RelayItem.Location.Country>> { + override val values = + sequenceOf( + listOf( + generateRelayItemCountry( + name = "Relay country Active", + cityNames = listOf("Relay city 1", "Relay city 2"), + relaysPerCity = 2 + ), + generateRelayItemCountry( + name = "Relay country Expanded", + cityNames = listOf("Normal city"), + relaysPerCity = 2, + expanded = true + ), + generateRelayItemCountry( + name = "Country and city Expanded", + cityNames = listOf("Expanded city A", "Expanded city B"), + relaysPerCity = 2, + expanded = true, + expandChildren = true + ) + ) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt new file mode 100644 index 0000000000..afaf81ac55 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemPreviewData.kt @@ -0,0 +1,80 @@ +package net.mullvad.mullvadvpn.compose.preview + +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.ProviderId +import net.mullvad.mullvadvpn.lib.model.RelayItem + +internal object RelayItemPreviewData { + fun generateRelayItemCountry( + name: String, + cityNames: List<String>, + relaysPerCity: Int, + active: Boolean = true, + expanded: Boolean = false, + expandChildren: Boolean = false, + ) = + RelayItem.Location.Country( + name = name, + id = name.generateCountryCode(), + cities = + cityNames.map { cityName -> + generateRelayItemCity( + cityName, + name.generateCountryCode(), + relaysPerCity, + active, + expandChildren + ) + }, + expanded = expanded, + ) +} + +private fun generateRelayItemCity( + name: String, + countryCode: GeoLocationId.Country, + numberOfRelays: Int, + active: Boolean = true, + expanded: Boolean = false, +) = + RelayItem.Location.City( + name = name, + id = name.generateCityCode(countryCode), + relays = + List(numberOfRelays) { index -> + generateRelayItemRelay( + name.generateCityCode(countryCode), + generateHostname(name.generateCityCode(countryCode), index), + active + ) + }, + expanded = expanded, + ) + +private fun generateRelayItemRelay( + cityCode: GeoLocationId.City, + hostName: String, + active: Boolean = true, +) = + RelayItem.Location.Relay( + id = + GeoLocationId.Hostname( + city = cityCode, + hostname = hostName, + ), + active = active, + provider = Provider(ProviderId("Provider"), Ownership.MullvadOwned), + ) + +private fun String.generateCountryCode() = + GeoLocationId.Country((take(1) + takeLast(1)).lowercase()) + +private fun String.generateCityCode(countryCode: GeoLocationId.Country) = + GeoLocationId.City(countryCode, take(CITY_CODE_LENGTH).lowercase()) + +private fun generateHostname(city: GeoLocationId.City, index: Int) = + "${city.countryCode.countryCode}-${city.cityCode}-wg-${index+1}" + +private const val CITY_CODE_LENGTH = 3 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt new file mode 100644 index 0000000000..26ea644185 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/RelayItemStatusCellPreviewParameterProvider.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.preview.RelayItemPreviewData.generateRelayItemCountry +import net.mullvad.mullvadvpn.lib.model.RelayItem + +class RelayItemStatusCellPreviewParameterProvider : + PreviewParameterProvider<List<RelayItem.Location.Country>> { + override val values = + sequenceOf( + listOf( + generateRelayItemCountry( + name = "Relay country Active", + cityNames = listOf("Relay city 1", "Relay city 2"), + relaysPerCity = 2 + ), + generateRelayItemCountry( + name = "Not Enabled Relay country", + cityNames = listOf("Not Enabled city"), + relaysPerCity = 1, + active = false + ), + generateRelayItemCountry( + name = "Relay country Expanded", + cityNames = listOf("Normal city"), + relaysPerCity = 2, + expanded = true + ), + generateRelayItemCountry( + name = "Country and city Expanded", + cityNames = listOf("Expanded city A", "Expanded city B"), + relaysPerCity = 2, + expanded = true, + expandChildren = true + ) + ) + ) +} 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 af6f8e992f..4d19095a6e 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 @@ -19,7 +19,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -49,12 +49,12 @@ import net.mullvad.mullvadvpn.compose.destinations.LoginDestination import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination +import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.transitions.SlideInFromBottomTransition import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle -import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus import net.mullvad.mullvadvpn.lib.payment.model.ProductId @@ -165,15 +165,15 @@ fun AccountScreen( // This will enable SECURE_FLAG while this screen is visible to preview screenshot SecureScreenWhileInView() - val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val copyTextString = stringResource(id = R.string.copied_mullvad_account_number) val copyToClipboard = createCopyToClipboardHandle(snackbarHostState = snackbarHostState) + val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() LaunchedEffectCollect(uiSideEffect) { sideEffect -> when (sideEffect) { AccountViewModel.UiSideEffect.NavigateToLogin -> navigateToLogin() is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> - context.openAccountPageInBrowser(sideEffect.token) + openAccountPage(sideEffect.token) is AccountViewModel.UiSideEffect.CopyAccountNumber -> launch { copyToClipboard(sideEffect.accountNumber, copyTextString) } } 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 fc13e053b8..94b5ef3b5b 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,7 +1,9 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Context import android.content.Intent import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -19,6 +21,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -35,6 +38,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -43,6 +47,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.NavGraphs import net.mullvad.mullvadvpn.compose.button.ConnectionButton @@ -58,6 +64,7 @@ import net.mullvad.mullvadvpn.compose.destinations.DeviceRevokedDestination 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.extensions.createOpenAccountPageHook 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 @@ -67,27 +74,30 @@ import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.compose.util.OnNavResultValue +import net.mullvad.mullvadvpn.compose.util.RequestVpnPermission +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately import net.mullvad.mullvadvpn.constant.SECURE_ZOOM import net.mullvad.mullvadvpn.constant.SECURE_ZOOM_ANIMATION_MILLIS import net.mullvad.mullvadvpn.constant.UNSECURE_ZOOM import net.mullvad.mullvadvpn.constant.fallbackLatLong -import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.map.AnimatedMap import net.mullvad.mullvadvpn.lib.map.data.GlobeColors import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors import net.mullvad.mullvadvpn.lib.map.data.Marker +import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.LatLong +import net.mullvad.mullvadvpn.lib.model.Latitude +import net.mullvad.mullvadvpn.lib.model.Longitude +import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible -import net.mullvad.mullvadvpn.model.GeoIpLocation -import net.mullvad.mullvadvpn.model.LatLong -import net.mullvad.mullvadvpn.model.Latitude -import net.mullvad.mullvadvpn.model.Longitude -import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild +import net.mullvad.mullvadvpn.util.removeHtmlTags import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import org.koin.androidx.compose.koinViewModel @@ -106,20 +116,31 @@ private fun PreviewConnectScreen() { @Destination(style = HomeTransition::class) @Composable -fun Connect(navigator: DestinationsNavigator) { +fun Connect( + navigator: DestinationsNavigator, + selectLocationResultRecipient: ResultRecipient<SelectLocationDestination, Boolean> +) { val connectViewModel: ConnectViewModel = koinViewModel() val state by connectViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + val launchVpnPermission = + rememberLauncherForActivityResult(RequestVpnPermission()) { + connectViewModel.requestVpnPermissionResult(it) + } + + val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() CollectSideEffectWithLifecycle( connectViewModel.uiSideEffect, minActiveState = Lifecycle.State.RESUMED ) { sideEffect -> when (sideEffect) { is ConnectViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> { - context.openAccountPageInBrowser(sideEffect.token) + openAccountPage(sideEffect.token) } is ConnectViewModel.UiSideEffect.OutOfTime -> navigator.navigate(OutOfTimeDestination, true) { @@ -131,11 +152,25 @@ fun Connect(navigator: DestinationsNavigator) { launchSingleTop = true popUpTo(NavGraphs.root) { inclusive = true } } + is ConnectViewModel.UiSideEffect.NoVpnPermission -> launchVpnPermission.launch(Unit) + is ConnectViewModel.UiSideEffect.ConnectError -> + launch { + snackbarHostState.showSnackbarImmediately( + message = sideEffect.toMessage(context), + ) + } + } + } + + selectLocationResultRecipient.OnNavResultValue { result -> + if (result) { + connectViewModel.onConnectClick() } } ConnectScreen( state = state, + snackbarHostState = snackbarHostState, onDisconnectClick = connectViewModel::onDisconnectClick, onReconnectClick = connectViewModel::onReconnectClick, onConnectClick = connectViewModel::onConnectClick, @@ -170,6 +205,7 @@ fun Connect(navigator: DestinationsNavigator) { @Composable fun ConnectScreen( state: ConnectUiState, + snackbarHostState: SnackbarHostState = SnackbarHostState(), onDisconnectClick: () -> Unit = {}, onReconnectClick: () -> Unit = {}, onConnectClick: () -> Unit = {}, @@ -185,12 +221,13 @@ fun ConnectScreen( val scrollState = rememberScrollState() ScaffoldWithTopBarAndDeviceName( - topBarColor = state.tunnelUiState.topBarColor(), - iconTintColor = state.tunnelUiState.iconTintColor(), + topBarColor = state.tunnelState.topBarColor(), + iconTintColor = state.tunnelState.iconTintColor(), onSettingsClicked = onSettingsClick, onAccountClicked = onAccountClick, deviceName = state.deviceName, - timeLeft = state.daysLeftUntilExpiry + timeLeft = state.daysLeftUntilExpiry, + snackbarHostState = snackbarHostState ) { var progressIndicatorBias by remember { mutableFloatStateOf(0f) } @@ -264,12 +301,12 @@ private fun MapColumn( val baseZoom = animateFloatAsState( targetValue = - if (state.tunnelRealState is TunnelState.Connected) SECURE_ZOOM else UNSECURE_ZOOM, + if (state.tunnelState is TunnelState.Connected) SECURE_ZOOM else UNSECURE_ZOOM, animationSpec = tween(SECURE_ZOOM_ANIMATION_MILLIS), label = "baseZoom" ) - val markers = state.tunnelRealState.toMarker(state.location)?.let { listOf(it) } ?: emptyList() + val markers = state.tunnelState.toMarker(state.location)?.let { listOf(it) } ?: emptyList() AnimatedMap( modifier = Modifier.padding(top = it.calculateTopPadding()), @@ -308,7 +345,7 @@ private fun MapColumn( @Composable private fun ConnectionInfo(state: ConnectUiState) { ConnectionStatusText( - state = state.tunnelRealState, + state = state.tunnelState, modifier = Modifier.padding(horizontal = Dimens.sideMargin) ) Text( @@ -365,15 +402,15 @@ private fun ButtonPanel( onClick = onSwitchLocationClick, showChevron = state.showLocation, text = - if (state.showLocation && state.selectedRelayItem != null) { - state.selectedRelayItem.locationName + if (state.showLocation && state.selectedRelayItemTitle != null) { + state.selectedRelayItemTitle } else { stringResource(id = R.string.switch_location) } ) Spacer(modifier = Modifier.height(Dimens.buttonSpacing)) ConnectionButton( - state = state.tunnelUiState, + state = state.tunnelState, modifier = Modifier.padding(horizontal = Dimens.sideMargin) .padding(bottom = Dimens.screenVerticalMargin) @@ -422,3 +459,16 @@ fun TunnelState.iconTintColor(): Color = fun GeoIpLocation.toLatLong() = LatLong(Latitude(latitude.toFloat()), Longitude(longitude.toFloat())) + +private fun ConnectViewModel.UiSideEffect.ConnectError.toMessage(context: Context): String = + when (this) { + ConnectViewModel.UiSideEffect.ConnectError.NoVpnPermission -> + context.getString(R.string.vpn_permission_denied_error) + is ConnectViewModel.UiSideEffect.ConnectError.AlwaysOnVpn -> + // Snackbar currently do not support annotated string + context + .getString(R.string.always_on_vpn_error_notification_content, appName) + .removeHtmlTags() + ConnectViewModel.UiSideEffect.ConnectError.Generic -> + context.getString(R.string.error_occurred) + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt index 3bd924a189..fc5fc62c3d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt @@ -1,7 +1,7 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Context import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -12,12 +12,16 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -27,9 +31,10 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.CheckableRelayLocationCell -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton @@ -44,10 +49,12 @@ import net.mullvad.mullvadvpn.compose.test.SAVE_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.SearchTextField import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem 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.RelayItem import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsSideEffect import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsViewModel import org.koin.androidx.compose.koinViewModel @@ -63,9 +70,9 @@ private fun PreviewCustomListLocationScreen() { @Destination(style = SlideInFromRightTransition::class) fun CustomListLocations( navigator: DestinationsNavigator, - backNavigator: ResultBackNavigator<CustomListResult.LocationsChanged>, + backNavigator: ResultBackNavigator<LocationsChanged>, discardChangesResultRecipient: ResultRecipient<DiscardChangesDialogDestination, Boolean>, - customListId: String, + customListId: CustomListId, newList: Boolean, ) { val customListsViewModel = @@ -84,17 +91,27 @@ fun CustomListLocations( } } + val snackbarHostState = remember { SnackbarHostState() } + val context: Context = LocalContext.current LaunchedEffectCollect(customListsViewModel.uiSideEffect) { sideEffect -> when (sideEffect) { is CustomListLocationsSideEffect.ReturnWithResult -> backNavigator.navigateBack(result = sideEffect.result) CustomListLocationsSideEffect.CloseScreen -> backNavigator.navigateBack() + CustomListLocationsSideEffect.Error -> + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.error_occurred), + duration = SnackbarDuration.Short + ) + } } } val state by customListsViewModel.uiState.collectAsStateWithLifecycle() CustomListLocationsScreen( state = state, + snackbarHostState = snackbarHostState, onSearchTermInput = customListsViewModel::onSearchTermInput, onSaveClick = customListsViewModel::save, onRelaySelectionClick = customListsViewModel::onRelaySelectionClick, @@ -108,16 +125,17 @@ fun CustomListLocations( ) } -@OptIn(ExperimentalFoundationApi::class) @Composable fun CustomListLocationsScreen( state: CustomListLocationsUiState, + snackbarHostState: SnackbarHostState = SnackbarHostState(), onSearchTermInput: (String) -> Unit = {}, onSaveClick: () -> Unit = {}, - onRelaySelectionClick: (RelayItem, selected: Boolean) -> Unit = { _, _ -> }, + onRelaySelectionClick: (RelayItem.Location, selected: Boolean) -> Unit = { _, _ -> }, onBackClick: () -> Unit = {} ) { ScaffoldWithSmallTopBar( + snackbarHostState = snackbarHostState, appBarTitle = stringResource( if (state.newList) { @@ -201,7 +219,7 @@ private fun LazyListScope.empty(searchTerm: String) { private fun LazyListScope.content( uiState: CustomListLocationsUiState.Content.Data, - onRelaySelectedChanged: (RelayItem, selected: Boolean) -> Unit, + onRelaySelectedChanged: (RelayItem.Location, selected: Boolean) -> Unit, ) { items( count = uiState.availableLocations.size, @@ -212,7 +230,9 @@ private fun LazyListScope.content( CheckableRelayLocationCell( relay = country, modifier = Modifier.animateContentSize(), - onRelayCheckedChange = onRelaySelectedChanged, + onRelayCheckedChange = { item, isChecked -> + onRelaySelectedChanged(item as RelayItem.Location, isChecked) + }, selectedRelays = uiState.selectedLocations, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt index 20a92132f1..b039f838a2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt @@ -30,7 +30,7 @@ import com.ramcosta.composedestinations.result.ResultRecipient import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.Deleted import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar @@ -38,15 +38,15 @@ import net.mullvad.mullvadvpn.compose.constant.ContentType import net.mullvad.mullvadvpn.compose.destinations.CreateCustomListDestination import net.mullvad.mullvadvpn.compose.destinations.EditCustomListDestination import net.mullvad.mullvadvpn.compose.extensions.itemsWithDivider -import net.mullvad.mullvadvpn.compose.extensions.showSnackbar import net.mullvad.mullvadvpn.compose.state.CustomListsUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.NEW_LIST_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomList import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.Alpha60 -import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.viewmodel.CustomListsViewModel import org.koin.androidx.compose.koinViewModel @@ -60,8 +60,7 @@ private fun PreviewCustomListsScreen() { @Destination(style = SlideInFromRightTransition::class) fun CustomLists( navigator: DestinationsNavigator, - editCustomListResultRecipient: - ResultRecipient<EditCustomListDestination, CustomListResult.Deleted> + editCustomListResultRecipient: ResultRecipient<EditCustomListDestination, Deleted> ) { val viewModel = koinViewModel<CustomListsViewModel>() val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -74,10 +73,9 @@ fun CustomLists( NavResult.Canceled -> { /* Do nothing */ } - is NavResult.Value -> { + is NavResult.Value -> scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar( + snackbarHostState.showSnackbarImmediately( message = context.getString( R.string.delete_custom_list_message, @@ -88,7 +86,6 @@ fun CustomLists( onAction = { viewModel.undoDeleteCustomList(result.value.undo) } ) } - } } } @@ -116,7 +113,7 @@ fun CustomListsScreen( state: CustomListsUiState, snackbarHostState: SnackbarHostState, addCustomList: () -> Unit = {}, - openCustomList: (RelayItem.CustomList) -> Unit = {}, + openCustomList: (CustomList) -> Unit = {}, onBackClick: () -> Unit = {} ) { ScaffoldWithMediumTopBar( @@ -169,15 +166,18 @@ private fun LazyListScope.loading() { } private fun LazyListScope.content( - customLists: List<RelayItem.CustomList>, - openCustomList: (RelayItem.CustomList) -> Unit + customLists: List<CustomList>, + openCustomList: (CustomList) -> Unit ) { itemsWithDivider( items = customLists, - key = { item: RelayItem.CustomList -> item.id }, + key = { item: CustomList -> item.id }, contentType = { ContentType.ITEM } ) { customList -> - NavigationComposeCell(title = customList.name, onClick = { openCustomList(customList) }) + NavigationComposeCell( + title = customList.name.value, + onClick = { openCustomList(customList) } + ) } } 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 e6402fc8bd..e781c12de9 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 @@ -16,21 +16,27 @@ import androidx.compose.material3.HorizontalDivider 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.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton @@ -42,126 +48,56 @@ import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar 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.preview.DeviceListPreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.DeviceItemUiState import net.mullvad.mullvadvpn.compose.state.DeviceListUiState import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition -import net.mullvad.mullvadvpn.lib.common.util.parseAsDateTime +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.model.GetDeviceListError 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.lib.theme.color.AlphaTopBar import net.mullvad.mullvadvpn.lib.theme.typeface.listItemSubText import net.mullvad.mullvadvpn.lib.theme.typeface.listItemText -import net.mullvad.mullvadvpn.model.Device import net.mullvad.mullvadvpn.util.formatDate +import net.mullvad.mullvadvpn.viewmodel.DeviceListSideEffect import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf @Composable @Preview -private fun PreviewDeviceListScreenTooManyDevices() { - AppTheme { - DeviceListScreen( - state = - DeviceListUiState( - deviceUiItems = - listOf( - DeviceListItemUiState( - device = - Device( - id = "ID1", - name = "Name1", - pubkey = ByteArray(10), - created = "2012-12-12 12:12:12 UTC" - ), - isLoading = false - ), - DeviceListItemUiState( - device = - Device( - id = "ID2", - name = "Name2", - pubkey = ByteArray(10), - created = "2012-12-12 12:12:12 UTC" - ), - isLoading = false - ), - DeviceListItemUiState( - device = - Device( - id = "ID3", - name = "Name3", - pubkey = ByteArray(10), - created = "2012-12-12 12:12:12 UTC" - ), - isLoading = false - ), - DeviceListItemUiState( - device = - Device( - id = "ID4", - name = "Name4", - pubkey = ByteArray(10), - created = "2012-12-12 12:12:12 UTC" - ), - isLoading = false - ), - DeviceListItemUiState( - device = - Device( - id = "ID5", - name = "Name5", - pubkey = ByteArray(10), - created = "2012-12-12 12:12:12 UTC" - ), - isLoading = true - ) - ), - isLoading = false - ) - ) - } +private fun PreviewDeviceListScreenContent( + @PreviewParameter(DeviceListPreviewParameterProvider::class) devices: List<DeviceItemUiState> +) { + AppTheme { DeviceListScreen(state = DeviceListUiState.Content(devices = devices)) } } @Composable @Preview -private fun PreviewDeviceListScreenNotTooManyDevices() { - AppTheme { - DeviceListScreen( - state = - DeviceListUiState( - deviceUiItems = - listOf( - DeviceListItemUiState( - device = - Device( - id = "ID", - name = "Name", - pubkey = ByteArray(10), - created = "2012-12-12 12:12:12 UTC" - ), - isLoading = false - ) - ), - isLoading = false - ) - ) - } +private fun PreviewDeviceListScreenEmpty() { + AppTheme { DeviceListScreen(state = DeviceListUiState.Content(devices = emptyList())) } } @Composable @Preview -private fun PreviewDeviceListScreenEmpty() { - AppTheme { - DeviceListScreen(state = DeviceListUiState(deviceUiItems = emptyList(), isLoading = false)) - } +private fun PreviewDeviceListLoading() { + AppTheme { DeviceListScreen(state = DeviceListUiState.Loading) } } @Composable @Preview -private fun PreviewDeviceListLoading() { +private fun PreviewDeviceListError() { AppTheme { - DeviceListScreen(state = DeviceListUiState(deviceUiItems = emptyList(), isLoading = true)) + DeviceListScreen( + state = + DeviceListUiState.Error(GetDeviceListError.Unknown(IllegalStateException("Error"))) + ) } } @@ -170,9 +106,13 @@ private fun PreviewDeviceListLoading() { fun DeviceList( navigator: DestinationsNavigator, accountToken: String, - confirmRemoveResultRecipient: ResultRecipient<RemoveDeviceConfirmationDialogDestination, String> + confirmRemoveResultRecipient: + ResultRecipient<RemoveDeviceConfirmationDialogDestination, DeviceId> ) { - val viewModel = koinViewModel<DeviceListViewModel>() + val viewModel = + koinViewModel<DeviceListViewModel>( + parameters = { parametersOf(AccountToken(accountToken)) } + ) val state by viewModel.uiState.collectAsStateWithLifecycle() confirmRemoveResultRecipient.onNavResult { @@ -181,13 +121,31 @@ fun DeviceList( /* Do nothing */ } is NavResult.Value -> { - viewModel.removeDevice(accountToken = accountToken, deviceIdToRemove = it.value) + viewModel.removeDevice(deviceIdToRemove = it.value) + } + } + } + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + CollectSideEffectWithLifecycle( + viewModel.uiSideEffect, + minActiveState = Lifecycle.State.RESUMED + ) { sideEffect -> + when (sideEffect) { + DeviceListSideEffect.FailedToRemoveDevice -> { + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.failed_to_remove_device) + ) + } } } } DeviceListScreen( state = state, + snackbarHostState = snackbarHostState, onBackClick = navigator::navigateUp, onContinueWithLogin = { navigator.navigate(LoginDestination(accountToken)) { @@ -196,6 +154,7 @@ fun DeviceList( } }, onSettingsClicked = { navigator.navigate(SettingsDestination) { launchSingleTop = true } }, + onTryAgainClicked = viewModel::fetchDevices, navigateToRemoveDeviceConfirmationDialog = { navigator.navigate(RemoveDeviceConfirmationDialogDestination(it)) { launchSingleTop = true @@ -207,9 +166,11 @@ fun DeviceList( @Composable fun DeviceListScreen( state: DeviceListUiState, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onBackClick: () -> Unit = {}, onContinueWithLogin: () -> Unit = {}, onSettingsClicked: () -> Unit = {}, + onTryAgainClicked: () -> Unit = {}, navigateToRemoveDeviceConfirmationDialog: (device: Device) -> Unit = {} ) { @@ -218,6 +179,7 @@ fun DeviceListScreen( iconTintColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked = onSettingsClicked, onAccountClicked = null, + snackbarHostState = snackbarHostState ) { Column( modifier = Modifier.fillMaxSize().padding(it), @@ -232,21 +194,17 @@ fun DeviceListScreen( .verticalScroll(scrollState) .weight(1f) .fillMaxWidth(), - verticalArrangement = - if (state.isLoading) { - Arrangement.Center - } else { - Arrangement.Top - } ) { - if (state.isLoading) { - DeviceListLoading() - } else { - DeviceListContent( - state = state, - navigateToRemoveDeviceConfirmationDialog = - navigateToRemoveDeviceConfirmationDialog - ) + DeviceListHeader(state) + when (state) { + is DeviceListUiState.Content -> + DeviceListContent( + state, + navigateToRemoveDeviceConfirmationDialog = + navigateToRemoveDeviceConfirmationDialog + ) + is DeviceListUiState.Error -> DeviceListError(onTryAgainClicked) + DeviceListUiState.Loading -> {} } } DeviceListButtonPanel(state, onContinueWithLogin, onBackClick) @@ -255,26 +213,38 @@ fun DeviceListScreen( } @Composable -private fun ColumnScope.DeviceListLoading() { - MullvadCircularProgressIndicatorLarge( - modifier = Modifier.padding(Dimens.smallPadding).align(Alignment.CenterHorizontally) - ) +private fun ColumnScope.DeviceListError(tryAgain: () -> Unit) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text( + text = stringResource(id = R.string.failed_to_fetch_devices), + modifier = Modifier.padding(Dimens.smallPadding).align(Alignment.CenterHorizontally) + ) + PrimaryButton( + onClick = tryAgain, + text = stringResource(id = R.string.try_again), + modifier = + Modifier.padding( + top = Dimens.buttonSpacing, + start = Dimens.sideMargin, + end = Dimens.sideMargin + ) + ) + } } @Composable private fun ColumnScope.DeviceListContent( - state: DeviceListUiState, + state: DeviceListUiState.Content, navigateToRemoveDeviceConfirmationDialog: (Device) -> Unit ) { - DeviceListHeader(state = state) - - state.deviceUiItems.forEachIndexed { index, deviceUiState -> + state.devices.forEachIndexed { index, (device, loading) -> DeviceListItem( - deviceUiState = deviceUiState, + device = device, + isLoading = loading, ) { - navigateToRemoveDeviceConfirmationDialog(deviceUiState.device) + navigateToRemoveDeviceConfirmationDialog(device) } - if (state.deviceUiItems.lastIndex != index) { + if (state.devices.lastIndex != index) { HorizontalDivider() } } @@ -282,31 +252,49 @@ private fun ColumnScope.DeviceListContent( @Composable private fun ColumnScope.DeviceListHeader(state: DeviceListUiState) { - Image( - painter = - painterResource( - id = - if (state.hasTooManyDevices) { - R.drawable.icon_fail - } else { - R.drawable.icon_success - } - ), - contentDescription = null, // No meaningful user info or action. - modifier = - Modifier.align(Alignment.CenterHorizontally) - .padding(top = Dimens.iconFailSuccessTopMargin) - .size(Dimens.bigIconSize) - ) + when (state) { + is DeviceListUiState.Content -> + Image( + painter = + painterResource( + id = + if (state.hasTooManyDevices) { + R.drawable.icon_fail + } else { + R.drawable.icon_success + } + ), + contentDescription = null, // No meaningful user info or action. + modifier = + Modifier.align(Alignment.CenterHorizontally) + .padding(top = Dimens.iconFailSuccessTopMargin) + .size(Dimens.bigIconSize) + ) + is DeviceListUiState.Error -> + Image( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = null, // No meaningful user info or action. + modifier = + Modifier.align(Alignment.CenterHorizontally) + .padding(top = Dimens.iconFailSuccessTopMargin) + .size(Dimens.bigIconSize) + ) + DeviceListUiState.Loading -> + MullvadCircularProgressIndicatorLarge( + modifier = + Modifier.align(Alignment.CenterHorizontally) + .padding(top = Dimens.iconFailSuccessTopMargin) + ) + } Text( text = stringResource( id = - if (state.hasTooManyDevices) { - R.string.max_devices_warning_title - } else { + if (state is DeviceListUiState.Content && !state.hasTooManyDevices) { R.string.max_devices_resolved_title + } else { + R.string.max_devices_warning_title } ), style = MaterialTheme.typography.headlineSmall, @@ -319,51 +307,48 @@ private fun ColumnScope.DeviceListHeader(state: DeviceListUiState) { ), ) - Text( - text = - stringResource( - id = - if (state.hasTooManyDevices) { - R.string.max_devices_warning_description - } else { - R.string.max_devices_resolved_description - } - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground, - modifier = - Modifier.wrapContentHeight() - .animateContentSize() - .padding( - top = Dimens.smallPadding, - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.spacingAboveButton - ) - ) + if (state is DeviceListUiState.Content) { + Text( + text = + stringResource( + id = + if (state.hasTooManyDevices) { + R.string.max_devices_warning_description + } else { + R.string.max_devices_resolved_description + } + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = + Modifier.wrapContentHeight() + .animateContentSize() + .padding( + top = Dimens.smallPadding, + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.spacingAboveButton + ) + ) + } } @Composable -private fun DeviceListItem( - deviceUiState: DeviceListItemUiState, - onDeviceRemovalClicked: () -> Unit -) { +private fun DeviceListItem(device: Device, isLoading: Boolean, onDeviceRemovalClicked: () -> Unit) { BaseCell( isRowEnabled = false, headlineContent = { Column(modifier = Modifier.weight(1f)) { Text( modifier = Modifier.fillMaxWidth(), - text = deviceUiState.device.displayName(), + text = device.displayName(), style = MaterialTheme.typography.listItemText, color = MaterialTheme.colorScheme.onPrimary ) Text( modifier = Modifier.fillMaxWidth(), text = - deviceUiState.device.created.parseAsDateTime()?.let { creationDate -> - stringResource(id = R.string.created_x, creationDate.formatDate()) - } ?: "", + stringResource(id = R.string.created_x, device.creationDate.formatDate()), style = MaterialTheme.typography.listItemSubText, color = MaterialTheme.colorScheme.onPrimary @@ -373,7 +358,7 @@ private fun DeviceListItem( } }, bodyView = { - if (deviceUiState.isLoading) { + if (isLoading) { MullvadCircularProgressIndicatorMedium( modifier = Modifier.padding(Dimens.smallPadding) ) @@ -410,7 +395,7 @@ private fun DeviceListButtonPanel( VariantButton( text = stringResource(id = R.string.continue_login), onClick = onContinueWithLogin, - isEnabled = state.hasTooManyDevices.not() && state.isLoading.not(), + isEnabled = state is DeviceListUiState.Content && !state.hasTooManyDevices, background = MaterialTheme.colorScheme.secondary ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt index 9186e639c5..0deadd545c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt @@ -29,7 +29,7 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.TwoRowCell -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.Deleted import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar @@ -42,11 +42,11 @@ import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.DELETE_DROPDOWN_MENU_ITEM_TEST_TAG import net.mullvad.mullvadvpn.compose.test.TOP_BAR_DROPDOWN_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -58,21 +58,16 @@ private fun PreviewEditCustomListScreen() { EditCustomListScreen( state = EditCustomListState.Content( - id = "id", - name = "Custom list", + id = CustomListId("id"), + name = CustomListName.fromString("Custom list"), locations = listOf( - RelayItem.Relay( - "Relay", - "Relay", - true, - GeographicLocationConstraint.Hostname( - "hostname", - "hostname", - "hostname" + GeoLocationId.Hostname( + GeoLocationId.City( + GeoLocationId.Country("country"), + cityCode = "city" ), - "Provider", - Ownership.MullvadOwned + "hostname", ) ) ) @@ -84,10 +79,9 @@ private fun PreviewEditCustomListScreen() { @Destination(style = SlideInFromRightTransition::class) fun EditCustomList( navigator: DestinationsNavigator, - backNavigator: ResultBackNavigator<CustomListResult.Deleted>, - customListId: String, - confirmDeleteListResultRecipient: - ResultRecipient<DeleteCustomListDestination, CustomListResult.Deleted> + backNavigator: ResultBackNavigator<Deleted>, + customListId: CustomListId, + confirmDeleteListResultRecipient: ResultRecipient<DeleteCustomListDestination, Deleted> ) { val viewModel = koinViewModel<EditCustomListViewModel>(parameters = { parametersOf(customListId) }) @@ -130,21 +124,21 @@ fun EditCustomList( @Composable fun EditCustomListScreen( state: EditCustomListState, - onDeleteList: (name: String) -> Unit = {}, - onNameClicked: (id: String, name: String) -> Unit = { _, _ -> }, - onLocationsClicked: (String) -> Unit = {}, + onDeleteList: (name: CustomListName) -> Unit = {}, + onNameClicked: (id: CustomListId, name: CustomListName) -> Unit = { _, _ -> }, + onLocationsClicked: (CustomListId) -> Unit = {}, onBackClick: () -> Unit = {} ) { val title = when (state) { EditCustomListState.Loading, - EditCustomListState.NotFound -> "" + EditCustomListState.NotFound -> null is EditCustomListState.Content -> state.name } ScaffoldWithMediumTopBar( appBarTitle = stringResource(id = R.string.edit_list), navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) }, - actions = { Actions(onDeleteList = { onDeleteList(title) }) }, + actions = { Actions(enabled = title != null, onDeleteList = { onDeleteList(title!!) }) }, ) { modifier: Modifier -> SpacedColumn(modifier = modifier, alignment = Alignment.Top) { when (state) { @@ -165,7 +159,7 @@ fun EditCustomListScreen( // Name cell TwoRowCell( titleText = stringResource(id = R.string.list_name), - subtitleText = state.name, + subtitleText = state.name.value, onCellClicked = { onNameClicked(state.id, state.name) } ) // Locations cell @@ -186,7 +180,7 @@ fun EditCustomListScreen( } @Composable -private fun Actions(onDeleteList: () -> Unit) { +private fun Actions(enabled: Boolean, onDeleteList: () -> Unit) { var showMenu by remember { mutableStateOf(false) } IconButton( onClick = { showMenu = true }, @@ -217,6 +211,7 @@ private fun Actions(onDeleteList: () -> Unit) { onDeleteList() showMenu = false }, + enabled = enabled, modifier = Modifier.testTag(DELETE_DROPDOWN_MENU_ITEM_TEST_TAG) ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt index bcd42d7c0c..f58b28eaca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt @@ -39,10 +39,10 @@ import net.mullvad.mullvadvpn.compose.extensions.itemsWithDivider import net.mullvad.mullvadvpn.compose.state.RelayFilterState import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.relaylist.Provider import net.mullvad.mullvadvpn.viewmodel.FilterScreenSideEffect import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import org.koin.androidx.compose.koinViewModel @@ -151,7 +151,7 @@ fun FilterScreen( Ownership(ownership, state, onSelectedOwnership) } } - itemWithDivider() { ProvidersHeader(providerExpanded) { providerExpanded = it } } + itemWithDivider { ProvidersHeader(providerExpanded) { providerExpanded = it } } if (providerExpanded) { itemWithDivider { AllProviders(state, onAllProviderCheckChange) } itemsWithDivider(state.filteredProvidersByOwnership) { provider -> @@ -215,7 +215,7 @@ private fun AllProviders( onAllProviderCheckChange: (isChecked: Boolean) -> Unit ) { CheckboxCell( - providerName = stringResource(R.string.all_providers), + title = stringResource(R.string.all_providers), checked = state.isAllProvidersChecked, onCheckedChange = { isChecked -> onAllProviderCheckChange(isChecked) } ) @@ -228,7 +228,7 @@ private fun Provider( onSelectedProvider: (checked: Boolean, provider: Provider) -> Unit ) { CheckboxCell( - providerName = provider.name, + title = provider.providerId.value, checked = provider in state.selectedProviders, onCheckedChange = { checked -> onSelectedProvider(checked, provider) } ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt index 508fcf67f3..e2ee4cc240 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -21,9 +22,12 @@ import net.mullvad.mullvadvpn.compose.destinations.ConnectDestination import net.mullvad.mullvadvpn.compose.destinations.NoDaemonScreenDestination import net.mullvad.mullvadvpn.compose.destinations.OutOfTimeDestination import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.compose.util.RequestVpnPermission import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.DaemonScreenEvent import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel +import net.mullvad.mullvadvpn.viewmodel.VpnPermissionSideEffect +import net.mullvad.mullvadvpn.viewmodel.VpnPermissionViewModel import org.koin.androidx.compose.koinViewModel private val changeLogDestinations = listOf(ConnectDestination, OutOfTimeDestination) @@ -35,6 +39,7 @@ fun MullvadApp() { val navController: NavHostController = engine.rememberNavController() val serviceVm = koinViewModel<NoDaemonViewModel>() + val permissionVm = koinViewModel<VpnPermissionViewModel>() DisposableEffect(Unit) { navController.addOnDestinationChangedListener(serviceVm) @@ -45,7 +50,7 @@ fun MullvadApp() { modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(), engine = engine, navController = navController, - navGraph = NavGraphs.root + navGraph = NavGraphs.root, ) // Globally handle daemon dropped connection with NoDaemonScreen @@ -68,4 +73,13 @@ fun MullvadApp() { navController.navigate(ChangelogDestination(it).route) } + + // Ask for VPN Permission + val launchVpnPermission = + rememberLauncherForActivityResult(RequestVpnPermission()) { _ -> permissionVm.connect() } + LaunchedEffectCollect(permissionVm.uiSideEffect) { + if (it is VpnPermissionSideEffect.ShowDialog) { + launchVpnPermission.launch(Unit) + } + } } 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 c5c99c62f5..d557558a60 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 @@ -47,16 +47,16 @@ import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.compose.test.OUT_OF_TIME_SCREEN_TITLE_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.ErrorState +import net.mullvad.mullvadvpn.lib.model.ErrorStateCause +import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.payment.model.ProductId 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.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 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 387170e8e0..7ae7a464fc 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 @@ -94,7 +94,7 @@ fun PrivacyDisclaimer( launch { try { withTimeout(DAEMON_READY_TIMEOUT_MS) { - (context as MainActivity).startServiceSuspend() + (context as MainActivity).bindService() } viewModel.onServiceStartedSuccessful() } catch (e: CancellationException) { 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 4476b8064a..7d861ea717 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 @@ -49,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultRecipient import com.ramcosta.composedestinations.spec.DestinationSpec import kotlinx.coroutines.launch @@ -59,8 +60,12 @@ import net.mullvad.mullvadvpn.compose.cell.IconCell import net.mullvad.mullvadvpn.compose.cell.StatusRelayLocationCell import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell +import net.mullvad.mullvadvpn.compose.communication.Created import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.CustomListSuccess +import net.mullvad.mullvadvpn.compose.communication.Deleted +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged +import net.mullvad.mullvadvpn.compose.communication.Renamed import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet @@ -73,7 +78,6 @@ import net.mullvad.mullvadvpn.compose.destinations.CustomListsDestination import net.mullvad.mullvadvpn.compose.destinations.DeleteCustomListDestination import net.mullvad.mullvadvpn.compose.destinations.EditCustomListNameDestination import net.mullvad.mullvadvpn.compose.destinations.FilterScreenDestination -import net.mullvad.mullvadvpn.compose.extensions.showSnackbar import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG @@ -83,12 +87,16 @@ import net.mullvad.mullvadvpn.compose.textfield.SearchTextField import net.mullvad.mullvadvpn.compose.transitions.SelectLocationTransition import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible -import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.relaylist.canAddLocation import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel @@ -102,7 +110,15 @@ private fun PreviewSelectLocationScreen() { searchTerm = "", selectedOwnership = null, selectedProvidersCount = 0, - countries = listOf(RelayItem.Country("Country 1", "Code 1", false, emptyList())), + countries = + listOf( + RelayItem.Location.Country( + GeoLocationId.Country("Country 1"), + "Code 1", + false, + emptyList() + ) + ), selectedItem = null, customLists = emptyList(), filteredCustomLists = emptyList() @@ -115,17 +131,17 @@ private fun PreviewSelectLocationScreen() { } @Destination(style = SelectLocationTransition::class) +@Suppress("LongMethod") @Composable fun SelectLocation( navigator: DestinationsNavigator, - createCustomListDialogResultRecipient: - ResultRecipient<CreateCustomListDestination, CustomListResult.Created>, + backNavigator: ResultBackNavigator<Boolean>, + createCustomListDialogResultRecipient: ResultRecipient<CreateCustomListDestination, Created>, editCustomListNameDialogResultRecipient: - ResultRecipient<EditCustomListNameDestination, CustomListResult.Renamed>, - deleteCustomListDialogResultRecipient: - ResultRecipient<DeleteCustomListDestination, CustomListResult.Deleted>, + ResultRecipient<EditCustomListNameDestination, Renamed>, + deleteCustomListDialogResultRecipient: ResultRecipient<DeleteCustomListDestination, Deleted>, updateCustomListResultRecipient: - ResultRecipient<CustomListLocationsDestination, CustomListResult.LocationsChanged> + ResultRecipient<CustomListLocationsDestination, LocationsChanged> ) { val vm = koinViewModel<SelectLocationViewModel>() val state = vm.uiState.collectAsStateWithLifecycle().value @@ -135,7 +151,7 @@ fun SelectLocation( LaunchedEffectCollect(vm.uiSideEffect) { when (it) { - SelectLocationSideEffect.CloseScreen -> navigator.navigateUp() + SelectLocationSideEffect.CloseScreen -> backNavigator.navigateBack(result = true, true) is SelectLocationSideEffect.LocationAddedToCustomList -> launch { snackbarHostState.showResultSnackbar( @@ -152,6 +168,13 @@ fun SelectLocation( onUndo = vm::performAction ) } + SelectLocationSideEffect.GenericError -> + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.error_occurred), + duration = SnackbarDuration.Short + ) + } } } @@ -177,10 +200,10 @@ fun SelectLocation( snackbarHostState = snackbarHostState, onSelectRelay = vm::selectRelay, onSearchTermInput = vm::onSearchTermInput, - onBackClick = navigator::navigateUp, + onBackClick = { backNavigator.navigateBack(true) }, onFilterClick = { navigator.navigate(FilterScreenDestination, true) }, onCreateCustomList = { relayItem -> - navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.code ?: "")) { + navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.id)) { launchSingleTop = true } }, @@ -191,7 +214,7 @@ fun SelectLocation( onRemoveLocationFromList = vm::removeLocationFromList, onEditCustomListName = { navigator.navigate( - EditCustomListNameDestination(customListId = it.id, initialName = it.name) + EditCustomListNameDestination(customListId = it.id, initialName = it.customListName) ) }, onEditLocationsCustomList = { @@ -200,7 +223,9 @@ fun SelectLocation( ) }, onDeleteCustomList = { - navigator.navigate(DeleteCustomListDestination(customListId = it.id, name = it.name)) + navigator.navigate( + DeleteCustomListDestination(customListId = it.id, name = it.customListName) + ) } ) } @@ -215,13 +240,15 @@ fun SelectLocationScreen( onSearchTermInput: (searchTerm: String) -> Unit = {}, onBackClick: () -> Unit = {}, onFilterClick: () -> Unit = {}, - onCreateCustomList: (location: RelayItem?) -> Unit = {}, + onCreateCustomList: (location: RelayItem.Location?) -> Unit = {}, onEditCustomLists: () -> Unit = {}, removeOwnershipFilter: () -> Unit = {}, removeProviderFilter: () -> Unit = {}, - onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit = { _, _ -> - }, - onRemoveLocationFromList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit = + onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = + { _, _ -> + }, + onRemoveLocationFromList: + (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit = { _, _ -> }, onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, @@ -252,30 +279,7 @@ fun SelectLocationScreen( ) Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) { - Row(modifier = Modifier.fillMaxWidth()) { - IconButton(onClick = onBackClick) { - Icon( - modifier = Modifier.rotate(270f), - painter = painterResource(id = R.drawable.icon_back), - tint = Color.Unspecified, - contentDescription = null, - ) - } - Text( - text = stringResource(id = R.string.select_location), - modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimary, - ) - IconButton(onClick = onFilterClick) { - Icon( - painter = painterResource(id = R.drawable.icons_more_circle), - contentDescription = null, - tint = Color.Unspecified, - ) - } - } + SelectLocationTopBar(onBackClick = onBackClick, onFilterClick = onFilterClick) when (state) { SelectLocationUiState.Loading -> {} @@ -303,8 +307,7 @@ fun SelectLocationScreen( } Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) val lazyListState = rememberLazyListState() - val selectedItemCode = - (state as? SelectLocationUiState.Content)?.selectedItem?.code ?: "" + val selectedItemCode = (state as? SelectLocationUiState.Content)?.selectedItem ?: "" RunOnKeyChange(key = selectedItemCode) { val index = state.indexOfSelectedRelayItem() @@ -345,7 +348,7 @@ fun SelectLocationScreen( BottomSheetState.ShowEditCustomListBottomSheet(customList) }, onShowEditCustomListEntryBottomSheet = { - item: RelayItem, + item: RelayItem.Location, customList: RelayItem.CustomList -> bottomSheetState = BottomSheetState.ShowCustomListsEntryBottomSheet( @@ -387,6 +390,34 @@ fun SelectLocationScreen( } } +@Composable +private fun SelectLocationTopBar(onBackClick: () -> Unit, onFilterClick: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth()) { + IconButton(onClick = onBackClick) { + Icon( + modifier = Modifier.rotate(270f), + painter = painterResource(id = R.drawable.icon_back), + tint = Color.Unspecified, + contentDescription = null, + ) + } + Text( + text = stringResource(id = R.string.select_location), + modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimary, + ) + IconButton(onClick = onFilterClick) { + Icon( + painter = painterResource(id = R.drawable.icons_more_circle), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } +} + private fun LazyListScope.loading() { item(contentType = ContentType.PROGRESS) { MullvadCircularProgressIndicatorLarge(Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)) @@ -396,12 +427,12 @@ private fun LazyListScope.loading() { @OptIn(ExperimentalFoundationApi::class) private fun LazyListScope.customLists( customLists: List<RelayItem.CustomList>, - selectedItem: RelayItem?, + selectedItem: RelayItemId?, backgroundColor: Color, onSelectRelay: (item: RelayItem) -> Unit, onShowCustomListBottomSheet: () -> Unit, onShowEditBottomSheet: (RelayItem.CustomList) -> Unit, - onShowEditCustomListEntryBottomSheet: (item: RelayItem, RelayItem.CustomList) -> Unit + onShowEditCustomListEntryBottomSheet: (item: RelayItem.Location, RelayItem.CustomList) -> Unit ) { item( contentType = { ContentType.HEADER }, @@ -418,18 +449,18 @@ private fun LazyListScope.customLists( if (customLists.isNotEmpty()) { items( items = customLists, - key = { item -> item.code }, + key = { item -> item.id }, contentType = { ContentType.ITEM }, ) { customList -> StatusRelayLocationCell( relay = customList, // Do not show selection for locations in custom lists - selectedItem = selectedItem as? RelayItem.CustomList, + selectedItem = selectedItem as? CustomListId, onSelectRelay = onSelectRelay, onLongClick = { if (it is RelayItem.CustomList) { onShowEditBottomSheet(it) - } else if (it in customList.locations) { + } else if (it is RelayItem.Location && it in customList.locations) { onShowEditCustomListEntryBottomSheet(it, customList) } }, @@ -456,10 +487,10 @@ private fun LazyListScope.customLists( @OptIn(ExperimentalFoundationApi::class) private fun LazyListScope.relayList( - countries: List<RelayItem.Country>, - selectedItem: RelayItem?, + countries: List<RelayItem.Location.Country>, + selectedItem: RelayItemId?, onSelectRelay: (item: RelayItem) -> Unit, - onShowLocationBottomSheet: (item: RelayItem) -> Unit, + onShowLocationBottomSheet: (item: RelayItem.Location) -> Unit, ) { item( contentType = ContentType.HEADER, @@ -471,14 +502,14 @@ private fun LazyListScope.relayList( } items( items = countries, - key = { item -> item.code }, + key = { item -> item.id }, contentType = { ContentType.ITEM }, ) { country -> StatusRelayLocationCell( relay = country, selectedItem = selectedItem, onSelectRelay = onSelectRelay, - onLongClick = onShowLocationBottomSheet, + onLongClick = { onShowLocationBottomSheet(it as RelayItem.Location) }, modifier = Modifier.animateContentSize().animateItemPlacement(), ) } @@ -488,10 +519,10 @@ private fun LazyListScope.relayList( @Composable private fun BottomSheets( bottomSheetState: BottomSheetState?, - onCreateCustomList: (RelayItem?) -> Unit, + onCreateCustomList: (RelayItem.Location?) -> Unit, onEditCustomLists: () -> Unit, - onAddLocationToList: (RelayItem, RelayItem.CustomList) -> Unit, - onRemoveLocationFromList: (RelayItem, RelayItem.CustomList) -> Unit, + onAddLocationToList: (RelayItem.Location, RelayItem.CustomList) -> Unit, + onRemoveLocationFromList: (RelayItem.Location, RelayItem.CustomList) -> Unit, onEditCustomListName: (RelayItem.CustomList) -> Unit, onEditLocationsCustomList: (RelayItem.CustomList) -> Unit, onDeleteCustomList: (RelayItem.CustomList) -> Unit, @@ -566,30 +597,18 @@ private fun BottomSheets( private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int = if (this is SelectLocationUiState.Content) { when (selectedItem) { - is RelayItem.Country, - is RelayItem.City, - is RelayItem.Relay -> - countries.indexOfFirst { it.code == selectedItem.countryCode() } + + is CustomListId -> + filteredCustomLists.indexOfFirst { it.id == selectedItem } + EXTRA_ITEM_CUSTOM_LIST + is GeoLocationId -> + countries.indexOfFirst { it.id == selectedItem.country } + customLists.size + EXTRA_ITEMS_LOCATION - is RelayItem.CustomList -> - filteredCustomLists.indexOfFirst { it.id == selectedItem.id } + - EXTRA_ITEM_CUSTOM_LIST else -> -1 } } else { -1 } -private fun RelayItem.countryCode(): String = - when (this) { - is RelayItem.Country -> this.code - is RelayItem.City -> this.location.countryCode - is RelayItem.Relay -> this.location.countryCode - is RelayItem.CustomList -> - throw IllegalArgumentException("Custom list does not have a country code") - } - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CustomListsBottomSheet( @@ -604,7 +623,7 @@ private fun CustomListsBottomSheet( sheetState = sheetState, onDismissRequest = { closeBottomSheet(false) }, modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG) - ) { -> + ) { HeaderCell( text = stringResource(id = R.string.edit_custom_lists), background = Color.Unspecified @@ -648,9 +667,9 @@ private fun LocationBottomSheet( onBackgroundColor: Color, sheetState: SheetState, customLists: List<RelayItem.CustomList>, - item: RelayItem, - onCreateCustomList: (relayItem: RelayItem) -> Unit, - onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit, + item: RelayItem.Location, + onCreateCustomList: (relayItem: RelayItem.Location) -> Unit, + onAddLocationToList: (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, closeBottomSheet: (animate: Boolean) -> Unit ) { MullvadModalBottomSheet( @@ -756,15 +775,16 @@ private fun CustomListEntryBottomSheet( onBackgroundColor: Color, sheetState: SheetState, customList: RelayItem.CustomList, - item: RelayItem, - onRemoveLocationFromList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit, + item: RelayItem.Location, + onRemoveLocationFromList: + (location: RelayItem.Location, customList: RelayItem.CustomList) -> Unit, closeBottomSheet: (animate: Boolean) -> Unit ) { MullvadModalBottomSheet( sheetState = sheetState, onDismissRequest = { closeBottomSheet(false) }, modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG) - ) { -> + ) { HeaderCell( text = stringResource(id = R.string.remove_location_from_list, item.name), background = Color.Unspecified @@ -797,11 +817,10 @@ private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { private suspend fun SnackbarHostState.showResultSnackbar( context: Context, - result: CustomListResult, + result: CustomListSuccess, onUndo: (CustomListAction) -> Unit ) { - currentSnackbarData?.dismiss() - showSnackbar( + showSnackbarImmediately( message = result.message(context), actionLabel = context.getString(R.string.undo), duration = SnackbarDuration.Long, @@ -809,18 +828,19 @@ private suspend fun SnackbarHostState.showResultSnackbar( ) } -private fun CustomListResult.message(context: Context): String = +private fun CustomListSuccess.message(context: Context): String = when (this) { - is CustomListResult.Created -> - context.getString(R.string.location_was_added_to_list, locationName, name) - is CustomListResult.Deleted -> context.getString(R.string.delete_custom_list_message, name) - is CustomListResult.Renamed -> context.getString(R.string.name_was_changed_to, name) - is CustomListResult.LocationsChanged -> - context.getString(R.string.locations_were_changed_for, name) + is Created -> + locationNames.firstOrNull()?.let { locationName -> + context.getString(R.string.location_was_added_to_list, locationName, name) + } ?: context.getString(R.string.locations_were_changed_for, name) + is Deleted -> context.getString(R.string.delete_custom_list_message, name) + is Renamed -> context.getString(R.string.name_was_changed_to, name) + is LocationsChanged -> context.getString(R.string.locations_were_changed_for, name) } @Composable -private fun <D : DestinationSpec<*>, R : CustomListResult> ResultRecipient<D, R> +private fun <D : DestinationSpec<*>, R : CustomListSuccess> ResultRecipient<D, R> .OnCustomListNavResult( snackbarHostState: SnackbarHostState, performAction: (action: CustomListAction) -> Unit @@ -856,12 +876,12 @@ sealed interface BottomSheetState { data class ShowCustomListsEntryBottomSheet( val customList: RelayItem.CustomList, - val item: RelayItem + val item: RelayItem.Location ) : BottomSheetState data class ShowLocationBottomSheet( val customLists: List<RelayItem.CustomList>, - val item: RelayItem + val item: RelayItem.Location ) : BottomSheetState data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) : diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt index 33b8419b9c..7f9542f22a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ServerIpOverridesScreen.kt @@ -67,10 +67,10 @@ import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightLeafTransition import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.compose.util.OnNavResultValue import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.SettingsPatchError import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled -import net.mullvad.mullvadvpn.model.SettingsPatchError import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesUiSideEffect import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewModel import net.mullvad.mullvadvpn.viewmodel.ServerIpOverridesViewState @@ -107,11 +107,12 @@ fun ServerIpOverrides( LaunchedEffectCollect(vm.uiSideEffect) { sideEffect -> when (sideEffect) { is ServerIpOverridesUiSideEffect.ImportResult -> - snackbarHostState.showSnackbarImmediately( - this, - message = sideEffect.error.toString(context), - actionLabel = null - ) + launch { + snackbarHostState.showSnackbarImmediately( + message = sideEffect.error.toString(context), + actionLabel = null + ) + } } } @@ -119,11 +120,15 @@ fun ServerIpOverrides( // On successful clear of overrides, show snackbar val scope = rememberCoroutineScope() - clearOverridesResult.OnNavResultValue { + clearOverridesResult.OnNavResultValue { clearSuccessful -> scope.launch { snackbarHostState.showSnackbarImmediately( - this, - message = context.getString(R.string.overrides_cleared), + message = + if (clearSuccessful) { + context.getString(R.string.overrides_cleared) + } else { + context.getString(R.string.error_occurred) + }, actionLabel = null ) } @@ -233,7 +238,7 @@ private fun ImportOverridesByBottomSheet( MullvadModalBottomSheet( sheetState = sheetState, onDismissRequest = { showBottomSheet(false) }, - ) { -> + ) { HeaderCell( text = stringResource(id = R.string.server_ip_overrides_import_by), background = Color.Unspecified 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 c355eb6405..a9b7873a2f 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 @@ -62,24 +62,24 @@ private fun PreviewSplitTunnelingScreen() { AppData( packageName = "my.package.a", name = "TitleA", - iconRes = R.drawable.icon_alert + iconRes = R.drawable.icon_alert, ), AppData( packageName = "my.package.b", name = "TitleB", - iconRes = R.drawable.icon_chevron - ) + iconRes = R.drawable.icon_chevron, + ), ), includedApps = listOf( AppData( packageName = "my.package.c", name = "TitleC", - iconRes = R.drawable.icon_alert - ) + iconRes = R.drawable.icon_alert, + ), ), - showSystemApps = true - ) + showSystemApps = true, + ), ) } } @@ -91,6 +91,7 @@ fun SplitTunneling(navigator: DestinationsNavigator) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val packageManager = remember(context) { context.packageManager } + SplitTunnelingScreen( state = state, onEnableSplitTunneling = viewModel::onEnableSplitTunneling, @@ -100,7 +101,7 @@ fun SplitTunneling(navigator: DestinationsNavigator) { onBackClick = navigator::navigateUp, onResolveIcon = { packageName -> packageManager.getApplicationIconBitmapOrNull(packageName) - } + }, ) } @@ -119,12 +120,12 @@ fun SplitTunnelingScreen( ScaffoldWithMediumTopBar( modifier = Modifier.fillMaxSize(), appBarTitle = stringResource(id = R.string.split_tunneling), - navigationIcon = { NavigateBackIconButton(onBackClick) } + navigationIcon = { NavigateBackIconButton(onBackClick) }, ) { modifier, lazyListState -> LazyColumn( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, - state = lazyListState + state = lazyListState, ) { description() enabledToggle(enabled = state.enabled, onEnableSplitTunneling = onEnableSplitTunneling) @@ -140,7 +141,7 @@ fun SplitTunnelingScreen( onShowSystemAppsClick = onShowSystemAppsClick, onExcludeAppClick = onExcludeAppClick, onIncludeAppClick = onIncludeAppClick, - onResolveIcon = onResolveIcon + onResolveIcon = onResolveIcon, ) } } @@ -156,7 +157,7 @@ private fun LazyListScope.enabledToggle( HeaderSwitchComposeCell( title = textResource(id = R.string.enable), isToggled = enabled, - onCellClicked = onEnableSplitTunneling + onCellClicked = onEnableSplitTunneling, ) } } @@ -168,7 +169,7 @@ private fun LazyListScope.description() { buildString { appendLine(stringResource(id = R.string.split_tunneling_description)) append(stringResource(id = R.string.split_tunneling_description_warning)) - } + }, ) } } @@ -191,7 +192,7 @@ private fun LazyListScope.appList( headerItem( key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS, textId = R.string.exclude_applications, - enabled = state.enabled + enabled = state.enabled, ) appItems( apps = state.excludedApps, @@ -199,19 +200,19 @@ private fun LazyListScope.appList( onAppClick = onIncludeAppClick, onResolveIcon = onResolveIcon, enabled = state.enabled, - excluded = true + excluded = true, ) spacer() } systemAppsToggle( showSystemApps = state.showSystemApps, onShowSystemAppsClick = onShowSystemAppsClick, - enabled = state.enabled + enabled = state.enabled, ) headerItem( key = SplitTunnelingContentKey.INCLUDED_APPLICATIONS, textId = R.string.all_applications, - enabled = state.enabled + enabled = state.enabled, ) appItems( apps = state.includedApps, @@ -219,7 +220,7 @@ private fun LazyListScope.appList( onAppClick = onExcludeAppClick, onResolveIcon = onResolveIcon, enabled = state.enabled, - excluded = false + excluded = false, ) } @@ -235,7 +236,7 @@ private fun LazyListScope.appItems( itemsIndexedWithDivider( items = apps, key = { _, listItem -> listItem.packageName }, - contentType = { _, _ -> ContentType.ITEM } + contentType = { _, _ -> ContentType.ITEM }, ) { index, listItem -> SplitTunnelingCell( title = listItem.name, @@ -250,9 +251,9 @@ private fun LazyListScope.appItems( AlphaVisible } else { AlphaDisabled - } + }, ), - onResolveIcon = onResolveIcon + onResolveIcon = onResolveIcon, ) { // Move focus down unless the clicked item was the last in this // section. @@ -278,10 +279,10 @@ private fun LazyListScope.headerItem(key: String, textId: Int, enabled: Boolean) AlphaVisible } else { AlphaDisabled - } + }, ), text = stringResource(id = textId), - background = MaterialTheme.colorScheme.primary + background = MaterialTheme.colorScheme.primary, ) } } @@ -294,7 +295,7 @@ private fun LazyListScope.systemAppsToggle( ) { itemWithDivider( key = SplitTunnelingContentKey.SHOW_SYSTEM_APPLICATIONS, - contentType = ContentType.OTHER_ITEM + contentType = ContentType.OTHER_ITEM, ) { HeaderSwitchComposeCell( title = stringResource(id = R.string.show_system_apps), @@ -308,8 +309,8 @@ private fun LazyListScope.systemAppsToggle( AlphaVisible } else { AlphaDisabled - } - ) + }, + ), ) } } 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 3e5a973d93..05eb72c142 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 @@ -141,6 +141,6 @@ fun ViewLogsScreen( } private fun shareText(context: Context, logContent: String) { - val shareIntent = context.getLogsShareIntent("Share logs", logContent) + val shareIntent = context.getLogsShareIntent(logContent) context.startActivity(shareIntent) } 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 5b4c907103..7eee7c1398 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,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Context import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -19,6 +20,7 @@ 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 @@ -31,7 +33,6 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.ResultRecipient import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R @@ -49,6 +50,7 @@ import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell import net.mullvad.mullvadvpn.compose.cell.NormalSwitchComposeCell import net.mullvad.mullvadvpn.compose.cell.SelectableCell import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell +import net.mullvad.mullvadvpn.compose.communication.DnsDialogResult import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar import net.mullvad.mullvadvpn.compose.component.textResource @@ -81,17 +83,19 @@ import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_ import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect import net.mullvad.mullvadvpn.compose.util.OnNavResultValue +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately import net.mullvad.mullvadvpn.constant.WIREGUARD_PRESET_PORTS +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange +import net.mullvad.mullvadvpn.lib.model.QuantumResistantState +import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation 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.toValueOrNull +import net.mullvad.mullvadvpn.util.toPortOrNull import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem import net.mullvad.mullvadvpn.viewmodel.VpnSettingsSideEffect import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel @@ -105,7 +109,7 @@ private fun PreviewVpnSettings() { state = VpnSettingsUiState.createDefault( isAutoConnectEnabled = true, - mtu = "1337", + mtu = Mtu(1337), isCustomDnsEnabled = true, customDnsItems = listOf(CustomDnsItem("0.0.0.0", false)), ), @@ -131,45 +135,47 @@ private fun PreviewVpnSettings() { @Destination(style = SlideInFromRightTransition::class) @Composable +@Suppress("LongMethod") fun VpnSettings( navigator: DestinationsNavigator, - dnsDialogResult: ResultRecipient<DnsDialogDestination, Boolean>, - customWgPortResult: ResultRecipient<WireguardCustomPortDialogDestination, Int?> + dnsDialogResult: ResultRecipient<DnsDialogDestination, DnsDialogResult>, + customWgPortResult: ResultRecipient<WireguardCustomPortDialogDestination, Port?>, + mtuDialogResult: ResultRecipient<MtuDialogDestination, Boolean>, ) { val vm = koinViewModel<VpnSettingsViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() dnsDialogResult.OnNavResultValue { result -> - if (result) { - vm.showApplySettingChangesWarningToast() - } else { - vm.onDnsDialogDismissed() + when (result) { + DnsDialogResult.Success -> vm.showApplySettingChangesWarningToast() + DnsDialogResult.Cancel -> vm.onDnsDialogDismissed() + DnsDialogResult.Error -> { + vm.showGenericErrorToast() + vm.onDnsDialogDismissed() + } } } - customWgPortResult.onNavResult { - when (it) { - NavResult.Canceled -> {} - is NavResult.Value -> { - val port = it.value + customWgPortResult.OnNavResultValue { port -> + if (port != null) { + vm.onWireguardPortSelected(Constraint.Only(port)) + } else { + vm.resetCustomPort() + } + } - if (port != null) { - vm.onWireguardPortSelected(Constraint.Only(Port(port))) - } else { - vm.resetCustomPort() - } - } + mtuDialogResult.OnNavResultValue { result -> + if (!result) { + vm.showGenericErrorToast() } } val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current LaunchedEffectCollect(vm.uiSideEffect) { when (it) { is VpnSettingsSideEffect.ShowToast -> - launch { - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar(message = it.message) - } + launch { snackbarHostState.showSnackbarImmediately(message = it.message(context)) } VpnSettingsSideEffect.NavigateToDnsDialog -> navigator.navigate(DnsDialogDestination(null, null)) { launchSingleTop = true } } @@ -240,7 +246,7 @@ fun VpnSettings( navigateToWireguardPortDialog = { val args = WireguardCustomPortNavArgs( - state.customWireguardPort?.toValueOrNull(), + state.customWireguardPort?.toPortOrNull(), state.availablePortRanges ) navigator.navigate(WireguardCustomPortDialogDestination(args)) { @@ -280,7 +286,7 @@ fun VpnSettingsScreen( onToggleBlockAdultContent: (Boolean) -> Unit = {}, onToggleBlockGambling: (Boolean) -> Unit = {}, onToggleBlockSocialMedia: (Boolean) -> Unit = {}, - navigateToMtuDialog: (mtu: Int?) -> Unit = {}, + navigateToMtuDialog: (mtu: Mtu?) -> Unit = {}, navigateToDns: (index: Int?, address: String?) -> Unit = { _, _ -> }, onToggleDnsClick: (Boolean) -> Unit = {}, onBackClick: () -> Unit = {}, @@ -512,8 +518,8 @@ fun VpnSettingsScreen( itemWithDivider { SelectableCell( title = stringResource(id = R.string.automatic), - isSelected = state.selectedWireguardPort is Constraint.Any, - onCellClicked = { onWireguardPortSelected(Constraint.Any()) } + isSelected = state.selectedWireguardPort == Constraint.Any, + onCellClicked = { onWireguardPortSelected(Constraint.Any) } ) } @@ -532,7 +538,7 @@ fun VpnSettingsScreen( CustomPortCell( title = stringResource(id = R.string.wireguard_custon_port_title), isSelected = state.selectedWireguardPort.isCustom(), - port = state.customWireguardPort?.toValueOrNull(), + port = state.customWireguardPort?.toPortOrNull(), onMainCellClicked = { if (state.customWireguardPort != null) { onWireguardPortSelected(state.customWireguardPort) @@ -610,10 +616,7 @@ fun VpnSettingsScreen( } item { - MtuComposeCell( - mtuValue = state.mtu, - onEditMtu = { navigateToMtuDialog(state.mtu.toIntOrNull()) } - ) + MtuComposeCell(mtuValue = state.mtu, onEditMtu = { navigateToMtuDialog(state.mtu) }) } item { MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG)) @@ -632,3 +635,10 @@ private fun ServerIpOverrides(onServerIpOverridesClick: () -> Unit) { onClick = onServerIpOverridesClick ) } + +private fun VpnSettingsSideEffect.ShowToast.message(context: Context) = + when (this) { + VpnSettingsSideEffect.ShowToast.ApplySettingsWarning -> + context.getString(R.string.settings_changes_effect_warning_short) + VpnSettingsSideEffect.ShowToast.GenericError -> context.getString(R.string.error_occurred) + } 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 8dcbea0350..29bc0c3306 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 @@ -20,7 +20,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -47,13 +47,14 @@ import net.mullvad.mullvadvpn.compose.destinations.PaymentDestination import net.mullvad.mullvadvpn.compose.destinations.RedeemVoucherDestination import net.mullvad.mullvadvpn.compose.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.destinations.VerificationPendingDialogDestination +import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces -import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser +import net.mullvad.mullvadvpn.lib.model.AccountToken import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice @@ -71,7 +72,7 @@ private fun PreviewWelcomeScreen() { WelcomeScreen( state = WelcomeUiState( - accountNumber = "4444555566667777", + accountNumber = AccountToken("4444555566667777"), deviceName = "Happy Mole", billingPaymentState = PaymentState.PaymentAvailable( @@ -126,13 +127,11 @@ fun Welcome( } } - val context = LocalContext.current - + val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() CollectSideEffectWithLifecycle(sideEffect = vm.uiSideEffect, Lifecycle.State.RESUMED) { uiSideEffect -> when (uiSideEffect) { - is WelcomeViewModel.UiSideEffect.OpenAccountView -> - context.openAccountPageInBrowser(uiSideEffect.token) + is WelcomeViewModel.UiSideEffect.OpenAccountView -> openAccountPage(uiSideEffect.token) WelcomeViewModel.UiSideEffect.OpenConnectScreen -> navigator.navigate(ConnectDestination) { launchSingleTop = true @@ -274,7 +273,7 @@ private fun AccountNumberRow(snackbarHostState: SnackbarHostState, state: Welcom val copiedAccountNumberMessage = stringResource(id = R.string.copied_mullvad_account_number) val copyToClipboard = createCopyToClipboardHandle(snackbarHostState = snackbarHostState) val onCopyToClipboard = { - copyToClipboard(state.accountNumber ?: "", copiedAccountNumberMessage) + copyToClipboard(state.accountNumber?.value ?: "", copiedAccountNumberMessage) } Row( @@ -286,7 +285,7 @@ private fun AccountNumberRow(snackbarHostState: SnackbarHostState, state: Welcom .padding(horizontal = Dimens.sideMargin) ) { Text( - text = state.accountNumber?.groupWithSpaces() ?: "", + text = state.accountNumber?.value?.groupWithSpaces() ?: "", modifier = Modifier.weight(1f).padding(vertical = Dimens.smallPadding), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onPrimary diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt index 4a1c41e562..910bdaa17f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt @@ -1,16 +1,14 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.GeoIpLocation -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.TransportProtocol +import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.repository.InAppNotification -import net.mullvad.talpid.net.TransportProtocol data class ConnectUiState( val location: GeoIpLocation?, - val selectedRelayItem: RelayItem?, - val tunnelUiState: TunnelState, - val tunnelRealState: TunnelState, + val selectedRelayItemTitle: String?, + val tunnelState: TunnelState, val inAddress: Triple<String, Int, TransportProtocol>?, val outAddress: String, val showLocation: Boolean, @@ -21,17 +19,16 @@ data class ConnectUiState( ) { val showLocationInfo: Boolean = - tunnelRealState !is TunnelState.Disconnected && location?.hostname != null + tunnelState !is TunnelState.Disconnected && location?.hostname != null val showLoading = - tunnelRealState is TunnelState.Connecting || tunnelRealState is TunnelState.Disconnecting + tunnelState is TunnelState.Connecting || tunnelState is TunnelState.Disconnecting companion object { val INITIAL = ConnectUiState( location = null, - selectedRelayItem = null, - tunnelUiState = TunnelState.Disconnected(), - tunnelRealState = TunnelState.Disconnected(), + selectedRelayItemTitle = null, + tunnelState = TunnelState.Disconnected(), inAddress = null, outAddress = "", showLocation = false, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt index 43052702bd..255e0bf561 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt @@ -1,5 +1,5 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.usecase.customlists.CreateWithLocationsError -data class CreateCustomListUiState(val error: CustomListsError? = null) +data class CreateCustomListUiState(val error: CreateWithLocationsError? = null) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt index 7c9c5aedec..f207d85359 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt @@ -1,6 +1,6 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItem sealed interface CustomListLocationsUiState { val newList: Boolean @@ -22,7 +22,7 @@ sealed interface CustomListLocationsUiState { data class Data( override val newList: Boolean = false, - val availableLocations: List<RelayItem.Country> = emptyList(), + val availableLocations: List<RelayItem.Location.Country> = emptyList(), val selectedLocations: Set<RelayItem> = emptySet(), override val searchTerm: String = "", override val saveEnabled: Boolean = false, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListsUiState.kt index f055bf95d2..63e3167881 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListsUiState.kt @@ -1,10 +1,9 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.CustomList interface CustomListsUiState { object Loading : CustomListsUiState - data class Content(val customLists: List<RelayItem.CustomList> = emptyList()) : - CustomListsUiState + data class Content(val customLists: List<CustomList> = emptyList()) : CustomListsUiState } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteCustomListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteCustomListUiState.kt new file mode 100644 index 0000000000..000fc13f4a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeleteCustomListUiState.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.usecase.customlists.DeleteWithUndoError + +data class DeleteCustomListUiState(val deleteError: DeleteWithUndoError?) 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 e539dbafc6..c5c2d5fab0 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 @@ -1,16 +1,24 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.Device +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.GetDeviceListError -data class DeviceListUiState( - val deviceUiItems: List<DeviceListItemUiState>, - val isLoading: Boolean, -) { - val hasTooManyDevices = deviceUiItems.count() >= 5 +sealed interface DeviceListUiState { + data object Loading : DeviceListUiState + + data class Error(val error: GetDeviceListError) : DeviceListUiState + + data class Content( + val devices: List<DeviceItemUiState>, + ) : DeviceListUiState { + val hasTooManyDevices = devices.size >= MAXIMUM_DEVICES + } companion object { - val INITIAL = DeviceListUiState(deviceUiItems = emptyList(), isLoading = true) + val INITIAL: DeviceListUiState = Loading } } -data class DeviceListItemUiState(val device: Device, val isLoading: Boolean) +data class DeviceItemUiState(val device: Device, val isLoading: Boolean) + +private const val MAXIMUM_DEVICES = 5 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListNameUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListNameUiState.kt new file mode 100644 index 0000000000..9e6bcdecf8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListNameUiState.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.usecase.customlists.RenameError + +data class EditCustomListNameUiState(val name: String = "", val error: RenameError? = null) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListState.kt index 9b564bb407..fa583e6fb9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListState.kt @@ -1,12 +1,17 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId sealed interface EditCustomListState { data object Loading : EditCustomListState data object NotFound : EditCustomListState - data class Content(val id: String, val name: String, val locations: List<RelayItem>) : - EditCustomListState + data class Content( + val id: CustomListId, + val name: CustomListName, + val locations: List<GeoLocationId> + ) : EditCustomListState } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt index ad301877c4..52ef7445b0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/FilterConstrainExtensions.kt @@ -1,34 +1,34 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.model.Providers -import net.mullvad.mullvadvpn.relaylist.Provider +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.Providers fun Constraint<Ownership>.toNullableOwnership(): Ownership? = when (this) { - is Constraint.Any -> null + Constraint.Any -> null is Constraint.Only -> this.value } fun Ownership?.toOwnershipConstraint(): Constraint<Ownership> = when (this) { - null -> Constraint.Any() + null -> Constraint.Any else -> Constraint.Only(this) } fun Constraint<Providers>.toSelectedProviders(allProviders: List<Provider>): List<Provider> = when (this) { - is Constraint.Any -> allProviders + Constraint.Any -> allProviders is Constraint.Only -> - value.providers.toList().mapNotNull { providerName -> - allProviders.firstOrNull { it.name == providerName } + value.providers.toList().mapNotNull { provider -> + allProviders.firstOrNull { it.providerId == provider } } } fun List<Provider>.toConstraintProviders(allProviders: List<Provider>): Constraint<Providers> = if (size == allProviders.size) { - Constraint.Any() + Constraint.Any } else { - Constraint.Only(Providers(map { provider -> provider.name }.toHashSet())) + Constraint.Only(Providers(map { provider -> provider.providerId }.toHashSet())) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt index 82f69e5380..0babd243da 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt @@ -1,6 +1,6 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.AccountToken const val MIN_ACCOUNT_LOGIN_LENGTH = 8 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt index d72e015194..6e195d40d8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt @@ -1,6 +1,6 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.lib.model.TunnelState data class OutOfTimeUiState( val tunnelState: TunnelState = TunnelState.Disconnected(), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt index 664f03ce40..0ef8dfb9c1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/RelayFilterState.kt @@ -1,7 +1,7 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.relaylist.Provider +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider data class RelayFilterState( val selectedOwnership: Ownership? = null, @@ -15,21 +15,12 @@ data class RelayFilterState( Ownership.entries } else { Ownership.entries.filter { ownership -> - selectedProviders.any { provider -> - if (provider.mullvadOwned) { - ownership == Ownership.MullvadOwned - } else { - ownership == Ownership.Rented - } - } + selectedProviders.any { provider -> provider.ownership == ownership } } } val filteredProvidersByOwnership = - when (selectedOwnership) { - Ownership.MullvadOwned -> allProviders.filter { it.mullvadOwned } - Ownership.Rented -> allProviders.filterNot { it.mullvadOwned } - else -> allProviders - } + if (selectedOwnership == null) allProviders + else allProviders.filter { provider -> provider.ownership == selectedOwnership } val isAllProvidersChecked = allProviders.size == selectedProviders.size } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt index 747e21d91c..79f434aad1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt @@ -1,8 +1,9 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH -import net.mullvad.mullvadvpn.relaylist.RelayItem sealed interface SelectLocationUiState { @@ -14,8 +15,8 @@ sealed interface SelectLocationUiState { val selectedProvidersCount: Int?, val filteredCustomLists: List<RelayItem.CustomList>, val customLists: List<RelayItem.CustomList>, - val countries: List<RelayItem.Country>, - val selectedItem: RelayItem? + val countries: List<RelayItem.Location.Country>, + val selectedItem: RelayItemId? ) : SelectLocationUiState { val hasFilter: Boolean = (selectedProvidersCount != null || selectedOwnership != null) val inSearch = searchTerm.length >= MIN_SEARCH_LENGTH diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/UpdateCustomListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/UpdateCustomListUiState.kt deleted file mode 100644 index 7eac74a40a..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/UpdateCustomListUiState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package net.mullvad.mullvadvpn.compose.state - -import net.mullvad.mullvadvpn.model.CustomListsError - -data class UpdateCustomListUiState(val name: String = "", val error: CustomListsError? = null) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt index c143dda0e8..925dc33aa9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt @@ -1,5 +1,7 @@ package net.mullvad.mullvadvpn.compose.state +import net.mullvad.mullvadvpn.lib.model.RedeemVoucherError + data class VoucherDialogUiState( val voucherInput: String = "", val voucherState: VoucherDialogState = VoucherDialogState.Default @@ -17,5 +19,5 @@ sealed interface VoucherDialogState { data class Success(val addedTime: Long) : VoucherDialogState - data class Error(val errorMessage: String) : VoucherDialogState + data class Error(val error: RedeemVoucherError) : VoucherDialogState } 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 75abbc7cef..dd9802db2c 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 @@ -1,15 +1,16 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.DefaultDnsOptions -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.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions +import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange +import net.mullvad.mullvadvpn.lib.model.QuantumResistantState +import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem data class VpnSettingsUiState( - val mtu: String, + val mtu: Mtu?, val isAutoConnectEnabled: Boolean, val isLocalNetworkSharingEnabled: Boolean, val isCustomDnsEnabled: Boolean, @@ -25,7 +26,7 @@ data class VpnSettingsUiState( companion object { fun createDefault( - mtu: String = "", + mtu: Mtu? = null, isAutoConnectEnabled: Boolean = false, isLocalNetworkSharingEnabled: Boolean = false, isCustomDnsEnabled: Boolean = false, @@ -33,7 +34,7 @@ data class VpnSettingsUiState( contentBlockersOptions: DefaultDnsOptions = DefaultDnsOptions(), selectedObfuscation: SelectedObfuscation = SelectedObfuscation.Off, quantumResistant: QuantumResistantState = QuantumResistantState.Off, - selectedWireguardPort: Constraint<Port> = Constraint.Any(), + selectedWireguardPort: Constraint<Port> = Constraint.Any, customWireguardPort: Constraint.Only<Port>? = null, availablePortRanges: List<PortRange> = emptyList(), systemVpnSettingsAvailable: Boolean = false, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt index e43cf6bb98..02e8217172 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt @@ -1,10 +1,11 @@ package net.mullvad.mullvadvpn.compose.state -import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.TunnelState data class WelcomeUiState( val tunnelState: TunnelState = TunnelState.Disconnected(), - val accountNumber: String? = null, + val accountNumber: AccountToken? = null, val deviceName: String? = null, val showSitePayment: Boolean = false, val billingPaymentState: PaymentState? = null, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt index 6c5e80d6ed..ce8f9989bb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt @@ -22,9 +22,7 @@ fun createCopyToClipboardHandle( return { textToCopy: String, toastMessage: String? -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && toastMessage != null) { scope.launch { - // Dismiss to prevent queueing up of snackbar data. - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar( + snackbarHostState.showSnackbarImmediately( message = toastMessage, duration = SnackbarDuration.Short ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt deleted file mode 100644 index 3581d1d0b4..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt +++ /dev/null @@ -1,81 +0,0 @@ -package net.mullvad.mullvadvpn.compose.util - -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.relaylist.RelayItem - -fun generateRelayItemCountry( - name: String, - cityNames: List<String>, - relaysPerCity: Int, - active: Boolean = true, - expanded: Boolean = false, - expandChildren: Boolean = false, -) = - RelayItem.Country( - name = name, - code = name.generateCountryCode(), - cities = - cityNames.map { cityName -> - generateRelayItemCity( - cityName, - name.generateCountryCode(), - relaysPerCity, - active, - expandChildren - ) - }, - expanded = expanded, - ) - -fun generateRelayItemCity( - name: String, - countryCode: String, - numberOfRelays: Int, - active: Boolean = true, - expanded: Boolean = false, -) = - RelayItem.City( - name = name, - code = name.generateCityCode(), - relays = - List(numberOfRelays) { index -> - generateRelayItemRelay( - countryCode, - name.generateCityCode(), - generateHostname(countryCode, name.generateCityCode(), index), - active - ) - }, - expanded = expanded, - location = GeographicLocationConstraint.City(countryCode, name.generateCityCode()), - ) - -fun generateRelayItemRelay( - countryCode: String, - cityCode: String, - hostName: String, - active: Boolean = true, -) = - RelayItem.Relay( - name = hostName, - location = - GeographicLocationConstraint.Hostname( - countryCode = countryCode, - cityCode = cityCode, - hostname = hostName, - ), - locationName = "$cityCode $hostName", - active = active, - providerName = "Provider", - ownership = Ownership.MullvadOwned, - ) - -private fun String.generateCountryCode() = (take(1) + takeLast(1)).lowercase() - -private fun String.generateCityCode() = take(CITY_CODE_LENGTH).lowercase() - -private fun generateHostname(countryCode: String, cityCode: String, index: Int) = - "$countryCode-$cityCode-wg-${index+1}" - -private const val CITY_CODE_LENGTH = 3 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RequestVpnPermission.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RequestVpnPermission.kt new file mode 100644 index 0000000000..13817db4bc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RequestVpnPermission.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn.compose.util + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.VpnService +import androidx.activity.result.contract.ActivityResultContract + +class RequestVpnPermission : ActivityResultContract<Unit, Boolean>() { + override fun createIntent(context: Context, input: Unit): Intent { + // We expect this permission to only be requested when the permission is missing, however, + // if it for some reason is called incorrectly we should return an empty intent so we avoid + // a crash. + return VpnService.prepare(context) ?: Intent() + } + + override fun parseResult(resultCode: Int, intent: Intent?): Boolean { + return resultCode == Activity.RESULT_OK + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt index 3e5b7e1618..1dcbc302ef 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Snackbar.kt @@ -2,18 +2,21 @@ package net.mullvad.mullvadvpn.compose.util import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch +import androidx.compose.material3.SnackbarResult +@Suppress("LongParameterList") suspend fun SnackbarHostState.showSnackbarImmediately( - coroutineScope: CoroutineScope, message: String, actionLabel: String? = null, + onAction: (() -> Unit) = {}, withDismissAction: Boolean = false, + onDismiss: (() -> Unit) = {}, duration: SnackbarDuration = if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite -) = - coroutineScope.launch { - currentSnackbarData?.dismiss() - showSnackbar(message, actionLabel, withDismissAction, duration) +) { + currentSnackbarData?.dismiss() + when (showSnackbar(message, actionLabel, withDismissAction, duration)) { + SnackbarResult.ActionPerformed -> onAction() + SnackbarResult.Dismissed -> onDismiss() } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt index d58107c713..8efe66085f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt @@ -1,9 +1,9 @@ package net.mullvad.mullvadvpn.constant import androidx.compose.animation.core.Spring -import net.mullvad.mullvadvpn.model.LatLong -import net.mullvad.mullvadvpn.model.Latitude -import net.mullvad.mullvadvpn.model.Longitude +import net.mullvad.mullvadvpn.lib.model.LatLong +import net.mullvad.mullvadvpn.lib.model.Latitude +import net.mullvad.mullvadvpn.lib.model.Longitude const val MINIMUM_LOADING_TIME_MILLIS = 500L diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/PathConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/PathConstant.kt new file mode 100644 index 0000000000..755e076721 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/PathConstant.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.constant + +const val GRPC_SOCKET_FILE_NAME = "rpc-socket" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt new file mode 100644 index 0000000000..d116d929b4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.di + +import kotlinx.coroutines.MainScope +import net.mullvad.mullvadvpn.BuildConfig +import net.mullvad.mullvadvpn.constant.GRPC_SOCKET_FILE_NAME +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.intent.IntentProvider +import net.mullvad.mullvadvpn.lib.model.BuildVersion +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.lib.shared.VpnPermissionRepository +import org.koin.android.ext.koin.androidContext +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val appModule = module { + single(named(RPC_SOCKET_PATH)) { "${androidContext().dataDir.path}/$GRPC_SOCKET_FILE_NAME" } + single { + ManagementService( + rpcSocketPath = get(named(RPC_SOCKET_PATH)), + extensiveLogging = BuildConfig.DEBUG, + scope = MainScope(), + ) + } + single { BuildVersion(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) } + single { IntentProvider() } + single { AccountRepository(get(), get(), MainScope()) } + single { VpnPermissionRepository(androidContext()) } + single { ConnectionProxy(get(), get()) } +} + +const val RPC_SOCKET_PATH = "RPC_SOCKET" 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 fe02cf5b7a..b6dba8f74e 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 @@ -3,42 +3,45 @@ package net.mullvad.mullvadvpn.di import android.content.Context import android.content.SharedPreferences import android.content.pm.PackageManager -import android.os.Messenger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.applist.ApplicationsProvider import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport -import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher -import net.mullvad.mullvadvpn.lib.ipc.MessageHandler +import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.payment.PaymentProvider -import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.lib.shared.VoucherRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.CustomListsRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.repository.ProblemReportRepository +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.RelayOverridesRepository import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener +import net.mullvad.mullvadvpn.repository.SplitTunnelingRepository +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase +import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase import net.mullvad.mullvadvpn.usecase.EmptyPaymentUseCase +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase +import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase 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 -import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListRelayItemsUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase import net.mullvad.mullvadvpn.util.ChangelogDataProvider import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.viewmodel.AccountViewModel @@ -69,6 +72,7 @@ import net.mullvad.mullvadvpn.viewmodel.SplashViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.VpnPermissionViewModel import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import org.apache.commons.validator.routines.InetAddressValidator @@ -76,7 +80,6 @@ import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.qualifier.named -import org.koin.dsl.bind import org.koin.dsl.module val uiModule = module { @@ -90,48 +93,47 @@ val uiModule = module { viewModel { SplitTunnelingViewModel(get(), get(), Dispatchers.Default) } single { ApplicationsProvider(get(), get(named(SELF_PACKAGE_NAME))) } - single { (messenger: Messenger, dispatcher: EventDispatcher) -> - SplitTunneling(messenger, dispatcher) - } - - single { ServiceConnectionManager(androidContext()) } bind MessageHandler::class + single { ServiceConnectionManager(androidContext()) } single { InetAddressValidator.getInstance() } single { androidContext().resources } single { androidContext().assets } single { androidContext().contentResolver } single { ChangelogRepository(get(named(APP_PREFERENCES_NAME)), get()) } - - single { AccountRepository(get()) } single { DeviceRepository(get()) } single { PrivacyDisclaimerRepository( - androidContext().getSharedPreferences(APP_PREFERENCES_NAME, Context.MODE_PRIVATE) + androidContext().getSharedPreferences(APP_PREFERENCES_NAME, Context.MODE_PRIVATE), ) } - single { SettingsRepository(get(), get()) } + single { SettingsRepository(get()) } single { MullvadProblemReport(get()) } - single { RelayOverridesRepository(get(), get()) } - single { CustomListsRepository(get(), get(), get()) } + single { RelayOverridesRepository(get()) } + single { CustomListsRepository(get()) } + single { RelayListRepository(get()) } + single { RelayListFilterRepository(get()) } + single { VoucherRepository(get(), get()) } + single { SplitTunnelingRepository(get()) } single { AccountExpiryNotificationUseCase(get()) } single { TunnelStateNotificationUseCase(get()) } single { VersionNotificationUseCase(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS) } single { NewDeviceNotificationUseCase(get()) } - single { PortRangeUseCase(get()) } - single { RelayListUseCase(get(), get()) } single { OutOfTimeUseCase(get(), get(), MainScope()) } single { ConnectivityUseCase(get()) } single { SystemVpnSettingsUseCase(androidContext()) } single { CustomListActionUseCase(get(), get()) } + single { SelectedLocationTitleUseCase(get(), get()) } + single { AvailableProvidersUseCase(get()) } + single { CustomListsRelayItemUseCase(get(), get()) } + single { CustomListRelayItemsUseCase(get(), get()) } + single { FilteredRelayListUseCase(get(), get()) } + single { LastKnownLocationUseCase(get()) } single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } single<IChangelogDataProvider> { ChangelogDataProvider(get()) } - single { RelayListFilterUseCase(get(), get()) } - single { RelayListListener(get()) } - // Will be resolved using from either of the two PaymentModule.kt classes. single { PaymentProvider(get()) } @@ -146,36 +148,48 @@ val uiModule = module { single { ProblemReportRepository() } + single { AppVersionInfoRepository(get(), get()) } + // View models - viewModel { AccountViewModel(get(), get(), get(), get(), IS_PLAY_BUILD) } - viewModel { - ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG) - } + viewModel { AccountViewModel(get(), get(), get(), IS_PLAY_BUILD) } + viewModel { ChangelogViewModel(get(), get(), BuildConfig.ALWAYS_SHOW_CHANGELOG) } viewModel { - ConnectViewModel(get(), get(), get(), get(), get(), get(), get(), get(), IS_PLAY_BUILD) + ConnectViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + IS_PLAY_BUILD + ) } - viewModel { DeviceListViewModel(get(), get()) } + viewModel { parameters -> DeviceListViewModel(get(), parameters.get()) } viewModel { DeviceRevokedViewModel(get(), get()) } - viewModel { MtuDialogViewModel(get()) } + viewModel { parameters -> MtuDialogViewModel(get(), parameters.getOrNull()) } viewModel { parameters -> DnsDialogViewModel(get(), get(), parameters.getOrNull(), parameters.getOrNull()) } - viewModel { LoginViewModel(get(), get(), get(), get()) } + viewModel { LoginViewModel(get(), get(), get()) } viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) } - viewModel { SelectLocationViewModel(get(), get(), get(), get()) } + viewModel { SelectLocationViewModel(get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) } viewModel { SplashViewModel(get(), get(), get()) } - viewModel { VoucherDialogViewModel(get(), get()) } - viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } + viewModel { VoucherDialogViewModel(get()) } + viewModel { VpnSettingsViewModel(get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } viewModel { PaymentViewModel(get()) } - viewModel { FilterViewModel(get()) } - viewModel { parameters -> CreateCustomListDialogViewModel(parameters.get(), get()) } + viewModel { FilterViewModel(get(), get()) } + viewModel { (location: GeoLocationId?) -> CreateCustomListDialogViewModel(location, get()) } viewModel { parameters -> - CustomListLocationsViewModel(parameters.get(), parameters.get(), get(), get()) + CustomListLocationsViewModel(parameters.get(), parameters.get(), get(), get(), get()) } viewModel { parameters -> EditCustomListViewModel(parameters.get(), get()) } viewModel { parameters -> @@ -183,8 +197,9 @@ val uiModule = module { } viewModel { CustomListsViewModel(get(), get()) } viewModel { parameters -> DeleteCustomListConfirmationViewModel(parameters.get(), get()) } - viewModel { ServerIpOverridesViewModel(get(), get(), get(), get()) } + viewModel { ServerIpOverridesViewModel(get(), get()) } viewModel { ResetServerIpOverridesConfirmationViewModel(get()) } + viewModel { VpnPermissionViewModel(get(), get()) } // This view model must be single so we correctly attach lifecycle and share it with activity single { NoDaemonViewModel(get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/provider/MullvadFileProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/provider/MullvadFileProvider.kt index 0410117366..d15f83da0c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/provider/MullvadFileProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/provider/MullvadFileProvider.kt @@ -24,7 +24,7 @@ enum class ProviderCacheDirectory(val directoryName: String) { LOGS("logs") } -fun Context.getLogsShareIntent(shareTitle: String, logContent: String): Intent { +fun Context.getLogsShareIntent(logContent: String): Intent { val fileName = createShareLogFileName() val cacheFile = createCacheFile(ProviderCacheDirectory.LOGS, fileName) cacheFile.writeText(logContent) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt index ad668ed9e8..2a7eeddb69 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt @@ -1,25 +1,19 @@ package net.mullvad.mullvadvpn.relaylist -import net.mullvad.mullvadvpn.model.CustomList -import net.mullvad.mullvadvpn.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem -private fun CustomList.toRelayItemCustomList( - relayCountries: List<RelayItem.Country> +fun CustomList.toRelayItemCustomList( + relayCountries: List<RelayItem.Location.Country> ): RelayItem.CustomList = RelayItem.CustomList( - id = this.id, - customListName = CustomListName.fromString(name), + id = id, + customListName = name, expanded = false, - locations = - this.locations.mapNotNull { - relayCountries.findItemForGeographicLocationConstraint(it) - }, + locations = locations.mapNotNull { relayCountries.findByGeoLocationId(it) }, ) -fun List<CustomList>.toRelayItemLists( - relayCountries: List<RelayItem.Country> -): List<RelayItem.CustomList> = this.map { it.toRelayItemCustomList(relayCountries) } - fun List<RelayItem.CustomList>.filterOnSearchTerm(searchTerm: String) = if (searchTerm.length >= MIN_SEARCH_LENGTH) { this.filter { it.name.contains(searchTerm, ignoreCase = true) } @@ -28,7 +22,9 @@ fun List<RelayItem.CustomList>.filterOnSearchTerm(searchTerm: String) = } fun RelayItem.CustomList.canAddLocation(location: RelayItem) = - this.locations.none { it.code == location.code } && - this.locations.flatMap { it.descendants() }.none { it.code == location.code } + this.locations.none { it.id == location.id } && + this.locations.flatMap { it.descendants() }.none { it.id == location.id } + +fun List<RelayItem.CustomList>.getById(id: CustomListId) = this.find { it.id == id } -fun List<RelayItem.CustomList>.getById(id: String) = this.find { it.id == id } +fun List<CustomList>.getById(id: CustomListId) = this.find { it.id == id } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Provider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Provider.kt deleted file mode 100644 index c103976700..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Provider.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.mullvad.mullvadvpn.relaylist - -data class Provider(val name: String, val mullvadOwned: Boolean) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt deleted file mode 100644 index af4a0084d2..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt +++ /dev/null @@ -1,86 +0,0 @@ -package net.mullvad.mullvadvpn.relaylist - -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.model.GeoIpLocation -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.Ownership - -sealed interface RelayItem { - val name: String - val code: String - val active: Boolean - val hasChildren: Boolean - - val locationName: String - get() = name - - val expanded: Boolean - - data class CustomList( - val customListName: CustomListName, - override val expanded: Boolean, - val id: String, - val locations: List<RelayItem>, - ) : RelayItem { - override val name: String = customListName.value - override val active - get() = locations.any { location -> location.active } - - override val hasChildren - get() = locations.isNotEmpty() - - override val code = id - } - - data class Country( - override val name: String, - override val code: String, - override val expanded: Boolean, - val cities: List<City> - ) : RelayItem { - val location = GeographicLocationConstraint.Country(code) - val relays = cities.flatMap { city -> city.relays } - override val active - get() = cities.any { city -> city.active } - - override val hasChildren - get() = cities.isNotEmpty() - } - - data class City( - override val name: String, - override val code: String, - override val expanded: Boolean, - val location: GeographicLocationConstraint.City, - val relays: List<Relay> - ) : RelayItem { - - override val active - get() = relays.any { relay -> relay.active } - - override val hasChildren - get() = relays.isNotEmpty() - } - - data class Relay( - override val name: String, - override val locationName: String, - override val active: Boolean, - val location: GeographicLocationConstraint.Hostname, - val providerName: String, - val ownership: Ownership, - ) : RelayItem { - override val code = name - override val hasChildren = false - override val expanded = false - } - - fun location(): GeoIpLocation? { - return when (this) { - is CustomList -> null - is Country -> location.location - is City -> location.location - is Relay -> location.location - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt index 3f138dee29..a3758b25fe 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt @@ -1,69 +1,58 @@ package net.mullvad.mullvadvpn.relaylist -import java.lang.IllegalArgumentException -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.LocationConstraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.model.Providers +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.lib.model.RelayItem -fun RelayItem.toLocationConstraint(): LocationConstraint { +fun RelayItem.children(): List<RelayItem> { return when (this) { - is RelayItem.Country -> LocationConstraint.Location(location) - is RelayItem.City -> LocationConstraint.Location(location) - is RelayItem.Relay -> LocationConstraint.Location(location) - is RelayItem.CustomList -> LocationConstraint.CustomList(id) + is RelayItem.Location.Country -> cities + is RelayItem.Location.City -> relays + is RelayItem.CustomList -> locations + else -> emptyList() } } -fun RelayItem.children(): List<RelayItem> { +fun RelayItem.Location.children(): List<RelayItem.Location> { return when (this) { - is RelayItem.Country -> cities - is RelayItem.City -> relays - is RelayItem.CustomList -> locations + is RelayItem.Location.Country -> cities + is RelayItem.Location.City -> relays else -> emptyList() } } -fun RelayItem.descendants(): List<RelayItem> { +fun RelayItem.Location.descendants(): List<RelayItem.Location> { val children = children() return children + children.flatMap { it.descendants() } } -private fun RelayItem.hasOwnership(ownershipConstraint: Constraint<Ownership>): Boolean = +fun List<RelayItem.Location>.withDescendants(): List<RelayItem.Location> = + this + flatMap { it.descendants() } + +private fun RelayItem.Location.hasOwnership(ownershipConstraint: Constraint<Ownership>): Boolean = if (ownershipConstraint is Constraint.Only) { when (this) { - is RelayItem.Country -> cities.any { it.hasOwnership(ownershipConstraint) } - is RelayItem.City -> relays.any { it.hasOwnership(ownershipConstraint) } - is RelayItem.Relay -> this.ownership == ownershipConstraint.value - is RelayItem.CustomList -> locations.any { it.hasOwnership(ownershipConstraint) } + is RelayItem.Location.Country -> cities.any { it.hasOwnership(ownershipConstraint) } + is RelayItem.Location.City -> relays.any { it.hasOwnership(ownershipConstraint) } + is RelayItem.Location.Relay -> this.provider.ownership == ownershipConstraint.value } } else { true } -private fun RelayItem.hasProvider(providersConstraint: Constraint<Providers>): Boolean = +private fun RelayItem.Location.hasProvider(providersConstraint: Constraint<Providers>): Boolean = if (providersConstraint is Constraint.Only) { when (this) { - is RelayItem.Country -> cities.any { it.hasProvider(providersConstraint) } - is RelayItem.City -> relays.any { it.hasProvider(providersConstraint) } - is RelayItem.Relay -> providersConstraint.value.providers.contains(providerName) - is RelayItem.CustomList -> locations.any { it.hasProvider(providersConstraint) } + is RelayItem.Location.Country -> cities.any { it.hasProvider(providersConstraint) } + is RelayItem.Location.City -> relays.any { it.hasProvider(providersConstraint) } + is RelayItem.Location.Relay -> + providersConstraint.value.providers.contains(provider.providerId) } } else { true } -fun RelayItem.filterOnOwnershipAndProvider( - ownership: Constraint<Ownership>, - providers: Constraint<Providers> -): RelayItem? = - when (this) { - is RelayItem.City -> filterOnOwnershipAndProvider(ownership, providers) - is RelayItem.Country -> filterOnOwnershipAndProvider(ownership, providers) - is RelayItem.CustomList -> filterOnOwnershipAndProvider(ownership, providers) - is RelayItem.Relay -> filterOnOwnershipAndProvider(ownership, providers) - } - fun RelayItem.CustomList.filterOnOwnershipAndProvider( ownership: Constraint<Ownership>, providers: Constraint<Providers> @@ -71,20 +60,19 @@ fun RelayItem.CustomList.filterOnOwnershipAndProvider( val newLocations = locations.mapNotNull { when (it) { - is RelayItem.City -> it.filterOnOwnershipAndProvider(ownership, providers) - is RelayItem.Country -> it.filterOnOwnershipAndProvider(ownership, providers) - is RelayItem.CustomList -> - throw IllegalArgumentException("CustomList can't contain CustomList") - is RelayItem.Relay -> it.filterOnOwnershipAndProvider(ownership, providers) + is RelayItem.Location.Country -> + it.filterOnOwnershipAndProvider(ownership, providers) + is RelayItem.Location.City -> it.filterOnOwnershipAndProvider(ownership, providers) + is RelayItem.Location.Relay -> it.filterOnOwnershipAndProvider(ownership, providers) } } return copy(locations = newLocations) } -fun RelayItem.Country.filterOnOwnershipAndProvider( +fun RelayItem.Location.Country.filterOnOwnershipAndProvider( ownership: Constraint<Ownership>, providers: Constraint<Providers> -): RelayItem.Country? { +): RelayItem.Location.Country? { val cities = cities.mapNotNull { it.filterOnOwnershipAndProvider(ownership, providers) } return if (cities.isNotEmpty()) { this.copy(cities = cities) @@ -93,10 +81,10 @@ fun RelayItem.Country.filterOnOwnershipAndProvider( } } -private fun RelayItem.City.filterOnOwnershipAndProvider( +private fun RelayItem.Location.City.filterOnOwnershipAndProvider( ownership: Constraint<Ownership>, providers: Constraint<Providers> -): RelayItem.City? { +): RelayItem.Location.City? { val relays = relays.mapNotNull { it.filterOnOwnershipAndProvider(ownership, providers) } return if (relays.isNotEmpty()) { this.copy(relays = relays) @@ -105,10 +93,10 @@ private fun RelayItem.City.filterOnOwnershipAndProvider( } } -private fun RelayItem.Relay.filterOnOwnershipAndProvider( +private fun RelayItem.Location.Relay.filterOnOwnershipAndProvider( ownership: Constraint<Ownership>, providers: Constraint<Providers> -): RelayItem.Relay? { +): RelayItem.Location.Relay? { return if (hasOwnership(ownership) && hasProvider(providers)) { this } else { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt deleted file mode 100644 index e469aec118..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayList.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.mullvad.mullvadvpn.relaylist - -data class RelayList( - val customLists: List<RelayItem.CustomList>, - val allCountries: List<RelayItem.Country>, - val filteredCountries: List<RelayItem.Country>, - val selectedItem: RelayItem?, -) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt index 78b3732734..069f0e1a08 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt @@ -1,91 +1,15 @@ package net.mullvad.mullvadvpn.relaylist -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.model.Relay as DaemonRelay -import net.mullvad.mullvadvpn.model.RelayList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId -/** - * Convert from a model.RelayList to list of relaylist.RelayCountry Non-wiregaurd relays are - * filtered out and also relays that do not fit the ownership and provider list So are also cities - * that only contains non-wireguard relays Countries, cities and relays are ordered by name - */ -fun RelayList.toRelayCountries(): List<RelayItem.Country> { - val relayCountries = - this.countries - .map { country -> - val cities = mutableListOf<RelayItem.City>() - val relayCountry = RelayItem.Country(country.name, country.code, false, cities) - - for (city in country.cities) { - val relays = mutableListOf<RelayItem.Relay>() - val relayCity = - RelayItem.City( - name = city.name, - code = city.code, - location = GeographicLocationConstraint.City(country.code, city.code), - expanded = false, - relays = relays - ) - - val validCityRelays = city.relays.filterValidRelays() - - for (relay in validCityRelays) { - relays.add( - RelayItem.Relay( - name = relay.hostname, - location = - GeographicLocationConstraint.Hostname( - country.code, - city.code, - relay.hostname - ), - locationName = "${city.name} (${relay.hostname})", - active = relay.active, - providerName = relay.provider, - ownership = - if (relay.owned) Ownership.MullvadOwned else Ownership.Rented - ) - ) - } - relays.sortWith(RelayNameComparator) +fun List<RelayItem.Location.Country>.findByGeoLocationId(geoLocationId: GeoLocationId) = + withDescendants().firstOrNull { it.id == geoLocationId } - if (relays.isNotEmpty()) { - cities.add(relayCity) - } - } - - cities.sortBy { it.name } - relayCountry - } - .filter { country -> country.cities.isNotEmpty() } - .toMutableList() - - relayCountries.sortBy { it.name } - - return relayCountries.toList() -} - -fun List<RelayItem.Country>.findItemForGeographicLocationConstraint( - constraint: GeographicLocationConstraint -) = - when (constraint) { - is GeographicLocationConstraint.Country -> { - this.find { country -> country.code == constraint.countryCode } - } - is GeographicLocationConstraint.City -> { - val country = this.find { country -> country.code == constraint.countryCode } - - country?.cities?.find { city -> city.code == constraint.cityCode } - } - is GeographicLocationConstraint.Hostname -> { - val country = this.find { country -> country.code == constraint.countryCode } - - val city = country?.cities?.find { city -> city.code == constraint.cityCode } - - city?.relays?.find { relay -> relay.name == constraint.hostname } - } - } +fun List<RelayItem.Location.Country>.findByGeoLocationId(geoLocationId: GeoLocationId.City) = + flatMap { it.cities }.firstOrNull { it.id == geoLocationId } /** * Filter and expand the list based on search terms If a country is matched, that country and all @@ -94,41 +18,41 @@ fun List<RelayItem.Country>.findItemForGeographicLocationConstraint( * expanded If a relay is matched, its parents are added and expanded and itself is also added. */ @Suppress("NestedBlockDepth") -fun List<RelayItem.Country>.filterOnSearchTerm( +fun List<RelayItem.Location.Country>.filterOnSearchTerm( searchTerm: String, - selectedItem: RelayItem? -): List<RelayItem.Country> { + selectedItem: RelayItemId? +): List<RelayItem.Location.Country> { return if (searchTerm.length >= MIN_SEARCH_LENGTH) { - val filteredCountries = mutableMapOf<String, RelayItem.Country>() + val filteredCountries = mutableMapOf<GeoLocationId.Country, RelayItem.Location.Country>() this.forEach { relayCountry -> - val cities = mutableListOf<RelayItem.City>() + val cities = mutableListOf<RelayItem.Location.City>() // Try to match the search term with a country // If we match a country, add that country and all cities and relays in that country // Do not currently expand the country or any city if (relayCountry.name.contains(other = searchTerm, ignoreCase = true)) { cities.addAll(relayCountry.cities.map { city -> city.copy(expanded = false) }) - filteredCountries[relayCountry.code] = + filteredCountries[relayCountry.id] = relayCountry.copy(expanded = false, cities = cities) } // Go through and try to match the search term with every city relayCountry.cities.forEach { relayCity -> - val relays = mutableListOf<RelayItem.Relay>() + val relays = mutableListOf<RelayItem.Location.Relay>() // If we match and we already added the country to the filtered list just expand the // country. // If the country is not currently in the filtered list, add it and expand it. // Finally if the city has not already been added to the filtered list, add it, but // do not expand it yet. if (relayCity.name.contains(other = searchTerm, ignoreCase = true)) { - val value = filteredCountries[relayCountry.code] + val value = filteredCountries[relayCountry.id] if (value != null) { - filteredCountries[relayCountry.code] = value.copy(expanded = true) + filteredCountries[relayCountry.id] = value.copy(expanded = true) } else { - filteredCountries[relayCountry.code] = + filteredCountries[relayCountry.id] = relayCountry.copy(expanded = true, cities = cities) } - if (cities.none { city -> city.code == relayCity.code }) { + if (cities.none { city -> city.id == relayCity.id }) { cities.add(relayCity.copy(expanded = false)) } } @@ -141,14 +65,14 @@ fun List<RelayItem.Country>.filterOnSearchTerm( // if so expand it, if not add it to the filtered list and expand it. // Finally add the relay to the list. if (relay.name.contains(other = searchTerm, ignoreCase = true)) { - val value = filteredCountries[relayCountry.code] + val value = filteredCountries[relayCountry.id] if (value != null) { - filteredCountries[relayCountry.code] = value.copy(expanded = true) + filteredCountries[relayCountry.id] = value.copy(expanded = true) } else { - filteredCountries[relayCountry.code] = + filteredCountries[relayCountry.id] = relayCountry.copy(expanded = true, cities = cities) } - val cityIndex = cities.indexOfFirst { it.code == relayCity.code } + val cityIndex = cities.indexOfFirst { it.id == relayCity.id } // No city found if (cityIndex < 0) { @@ -169,78 +93,40 @@ fun List<RelayItem.Country>.filterOnSearchTerm( } } -private fun List<DaemonRelay>.filterValidRelays(): List<DaemonRelay> = filter { - it.isWireguardRelay -} - /** Expand the parent(s), if any, for the current selected item */ -private fun List<RelayItem.Country>.expandItemForSelection( - selectedItem: RelayItem? -): List<RelayItem.Country> { - return selectedItem?.let { - when (selectedItem) { - is RelayItem.Country -> { - this - } - is RelayItem.City -> { - this.map { country -> - if (country.code == selectedItem.location.countryCode) { - country.copy(expanded = true) - } else { - country - } - } - } - is RelayItem.Relay -> { - this.map { country -> - if (country.code == selectedItem.location.countryCode) { - country.copy( - expanded = true, - cities = - country.cities.map { city -> - if (city.code == selectedItem.location.cityCode) { - city.copy(expanded = true) - } else { - city - } +private fun List<RelayItem.Location.Country>.expandItemForSelection( + selectedItem: RelayItemId? +): List<RelayItem.Location.Country> { + selectedItem ?: return this + return when (selectedItem) { + is CustomListId, + is GeoLocationId.Country -> this + is GeoLocationId.City -> + map { if (it.id == selectedItem.country) it.copy(expanded = true) else it } + is GeoLocationId.Hostname -> { + map { country -> + if (country.id == selectedItem.country) { + country.copy( + expanded = true, + cities = + country.cities.map { city -> + if (city.id == selectedItem.city) { + city.copy(expanded = true) + } else { + city } - ) - } else { - country - } - } - } - is RelayItem.CustomList -> this - } - } ?: this -} - -@Suppress("NestedBlockDepth", "ReturnCount") -fun RelayList.getGeographicLocationConstraintByCode(code: String): GeographicLocationConstraint? { - countries.forEach { country -> - val countryCode = country.code - if (country.code == code) { - return GeographicLocationConstraint.Country(countryCode) - } - country.cities.forEach { city -> - val cityCode = city.code - if (city.code == code) { - return GeographicLocationConstraint.City(countryCode, city.code) - } - city.relays.forEach { relay -> - if (relay.hostname == code) { - return GeographicLocationConstraint.Hostname( - countryCode, - cityCode, - relay.hostname + }, ) + } else { + country } } } } - return null } -fun List<RelayItem.Country>.getRelayItemsByCodes(codes: List<String>): List<RelayItem> = - this.filter { codes.contains(it.code) } + - this.flatMap { it.descendants() }.filter { codes.contains(it.code) } +fun List<RelayItem.Location.Country>.getRelayItemsByCodes( + codes: List<GeoLocationId> +): List<RelayItem.Location> = + this.filter { codes.contains(it.id) } + + this.flatMap { it.descendants() }.filter { codes.contains(it.id) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparator.kt deleted file mode 100644 index c062fd1466..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparator.kt +++ /dev/null @@ -1,31 +0,0 @@ -package net.mullvad.mullvadvpn.relaylist - -internal object RelayNameComparator : Comparator<RelayItem.Relay> { - override fun compare(o1: RelayItem.Relay, o2: RelayItem.Relay): Int { - val partitions1 = o1.name.split(regex) - val partitions2 = o2.name.split(regex) - return if (partitions1.size > partitions2.size) partitions1 compareWith partitions2 - else -(partitions2 compareWith partitions1) - } - - private infix fun List<String>.compareWith(other: List<String>): Int { - this.forEachIndexed { index, s -> - if (other.size <= index) return 1 - val partsCompareResult = compareStringOrInt(other[index], s) - if (partsCompareResult != 0) return partsCompareResult - } - return 0 - } - - private fun compareStringOrInt(s1: String, s2: String): Int { - val int1 = s1.toIntOrNull() - val int2 = s2.toIntOrNull() - return if (int1 == null || int2 == null || int1 == int2) { - s2.compareTo(s1) - } else { - int2.compareTo(int1) - } - } - - private val regex = "(?<=\\d)(?=\\D)|(?<=\\D)(?=\\d)".toRegex() -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt deleted file mode 100644 index 369f3e8fee..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt +++ /dev/null @@ -1,83 +0,0 @@ -package net.mullvad.mullvadvpn.repository - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.MessageHandler -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.lib.ipc.events -import net.mullvad.mullvadvpn.model.AccountCreationResult -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.AccountHistory -import net.mullvad.mullvadvpn.model.LoginResult - -class AccountRepository( - private val messageHandler: MessageHandler, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO -) { - private val accountCreationEvents: SharedFlow<AccountCreationResult> = - messageHandler - .events<Event.AccountCreationEvent>() - .map { it.result } - .shareIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed()) - - val accountExpiryState: StateFlow<AccountExpiry> = - messageHandler - .events<Event.AccountExpiryEvent>() - .map { it.expiry } - .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, AccountExpiry.Missing) - - val accountHistory: StateFlow<AccountHistory> = - messageHandler - .events<Event.AccountHistoryEvent>() - .map { it.history } - .onStart { fetchAccountHistory() } - .stateIn(CoroutineScope(dispatcher), SharingStarted.Lazily, AccountHistory.Missing) - - private val loginEvents: SharedFlow<LoginResult> = - messageHandler - .events<Event.LoginEvent>() - .map { it.result } - .shareIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed()) - - suspend fun createAccount(): AccountCreationResult = - withContext(dispatcher) { - val deferred = async { accountCreationEvents.first() } - messageHandler.trySendRequest(Request.CreateAccount) - deferred.await().also { fetchAccountHistory() } - } - - suspend fun login(accountToken: String): LoginResult = - withContext(Dispatchers.IO) { - val deferred = async { loginEvents.first() } - messageHandler.trySendRequest(Request.Login(accountToken)) - deferred.await().also { fetchAccountHistory() } - } - - fun logout() { - messageHandler.trySendRequest(Request.Logout) - } - - fun fetchAccountExpiry() { - messageHandler.trySendRequest(Request.FetchAccountExpiry) - } - - fun fetchAccountHistory() { - messageHandler.trySendRequest(Request.FetchAccountHistory) - } - - fun clearAccountHistory() { - messageHandler.trySendRequest(Request.ClearAccountHistory) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt index 0832f434a5..fd67a6c17a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt @@ -1,79 +1,68 @@ package net.mullvad.mullvadvpn.repository -import kotlinx.coroutines.flow.first +import arrow.core.Either +import arrow.core.raise.either +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.mapNotNull -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.MessageHandler -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.lib.ipc.events -import net.mullvad.mullvadvpn.model.CreateCustomListResult -import net.mullvad.mullvadvpn.model.CustomList -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.model.CustomListsError -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.UpdateCustomListResult -import net.mullvad.mullvadvpn.relaylist.getGeographicLocationConstraintByCode -import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener -import net.mullvad.mullvadvpn.util.firstOrNullWithTimeout +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.common.util.firstOrNullWithTimeout +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.GetCustomListError +import net.mullvad.mullvadvpn.lib.model.UpdateCustomListLocationsError +import net.mullvad.mullvadvpn.lib.model.UpdateCustomListNameError class CustomListsRepository( - private val messageHandler: MessageHandler, - private val settingsRepository: SettingsRepository, - private val relayListListener: RelayListListener + private val managementService: ManagementService, + dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { - suspend fun createCustomList(name: CustomListName): CreateCustomListResult { - val result = messageHandler.trySendRequest(Request.CreateCustomList(name.value)) + val customLists: StateFlow<List<CustomList>?> = + managementService.settings + .mapNotNull { it.customLists } + .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, null) - return if (result) { - messageHandler.events<Event.CreateCustomListResultEvent>().first().result - } else { - CreateCustomListResult.Error(CustomListsError.OtherError) - } - } + suspend fun createCustomList(name: CustomListName) = managementService.createCustomList(name) - fun deleteCustomList(id: String) = messageHandler.trySendRequest(Request.DeleteCustomList(id)) + suspend fun deleteCustomList(id: CustomListId) = managementService.deleteCustomList(id) - private suspend fun updateCustomList(customList: CustomList): UpdateCustomListResult { - val result = messageHandler.trySendRequest(Request.UpdateCustomList(customList)) + private suspend fun updateCustomList(customList: CustomList) = + managementService.updateCustomList(customList) - return if (result) { - messageHandler.events<Event.UpdateCustomListResultEvent>().first().result - } else { - UpdateCustomListResult.Error(CustomListsError.OtherError) - } + suspend fun updateCustomListName( + id: CustomListId, + name: CustomListName + ): Either<UpdateCustomListNameError, Unit> = either { + val customList = getCustomListById(id).bind() + updateCustomList(customList.copy(name = name)) + .mapLeft(UpdateCustomListNameError::from) + .bind() } - suspend fun updateCustomListLocationsFromCodes( - id: String, - locationCodes: List<String> - ): UpdateCustomListResult = - updateCustomListLocations( - id = id, - locations = - ArrayList(locationCodes.mapNotNull { getGeographicLocationConstraintByCode(it) }) - ) - - suspend fun updateCustomListName(id: String, name: CustomListName): UpdateCustomListResult = - getCustomListById(id)?.let { updateCustomList(it.copy(name = name.value)) } - ?: UpdateCustomListResult.Error(CustomListsError.OtherError) - - private suspend fun updateCustomListLocations( - id: String, - locations: ArrayList<GeographicLocationConstraint> - ): UpdateCustomListResult = - awaitCustomListById(id)?.let { updateCustomList(it.copy(locations = locations)) } - ?: UpdateCustomListResult.Error(CustomListsError.OtherError) - - private suspend fun awaitCustomListById(id: String): CustomList? = - settingsRepository.settingsUpdates - .mapNotNull { settings -> settings?.customLists?.customLists?.find { it.id == id } } - .firstOrNullWithTimeout(GET_CUSTOM_LIST_TIMEOUT_MS) - - fun getCustomListById(id: String): CustomList? = - settingsRepository.settingsUpdates.value?.customLists?.customLists?.find { it.id == id } + suspend fun updateCustomListLocations( + id: CustomListId, + locations: List<GeoLocationId> + ): Either<UpdateCustomListLocationsError, Unit> = either { + val customList = getCustomListById(id).bind() + updateCustomList(customList.copy(locations = locations)) + .mapLeft(UpdateCustomListLocationsError::from) + .bind() + } - private fun getGeographicLocationConstraintByCode(code: String): GeographicLocationConstraint? = - relayListListener.relayListEvents.value.getGeographicLocationConstraintByCode(code) + suspend fun getCustomListById(id: CustomListId): Either<GetCustomListError, CustomList> = + either { + customLists + .mapNotNull { it?.find { customList -> customList.id == id } } + .firstOrNullWithTimeout(GET_CUSTOM_LIST_TIMEOUT_MS) + ?: raise(GetCustomListError(id)) + } + .mapLeft { GetCustomListError(id) } companion object { private const val GET_CUSTOM_LIST_TIMEOUT_MS = 5000L diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/DeviceRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/DeviceRepository.kt deleted file mode 100644 index 4fa211c874..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/DeviceRepository.kt +++ /dev/null @@ -1,129 +0,0 @@ -package net.mullvad.mullvadvpn.repository - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withTimeoutOrNull -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.model.DeviceList -import net.mullvad.mullvadvpn.model.DeviceListEvent -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.deviceDataSource - -class DeviceRepository( - private val serviceConnectionManager: ServiceConnectionManager, - dispatcher: CoroutineDispatcher = Dispatchers.IO -) { - private val cachedDeviceList = MutableStateFlow<DeviceList>(DeviceList.Unavailable) - - val deviceState = - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - state.container.deviceDataSource.deviceStateUpdates - } else { - flowOf(DeviceState.Unknown) - } - } - .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, DeviceState.Initial) - - private val deviceListEvents = - serviceConnectionManager.connectionState.flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - state.container.deviceDataSource.deviceListUpdates - } else { - emptyFlow() - } - } - - val deviceList = - deviceListEvents - .map { - if (it is DeviceListEvent.Available) { - cachedDeviceList.value = DeviceList.Available(it.devices) - DeviceList.Available(it.devices) - } else { - DeviceList.Error - } - } - .onStart { - if (cachedDeviceList.value is DeviceList.Available) { - emit(cachedDeviceList.value) - } - } - .shareIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed()) - - val deviceRemovalEvent: SharedFlow<Event.DeviceRemovalEvent> = - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - state.container.deviceDataSource.deviceRemovalResult - } else { - emptyFlow() - } - } - .shareIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed()) - - fun refreshDeviceState() { - serviceConnectionManager.deviceDataSource()?.refreshDevice() - } - - fun removeDevice(accountToken: String, deviceId: String) { - serviceConnectionManager.deviceDataSource()?.removeDevice(accountToken, deviceId) - } - - fun refreshDeviceList(accountToken: String) { - serviceConnectionManager.deviceDataSource()?.refreshDeviceList(accountToken) - } - - fun clearCache() { - cachedDeviceList.value = DeviceList.Unavailable - } - - private fun updateCache(event: DeviceListEvent, accountToken: String) { - cachedDeviceList.value = - if (event is DeviceListEvent.Available && event.accountToken == accountToken) { - DeviceList.Available(event.devices) - } else if (event is DeviceListEvent.Error) { - DeviceList.Error - } else { - DeviceList.Unavailable - } - } - - suspend fun refreshAndAwaitDeviceListWithTimeout( - accountToken: String, - shouldClearCache: Boolean, - shouldOverrideCache: Boolean, - timeoutMillis: Long, - ): DeviceListEvent { - if (shouldClearCache) { - clearCache() - } - - val result = - withTimeoutOrNull(timeoutMillis) { - deviceListEvents.onStart { refreshDeviceList(accountToken) }.firstOrNull() - ?: DeviceListEvent.Error - } ?: DeviceListEvent.Error - - if (shouldOverrideCache) { - updateCache(result, accountToken) - } - - return result - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt index 0751d0b1f7..decff575f8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt @@ -1,17 +1,16 @@ package net.mullvad.mullvadvpn.repository -import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase -import net.mullvad.talpid.tunnel.ErrorState import org.joda.time.DateTime enum class StatusLevel { @@ -21,7 +20,6 @@ enum class StatusLevel { } sealed class InAppNotification { - val uuid: UUID = UUID.randomUUID() abstract val statusLevel: StatusLevel abstract val priority: Long diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListFilterRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListFilterRepository.kt new file mode 100644 index 0000000000..9251cac65c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListFilterRepository.kt @@ -0,0 +1,39 @@ +package net.mullvad.mullvadvpn.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Providers + +class RelayListFilterRepository( + private val managementService: ManagementService, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) { + val selectedOwnership: StateFlow<Constraint<Ownership>> = + managementService.settings + .map { settings -> settings.relaySettings.relayConstraints.ownership } + .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), Constraint.Any) + + val selectedProviders: StateFlow<Constraint<Providers>> = + managementService.settings + .map { settings -> settings.relaySettings.relayConstraints.providers } + .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), Constraint.Any) + + suspend fun updateSelectedOwnershipAndProviderFilter( + ownership: Constraint<Ownership>, + providers: Constraint<Providers> + ) = managementService.setOwnershipAndProviders(ownership, providers) + + suspend fun updateSelectedOwnership(value: Constraint<Ownership>) = + managementService.setOwnership(value) + + suspend fun updateSelectedProviders(value: Constraint<Providers>) = + managementService.setProviders(value) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt new file mode 100644 index 0000000000..7d9846c31b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt @@ -0,0 +1,53 @@ +package net.mullvad.mullvadvpn.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.PortRange +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData + +class RelayListRepository( + private val managementService: ManagementService, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) { + val relayList: StateFlow<List<RelayItem.Location.Country>> = + managementService.relayCountries.stateIn( + CoroutineScope(dispatcher), + SharingStarted.WhileSubscribed(), + emptyList() + ) + + val wireguardEndpointData: StateFlow<WireguardEndpointData> = + managementService.wireguardEndpointData.stateIn( + CoroutineScope(dispatcher), + SharingStarted.WhileSubscribed(), + defaultWireguardEndpointData() + ) + + val selectedLocation: StateFlow<Constraint<RelayItemId>> = + managementService.settings + .map { it.relaySettings.relayConstraints.location } + .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), Constraint.Any) + + val portRanges: Flow<List<PortRange>> = + wireguardEndpointData.map { it.portRanges }.distinctUntilChanged() + + suspend fun updateSelectedRelayLocation(value: RelayItemId) = + managementService.setRelayLocation(value) + + suspend fun updateSelectedWireguardConstraints(value: WireguardConstraints) = + managementService.setWireguardConstraints(value) + + private fun defaultWireguardEndpointData() = WireguardEndpointData(emptyList()) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt index 835cab4710..ddc6a6f529 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayOverridesRepository.kt @@ -5,40 +5,21 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import net.mullvad.mullvadvpn.lib.ipc.MessageHandler -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.RelayOverride -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.settingsListener -import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.RelayOverride class RelayOverridesRepository( - private val serviceConnectionManager: ServiceConnectionManager, - private val messageHandler: MessageHandler, + private val managementService: ManagementService, dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { - fun clearAllOverrides() { - messageHandler.trySendRequest(Request.ClearAllRelayOverrides) - } + suspend fun clearAllOverrides() = managementService.clearAllRelayOverrides() + + suspend fun applySettingsPatch(json: String) = managementService.applySettingsPatch(json) val relayOverrides: StateFlow<List<RelayOverride>?> = - serviceConnectionManager.connectionState - .flatMapReadyConnectionOrDefault(flowOf()) { state -> - callbackFlowFromNotifier(state.container.settingsListener.settingsNotifier) - } - .mapNotNull { it?.relayOverrides?.toList() } - .onStart { - serviceConnectionManager - .settingsListener() - ?.settingsNotifier - ?.latestEvent - ?.relayOverrides - ?.toList() - } + managementService.settings + .mapNotNull { it.relayOverrides } .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, null) } 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 7d61feaf0c..e2469f626f 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 @@ -4,107 +4,66 @@ import java.net.InetAddress import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext -import net.mullvad.mullvadvpn.lib.ipc.Event.ApplyJsonSettingsResult -import net.mullvad.mullvadvpn.lib.ipc.MessageHandler -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.lib.ipc.events -import net.mullvad.mullvadvpn.model.CustomDnsOptions -import net.mullvad.mullvadvpn.model.DefaultDnsOptions -import net.mullvad.mullvadvpn.model.DnsOptions -import net.mullvad.mullvadvpn.model.DnsState -import net.mullvad.mullvadvpn.model.ObfuscationSettings -import net.mullvad.mullvadvpn.model.QuantumResistantState -import net.mullvad.mullvadvpn.model.Settings -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.customDns -import net.mullvad.mullvadvpn.ui.serviceconnection.settingsListener -import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.CustomDnsOptions +import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions +import net.mullvad.mullvadvpn.lib.model.DnsOptions +import net.mullvad.mullvadvpn.lib.model.DnsState +import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.ObfuscationSettings +import net.mullvad.mullvadvpn.lib.model.QuantumResistantState +import net.mullvad.mullvadvpn.lib.model.Settings class SettingsRepository( - private val serviceConnectionManager: ServiceConnectionManager, - private val messageHandler: MessageHandler, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val managementService: ManagementService, + dispatcher: CoroutineDispatcher = Dispatchers.IO ) { val settingsUpdates: StateFlow<Settings?> = - serviceConnectionManager.connectionState - .flatMapReadyConnectionOrDefault(flowOf()) { state -> - callbackFlowFromNotifier(state.container.settingsListener.settingsNotifier) - } - .onStart { serviceConnectionManager.settingsListener()?.settingsNotifier?.latestEvent } - .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), null) + managementService.settings.stateIn( + CoroutineScope(dispatcher), + SharingStarted.WhileSubscribed(), + null + ) - fun setDnsOptions( + suspend fun setDnsOptions( isCustomDnsEnabled: Boolean, dnsList: List<InetAddress>, contentBlockersOptions: DefaultDnsOptions - ) { - updateDnsSettings { + ) = + managementService.setDnsOptions( DnsOptions( state = if (isCustomDnsEnabled) DnsState.Custom else DnsState.Default, customOptions = CustomDnsOptions(ArrayList(dnsList)), defaultOptions = contentBlockersOptions ) - } - } + ) - fun setDnsState( + suspend fun setDnsState( state: DnsState, - ) { - updateDnsSettings { it.copy(state = state) } - } + ) = managementService.setDnsState(state) - fun updateCustomDnsList(update: (List<InetAddress>) -> List<InetAddress>) { - updateDnsSettings { dnsOptions -> - val newDnsList = ArrayList(update(dnsOptions.customOptions.addresses.map { it })) - dnsOptions.copy( - state = if (newDnsList.isEmpty()) DnsState.Default else DnsState.Custom, - customOptions = - CustomDnsOptions( - addresses = newDnsList, - ) - ) - } - } + suspend fun deleteCustomDns(address: InetAddress) = managementService.deleteCustomDns(address) + + suspend fun setCustomDns(index: Int, address: InetAddress) = + managementService.setCustomDns(index, address) - private fun updateDnsSettings(lambda: (DnsOptions) -> DnsOptions) { - settingsUpdates.value?.tunnelOptions?.dnsOptions?.let { - serviceConnectionManager.customDns()?.setDnsOptions(lambda(it)) - } - } + suspend fun addCustomDns(address: InetAddress) = managementService.addCustomDns(address) - fun setWireguardMtu(value: Int?) { - serviceConnectionManager.settingsListener()?.wireguardMtu = value - } + suspend fun setWireguardMtu(mtu: Mtu) = managementService.setWireguardMtu(mtu.value) - fun setWireguardQuantumResistant(value: QuantumResistantState) { - serviceConnectionManager.settingsListener()?.wireguardQuantumResistant = value - } + suspend fun resetWireguardMtu() = managementService.resetWireguardMtu() - fun setObfuscationOptions(value: ObfuscationSettings) { - serviceConnectionManager.settingsListener()?.obfuscationSettings = value - } + suspend fun setWireguardQuantumResistant(value: QuantumResistantState) = + managementService.setWireguardQuantumResistant(value) - fun setAutoConnect(isEnabled: Boolean) { - serviceConnectionManager.settingsListener()?.autoConnect = isEnabled - } + suspend fun setObfuscationOptions(value: ObfuscationSettings) = + managementService.setObfuscationOptions(value) - fun setLocalNetworkSharing(isEnabled: Boolean) { - serviceConnectionManager.settingsListener()?.allowLan = isEnabled - } + suspend fun setAutoConnect(isEnabled: Boolean) = managementService.setAutoConnect(isEnabled) - suspend fun applySettingsPatch(json: String) = - withContext(dispatcher) { - val deferred = async { messageHandler.events<ApplyJsonSettingsResult>().first() } - messageHandler.trySendRequest(Request.ApplyJsonSettings(json)) - deferred.await() - } + suspend fun setLocalNetworkSharing(isEnabled: Boolean) = + managementService.setAllowLan(isEnabled) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SplitTunnelingRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SplitTunnelingRepository.kt new file mode 100644 index 0000000000..383d52b6ce --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SplitTunnelingRepository.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.AppId + +class SplitTunnelingRepository( + private val managementService: ManagementService, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) { + val splitTunnelingEnabled = + managementService.settings + .map { it.splitTunnelSettings.enabled } + .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), false) + + val excludedApps = + managementService.settings + .map { it.splitTunnelSettings.excludedApps } + .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), emptySet()) + + suspend fun enableSplitTunneling(enabled: Boolean) = + managementService.setSplitTunnelingState(enabled) + + suspend fun excludeApp(app: AppId) = managementService.addSplitTunnelingApp(app) + + suspend fun includeApp(app: AppId) = managementService.removeSplitTunnelingApp(app) +} 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 2bfe5d5d9d..5649442050 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 @@ -1,10 +1,7 @@ package net.mullvad.mullvadvpn.ui -import android.app.Activity import android.content.Intent -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 @@ -12,21 +9,15 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch 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.requestNotificationPermissionIfMissing -import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras +import net.mullvad.mullvadvpn.lib.intent.IntentProvider import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules @@ -38,12 +29,10 @@ class MainActivity : ComponentActivity() { // handling the callback value. } - 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 lateinit var serviceConnectionViewModel: NoDaemonViewModel + private lateinit var noDaemonViewModel: NoDaemonViewModel + private lateinit var intentProvider: IntentProvider override fun onCreate(savedInstanceState: Bundle?) { loadKoinModules(listOf(uiModule, paymentModule)) @@ -51,18 +40,21 @@ class MainActivity : ComponentActivity() { // Tell the system that we will draw behind the status bar and navigation bar WindowCompat.setDecorFitsSystemWindows(window, false) - getKoin().apply { - accountRepository = get() - deviceRepository = get() + with(getKoin()) { privacyDisclaimerRepository = get() serviceConnectionManager = get() - changelogViewModel = get() - serviceConnectionViewModel = get() + noDaemonViewModel = get() + intentProvider = get() } - lifecycle.addObserver(serviceConnectionViewModel) + lifecycle.addObserver(noDaemonViewModel) super.onCreate(savedInstanceState) + // Needs to be before set content since we want to access the intent in compose + if (savedInstanceState == null) { + intentProvider.setStartIntent(intent) + } + setContent { AppTheme { MullvadApp() } } // This is to protect against tapjacking attacks @@ -74,56 +66,29 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { - startServiceSuspend(waitForConnectedReady = false) + bindService() } } } } - suspend fun startServiceSuspend(waitForConnectedReady: Boolean = true) { - requestNotificationPermissionIfMissing(requestNotificationPermissionLauncher) - serviceConnectionManager.bind( - vpnPermissionRequestHandler = ::requestVpnPermission, - apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() - ) - if (waitForConnectedReady) { - // Ensure we wait until the service is ready - serviceConnectionManager.connectionState - .filterIsInstance<ServiceConnectionState.ConnectedReady>() - .first() - } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + intentProvider.setStartIntent(intent) } - override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { - // super call is needed for return value when opening file. - super.onActivityResult(requestCode, resultCode, resultData) - - // Ensure we are responding to the correct request - if (requestCode == REQUEST_VPN_PERMISSION_RESULT_CODE) { - serviceConnectionManager.onVpnPermissionResult(resultCode == Activity.RESULT_OK) - } + fun bindService() { + requestNotificationPermissionIfMissing(requestNotificationPermissionLauncher) + serviceConnectionManager.bind() } override fun onStop() { - Log.d("mullvad", "Stopping main activity") super.onStop() serviceConnectionManager.unbind() } override fun onDestroy() { - serviceConnectionManager.onDestroy() - lifecycle.removeObserver(serviceConnectionViewModel) + lifecycle.removeObserver(noDaemonViewModel) super.onDestroy() } - - @Suppress("DEPRECATION") - private fun requestVpnPermission() { - val intent = VpnService.prepare(this) - - startActivityForResult(intent, REQUEST_VPN_PERMISSION_RESULT_CODE) - } - - companion object { - private const val REQUEST_VPN_PERMISSION_RESULT_CODE = 0 - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt index ca5fe50aed..c0ab7dd0ed 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt @@ -1,8 +1,9 @@ package net.mullvad.mullvadvpn.ui data class VersionInfo( - @Deprecated(message = "Use BuildConfig.VERSION_NAME") val currentVersion: String?, - val upgradeVersion: String?, - val isOutdated: Boolean, - val isSupported: Boolean -) + val currentVersion: String, + val isSupported: Boolean, + val suggestedUpgradeVersion: String? +) { + val isUpdateAvailable: Boolean = suggestedUpgradeVersion != null +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoCache.kt deleted file mode 100644 index 9210e5809b..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoCache.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher -import net.mullvad.mullvadvpn.model.AppVersionInfo - -class AppVersionInfoCache( - eventDispatcher: EventDispatcher, - private val settingsListener: SettingsListener -) { - private var appVersionInfo by - observable<AppVersionInfo?>(null) { _, _, _ -> onUpdate?.invoke() } - - val isSupported - get() = appVersionInfo?.supported ?: true - - val isOutdated - get() = appVersionInfo?.suggestedUpgrade != null - - val upgradeVersion - get() = appVersionInfo?.suggestedUpgrade - - var onUpdate by observable<(() -> Unit)?>(null) { _, _, callback -> callback?.invoke() } - - var showBetaReleases by - observable(false) { _, wasShowing, shouldShow -> - if (shouldShow != wasShowing) { - onUpdate?.invoke() - } - } - private set - - var version: String? = null - private set - - init { - eventDispatcher.apply { - registerHandler(Event.CurrentVersion::class) { event -> version = event.version } - - registerHandler(Event.AppVersionInfo::class) { event -> - appVersionInfo = event.versionInfo - } - } - - settingsListener.settingsNotifier.subscribe(this) { maybeSettings -> - maybeSettings?.let { settings -> showBetaReleases = settings.showBetaReleases } - } - } - - fun onDestroy() { - settingsListener.settingsNotifier.unsubscribe(this) - onUpdate = null - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt new file mode 100644 index 0000000000..74b67348b3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.BuildVersion +import net.mullvad.mullvadvpn.ui.VersionInfo + +class AppVersionInfoRepository( + private val buildVersion: BuildVersion, + private val managementService: ManagementService +) { + fun versionInfo(): Flow<VersionInfo> = + managementService.versionInfo.map { appVersionInfo -> + VersionInfo( + currentVersion = buildVersion.name, + isSupported = appVersionInfo.supported, + suggestedUpgradeVersion = appVersionInfo.suggestedUpgrade, + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AuthTokenCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AuthTokenCache.kt deleted file mode 100644 index 2c7ea5385c..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AuthTokenCache.kt +++ /dev/null @@ -1,38 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.Messenger -import java.util.LinkedList -import kotlinx.coroutines.CompletableDeferred -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher -import net.mullvad.mullvadvpn.lib.ipc.Request - -class AuthTokenCache(private val connection: Messenger, eventDispatcher: EventDispatcher) { - private val fetchQueue = LinkedList<CompletableDeferred<String>>() - - init { - eventDispatcher.registerHandler(Event.AuthToken::class) { event -> - synchronized(this@AuthTokenCache) { fetchQueue.poll()?.complete(event.token ?: "") } - } - } - - suspend fun fetchAuthToken(): String { - val authToken = CompletableDeferred<String>() - - synchronized(this) { fetchQueue.offer(authToken) } - - connection.send(Request.FetchAuthToken.message) - - return authToken.await() - } - - fun onDestroy() { - synchronized(this) { - for (pendingFetch in fetchQueue) { - pendingFetch.cancel() - } - - fetchQueue.clear() - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt deleted file mode 100644 index bbc267b2fa..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxy.kt +++ /dev/null @@ -1,143 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.Messenger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.lib.ipc.extensions.trySendRequest -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.util.EventNotifier - -const val ANTICIPATED_STATE_TIMEOUT_MS = 1500L - -class ConnectionProxy(private val connection: Messenger, eventDispatcher: EventDispatcher) { - private var resetAnticipatedStateJob: Job? = null - - val onStateChange = EventNotifier<TunnelState>(TunnelState.Disconnected()) - val onUiStateChange = EventNotifier<TunnelState>(TunnelState.Disconnected()) - - var state by onStateChange.notifiable() - private set - - var uiState by onUiStateChange.notifiable() - private set - - init { - eventDispatcher.registerHandler(Event.TunnelStateChange::class) { event -> - handleNewState(event.tunnelState) - } - } - - fun connect() { - if (anticipateConnectingState()) { - connection.trySendRequest(Request.Connect, true) - } - } - - fun disconnect() { - if (anticipateReconnectingState()) { - connection.trySendRequest(Request.Disconnect, true) - } - } - - fun reconnect() { - if (anticipateDisconnectingState()) { - connection.trySendRequest(Request.Reconnect, true) - } - } - - fun onDestroy() { - onStateChange.unsubscribeAll() - onUiStateChange.unsubscribeAll() - } - - private fun handleNewState(newState: TunnelState) { - synchronized(this) { - resetAnticipatedStateJob?.cancel() - state = newState - uiState = newState - } - } - - private fun anticipateConnectingState(): Boolean { - synchronized(this) { - val currentState = uiState - - if (currentState is TunnelState.Connecting || currentState is TunnelState.Connected) { - return false - } else { - scheduleToResetAnticipatedState() - uiState = TunnelState.Connecting(null, null) - return true - } - } - } - - private fun anticipateReconnectingState(): Boolean { - synchronized(this) { - val currentState = uiState - - val willReconnect = - when (currentState) { - is TunnelState.Disconnected -> false - is TunnelState.Disconnecting -> { - when (currentState.actionAfterDisconnect) { - ActionAfterDisconnect.Nothing -> false - ActionAfterDisconnect.Reconnect -> true - ActionAfterDisconnect.Block -> true - } - } - is TunnelState.Connecting -> true - is TunnelState.Connected -> true - is TunnelState.Error -> true - } - - if (willReconnect) { - scheduleToResetAnticipatedState() - uiState = TunnelState.Disconnecting(ActionAfterDisconnect.Reconnect) - } - - return willReconnect - } - } - - private fun anticipateDisconnectingState(): Boolean { - synchronized(this) { - val currentState = uiState - - if (currentState is TunnelState.Disconnected) { - return false - } else { - scheduleToResetAnticipatedState() - uiState = TunnelState.Disconnecting(ActionAfterDisconnect.Nothing) - return true - } - } - } - - private fun scheduleToResetAnticipatedState() { - resetAnticipatedStateJob?.cancel() - - var currentJob: Job? = null - - val newJob = - GlobalScope.launch(Dispatchers.Default) { - delay(ANTICIPATED_STATE_TIMEOUT_MS) - - synchronized(this@ConnectionProxy) { - if (!currentJob!!.isCancelled) { - uiState = state - } - } - } - - currentJob = newJob - resetAnticipatedStateJob = newJob - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/CustomDns.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/CustomDns.kt deleted file mode 100644 index bfad798e08..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/CustomDns.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.Messenger -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.lib.ipc.extensions.trySendRequest -import net.mullvad.mullvadvpn.model.DnsOptions - -class CustomDns(private val connection: Messenger) { - - fun setDnsOptions(dnsOptions: DnsOptions) { - connection.trySendRequest(Request.SetDnsOptions(dnsOptions), false) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/EmptyServiceConnection.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/EmptyServiceConnection.kt new file mode 100644 index 0000000000..28819f7aa0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/EmptyServiceConnection.kt @@ -0,0 +1,16 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +import android.content.ComponentName +import android.content.ServiceConnection +import android.os.IBinder + +class EmptyServiceConnection : ServiceConnection { + @Suppress("EmptyFunctionBlock") + override fun onServiceConnected(name: ComponentName?, service: IBinder?) {} + + @Suppress("EmptyFunctionBlock") override fun onServiceDisconnected(name: ComponentName?) {} + + override fun onNullBinding(name: ComponentName?) { + error("Received onNullBinding") + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt deleted file mode 100644 index 841c9e0c59..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt +++ /dev/null @@ -1,61 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.MessageHandler -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.lib.ipc.events -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.LocationConstraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.model.Providers -import net.mullvad.mullvadvpn.model.RelayList -import net.mullvad.mullvadvpn.model.WireguardConstraints -import net.mullvad.mullvadvpn.model.WireguardEndpointData - -class RelayListListener( - private val messageHandler: MessageHandler, - dispatcher: CoroutineDispatcher = Dispatchers.IO -) { - val relayListEvents: StateFlow<RelayList> = - messageHandler - .events<Event.NewRelayList>() - .map { it.relayList ?: defaultRelayList() } - // This is added so that we always have a relay list. Otherwise sometimes there would - // not be a relay list since the fetching of a relay list would be done before the - // event stream is available. - .onStart { messageHandler.trySendRequest(Request.FetchRelayList) } - .stateIn( - CoroutineScope(dispatcher), - SharingStarted.WhileSubscribed(), - defaultRelayList() - ) - - fun updateSelectedRelayLocation(value: LocationConstraint) { - messageHandler.trySendRequest(Request.SetRelayLocation(value)) - } - - fun updateSelectedWireguardConstraints(value: WireguardConstraints) { - messageHandler.trySendRequest(Request.SetWireguardConstraints(value)) - } - - fun updateSelectedOwnershipAndProviderFilter( - ownership: Constraint<Ownership>, - providers: Constraint<Providers> - ) { - messageHandler.trySendRequest(Request.SetOwnershipAndProviders(ownership, providers)) - } - - fun fetchRelayList() { - messageHandler.trySendRequest(Request.FetchRelayList) - } - - private fun defaultRelayList() = RelayList(ArrayList(), WireguardEndpointData(ArrayList())) -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionContainer.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionContainer.kt deleted file mode 100644 index 8aabe6c9f5..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionContainer.kt +++ /dev/null @@ -1,90 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.Looper -import android.os.Messenger -import android.os.RemoteException -import android.util.Log -import kotlinx.coroutines.flow.filterIsInstance -import net.mullvad.mullvadvpn.lib.ipc.DispatchingHandler -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.lib.ipc.extensions.trySendRequest -import org.koin.core.component.KoinComponent - -// Container of classes that communicate with the service through an active connection -// -// The properties of this class can be used to send events to the service, to listen for events from -// the service and to get values received from events. -class ServiceConnectionContainer( - val connection: Messenger, - onServiceReady: (ServiceConnectionContainer) -> Unit, - onVpnPermissionRequest: () -> Unit -) : KoinComponent { - private val dispatcher = - DispatchingHandler(Looper.getMainLooper()) { message -> Event.fromMessage(message) } - - val events = dispatcher.parsedMessages.filterIsInstance<Event>() - - val authTokenCache = AuthTokenCache(connection, dispatcher) - val connectionProxy = ConnectionProxy(connection, dispatcher) - val deviceDataSource = ServiceConnectionDeviceDataSource(connection, dispatcher) - val settingsListener = SettingsListener(connection, dispatcher) - - val splitTunneling = SplitTunneling(connection, dispatcher) - val voucherRedeemer = VoucherRedeemer(connection, dispatcher) - val vpnPermission = VpnPermission(connection, dispatcher) - - val appVersionInfoCache = AppVersionInfoCache(dispatcher, settingsListener) - val customDns = CustomDns(connection) - - private var listenerId: Int? = null - - init { - vpnPermission.onRequest = onVpnPermissionRequest - - dispatcher.registerHandler(Event.ListenerReady::class) { event -> - listenerId = event.listenerId - onServiceReady.invoke(this@ServiceConnectionContainer) - } - - registerListener(connection) - } - - fun trySendRequest(request: Request, logErrors: Boolean): Boolean { - return connection.trySendRequest(request, logErrors = logErrors) - } - - fun onDestroy() { - unregisterListener() - - dispatcher.onDestroy() - - authTokenCache.onDestroy() - connectionProxy.onDestroy() - settingsListener.onDestroy() - voucherRedeemer.onDestroy() - - appVersionInfoCache.onDestroy() - } - - private fun registerListener(connection: Messenger) { - val listener = Messenger(dispatcher) - val request = Request.RegisterListener(listener) - - try { - connection.send(request.message) - } catch (exception: RemoteException) { - Log.e("mullvad", "Failed to register listener for service events", exception) - } - } - - private fun unregisterListener() { - listenerId?.let { id -> - try { - connection.send(Request.UnregisterListener(id).message) - } catch (exception: RemoteException) { - Log.e("mullvad", "Failed to unregister listener for service events", exception) - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSource.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSource.kt deleted file mode 100644 index a9094ed011..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSource.kt +++ /dev/null @@ -1,56 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.Messenger -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.lib.ipc.extensions.trySendRequest - -class ServiceConnectionDeviceDataSource( - private val connection: Messenger, - private val dispatcher: EventDispatcher -) { - val deviceStateUpdates = callbackFlow { - val handler: (Event.DeviceStateEvent) -> Unit = { event -> trySend(event.newState) } - dispatcher.registerHandler(Event.DeviceStateEvent::class, handler) - connection.trySendRequest(Request.GetDevice, false) - awaitClose { - // The current dispatcher doesn't support unregistration of handlers. - } - } - - val deviceListUpdates = callbackFlow { - val handler: (Event.DeviceListUpdate) -> Unit = { event -> trySend(event.event) } - dispatcher.registerHandler(Event.DeviceListUpdate::class, handler) - awaitClose { - // The current dispatcher doesn't support unregistration of handlers. - } - } - - val deviceRemovalResult = callbackFlow { - val handler: (Event.DeviceRemovalEvent) -> Unit = { event -> trySend(event) } - dispatcher.registerHandler(Event.DeviceRemovalEvent::class, handler) - awaitClose { - // The current dispatcher doesn't support unregistration of handlers. - } - } - - // Async result: Event.DeviceChanged - fun refreshDevice() { - connection.trySendRequest(Request.RefreshDeviceState, true) - } - - fun getDevice() { - connection.trySendRequest(Request.GetDevice, true) - } - - fun removeDevice(accountToken: String, deviceId: String) { - connection.trySendRequest(Request.RemoveDevice(accountToken, deviceId), true) - } - - fun refreshDeviceList(accountToken: String) { - connection.trySendRequest(Request.GetDeviceList(accountToken), true) - } -} 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 4e1d773f1e..315243a77c 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 @@ -1,149 +1,51 @@ package net.mullvad.mullvadvpn.ui.serviceconnection -import android.content.ComponentName import android.content.Context +import android.content.Context.BIND_AUTO_CREATE 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 -import kotlin.reflect.KClass -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.map -import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration -import net.mullvad.mullvadvpn.lib.endpoint.BuildConfig -import net.mullvad.mullvadvpn.lib.endpoint.putApiEndpointConfigurationExtra -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.MessageHandler -import net.mullvad.mullvadvpn.lib.ipc.Request import net.mullvad.mullvadvpn.service.MullvadVpnService -import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault -import net.mullvad.talpid.util.EventNotifier -class ServiceConnectionManager(private val context: Context) : MessageHandler { +class ServiceConnectionManager(private val context: Context) { private val _connectionState = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) + MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Unbound) val connectionState = _connectionState.asStateFlow() - // TODO: Remove after refactoring fragments to support flow. - @Deprecated(message = "Use connectionState") - val serviceNotifier = EventNotifier<ServiceConnectionContainer?>(null) + // Dummy service connection to be able to bind, all communication goes over gRPC. + private val serviceConnection = EmptyServiceConnection() - var isBound = false - private var vpnPermissionRequestHandler: (() -> Unit)? = null + @Synchronized + fun bind() { + if (_connectionState.value is ServiceConnectionState.Unbound) { + val intent = Intent(context, MullvadVpnService::class.java) - private val events = - connectionState.flatMapReadyConnectionOrDefault(emptyFlow()) { it.container.events } - - private val serviceConnection = - object : android.content.ServiceConnection { - override fun onServiceConnected(className: ComponentName, binder: IBinder) { - Log.d("mullvad", "UI successfully connected to the service") - - notify( - ServiceConnectionState.ConnectedNotReady( - ServiceConnectionContainer( - Messenger(binder), - ::handleNewServiceConnection, - ::handleVpnPermissionRequest - ) - ) + // We set BIND_AUTO_CREATE so that the service is started if it is not already running + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + context.bindService( + intent, + serviceConnection, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED or BIND_AUTO_CREATE ) + } else { + context.bindService(intent, serviceConnection, BIND_AUTO_CREATE) } - - override fun onServiceDisconnected(className: ComponentName) { - Log.d("mullvad", "UI lost the connection to the service") - _connectionState.value.readyContainer()?.onDestroy() - notify(ServiceConnectionState.Disconnected) - } - } - - fun bind( - vpnPermissionRequestHandler: () -> Unit, - apiEndpointConfiguration: ApiEndpointConfiguration? - ) { - synchronized(this) { - if (isBound.not()) { - this.vpnPermissionRequestHandler = vpnPermissionRequestHandler - val intent = Intent(context, MullvadVpnService::class.java) - - if (BuildConfig.DEBUG && apiEndpointConfiguration != null) { - intent.putApiEndpointConfigurationExtra(apiEndpointConfiguration) - } - - context.startService(intent) - 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 - } + _connectionState.value = ServiceConnectionState.Bound + } else { + error("Service is already bound") } } + @Synchronized fun unbind() { - synchronized(this) { - if (isBound) { - _connectionState.value.readyContainer()?.onDestroy() - context.unbindService(serviceConnection) - notify(ServiceConnectionState.Disconnected) - vpnPermissionRequestHandler = null - isBound = false - } - } - } - - override fun <E : Event> events(klass: KClass<E>): Flow<E> { - return events.map { it }.filterIsInstance(klass) - } - - override fun trySendRequest(request: Request): Boolean { - return connectionState.value.readyContainer()?.trySendRequest(request, logErrors = false) - ?: false - } - - fun onDestroy() { - _connectionState.value.readyContainer()?.onDestroy() - serviceNotifier.unsubscribeAll() - notify(ServiceConnectionState.Disconnected) - vpnPermissionRequestHandler = null - } - - fun onVpnPermissionResult(isGranted: Boolean) { - _connectionState.value.let { state -> - if (state is ServiceConnectionState.ConnectedReady) { - state.container.vpnPermission.grant(isGranted) - } - } - } - - private fun notify(state: ServiceConnectionState) { - _connectionState.value = state - - // TODO: Remove once `serviceNotifier` is no longer used. - if (state is ServiceConnectionState.ConnectedReady) { - serviceNotifier.notify(state.container) - } else if (state is ServiceConnectionState.Disconnected) { - serviceNotifier.notify(null) + if (_connectionState.value is ServiceConnectionState.Bound) { + context.unbindService(serviceConnection) + _connectionState.value = ServiceConnectionState.Unbound + } else { + error("Service is not bound") } } - - private fun handleVpnPermissionRequest() { - vpnPermissionRequestHandler?.invoke() - } - - private fun handleNewServiceConnection(serviceConnectionContainer: ServiceConnectionContainer) { - notify(ServiceConnectionState.ConnectedReady(serviceConnectionContainer)) - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt deleted file mode 100644 index 31ac1befdc..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt +++ /dev/null @@ -1,24 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -fun ServiceConnectionManager.appVersionInfoCache() = - this.connectionState.value.readyContainer()?.appVersionInfoCache - -fun ServiceConnectionManager.authTokenCache() = - this.connectionState.value.readyContainer()?.authTokenCache - -fun ServiceConnectionManager.connectionProxy() = - this.connectionState.value.readyContainer()?.connectionProxy - -fun ServiceConnectionManager.deviceDataSource() = - this.connectionState.value.readyContainer()?.deviceDataSource - -fun ServiceConnectionManager.customDns() = this.connectionState.value.readyContainer()?.customDns - -fun ServiceConnectionManager.settingsListener() = - this.connectionState.value.readyContainer()?.settingsListener - -fun ServiceConnectionManager.splitTunneling() = - this.connectionState.value.readyContainer()?.splitTunneling - -fun ServiceConnectionManager.voucherRedeemer() = - this.connectionState.value.readyContainer()?.voucherRedeemer diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionState.kt index ca868e5cfa..7747844673 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionState.kt @@ -1,14 +1,7 @@ package net.mullvad.mullvadvpn.ui.serviceconnection sealed class ServiceConnectionState { - data class ConnectedReady(val container: ServiceConnectionContainer) : ServiceConnectionState() + data object Bound : ServiceConnectionState() - data class ConnectedNotReady(val container: ServiceConnectionContainer) : - ServiceConnectionState() - - object Disconnected : ServiceConnectionState() - - fun readyContainer(): ServiceConnectionContainer? { - return (this as? ConnectedReady)?.container - } + data object Unbound : ServiceConnectionState() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt deleted file mode 100644 index e2ccc2e470..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt +++ /dev/null @@ -1,75 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.Messenger -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.ObfuscationSettings -import net.mullvad.mullvadvpn.model.QuantumResistantState -import net.mullvad.mullvadvpn.model.RelaySettings -import net.mullvad.mullvadvpn.model.Settings -import net.mullvad.talpid.util.EventNotifier - -class SettingsListener(private val connection: Messenger, eventDispatcher: EventDispatcher) { - val relaySettingsNotifier = EventNotifier<RelaySettings?>(null) - val settingsNotifier = EventNotifier<Settings?>(null) - - private var settings by settingsNotifier.notifiable() - - var allowLan: Boolean - get() = settingsNotifier.latestEvent?.allowLan ?: false - set(value) { - connection.send(Request.SetAllowLan(value).message) - } - - var autoConnect: Boolean - get() = settingsNotifier.latestEvent?.autoConnect ?: false - set(value) { - connection.send(Request.SetAutoConnect(value).message) - } - - var wireguardMtu: Int? - get() = settingsNotifier.latestEvent?.tunnelOptions?.wireguard?.mtu - set(value) { - connection.send(Request.SetWireGuardMtu(value).message) - } - - var wireguardQuantumResistant: QuantumResistantState - get() = - settingsNotifier.latestEvent?.tunnelOptions?.wireguard?.quantumResistant - ?: QuantumResistantState.Off - set(value) { - connection.send(Request.SetWireGuardQuantumResistant(value).message) - } - - var obfuscationSettings: ObfuscationSettings? - get() = settingsNotifier.latestEvent?.obfuscationSettings - set(value) { - connection.send(Request.SetObfuscationSettings(value).message) - } - - init { - eventDispatcher.registerHandler(Event.SettingsUpdate::class, ::handleNewEvent) - } - - fun onDestroy() { - relaySettingsNotifier.unsubscribeAll() - settingsNotifier.unsubscribeAll() - } - - private fun handleNewEvent(event: Event.SettingsUpdate) { - event.settings?.let { settings -> handleNewSettings(settings) } - } - - private fun handleNewSettings(newSettings: Settings) { - if (settings?.relaySettings != newSettings.relaySettings) { - relaySettingsNotifier.notify(newSettings.relaySettings) - } - - settings = newSettings - } - - fun applySettingsPatch(json: String) { - connection.send(Request.ApplyJsonSettings(json).message) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt deleted file mode 100644 index 666d772184..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SplitTunneling.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.Messenger -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher -import net.mullvad.mullvadvpn.lib.ipc.Request - -class SplitTunneling(private val connection: Messenger, eventDispatcher: EventDispatcher) { - private var _excludedApps by - observable(emptySet<String>()) { _, _, apps -> excludedAppsChange.invoke(apps) } - - var enabled by observable(false) { _, _, isEnabled -> enabledChange.invoke(isEnabled) } - - var enabledChange: (enabled: Boolean) -> Unit = {} - set(value) { - field = value - synchronized(this) { value.invoke(enabled) } - } - - var excludedAppsChange: (apps: Set<String>) -> Unit = {} - set(value) { - field = value - synchronized(this) { value.invoke(_excludedApps) } - } - - init { - eventDispatcher.registerHandler(Event.SplitTunnelingUpdate::class) { event -> - if (event.excludedApps != null) { - enabled = true - _excludedApps = event.excludedApps!!.toSet() - } else { - enabled = false - } - } - } - - fun excludeApp(appPackageName: String) = - connection.send(Request.ExcludeApp(appPackageName).message) - - fun includeApp(appPackageName: String) = - connection.send(Request.IncludeApp(appPackageName).message) - - fun persist() = connection.send(Request.PersistExcludedApps.message) - - fun enableSplitTunneling(isEnabled: Boolean) = - connection.send(Request.SetEnableSplitTunneling(isEnabled).message) -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VoucherRedeemer.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VoucherRedeemer.kt deleted file mode 100644 index fbf082ba3c..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VoucherRedeemer.kt +++ /dev/null @@ -1,41 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.Messenger -import kotlinx.coroutines.CompletableDeferred -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.MessageDispatcher -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.VoucherSubmissionResult - -class VoucherRedeemer(val connection: Messenger, eventDispatcher: MessageDispatcher<Event>) { - private val activeSubmissions = - mutableMapOf<String, CompletableDeferred<VoucherSubmissionResult>>() - - init { - eventDispatcher.registerHandler(Event.VoucherSubmissionResult::class) { event -> - synchronized(this@VoucherRedeemer) { - activeSubmissions.remove(event.voucher)?.complete(event.result) - } - } - } - - suspend fun submit(voucher: String): VoucherSubmissionResult { - val result = CompletableDeferred<VoucherSubmissionResult>() - - synchronized(this) { activeSubmissions.put(voucher, result) } - - connection.send(Request.SubmitVoucher(voucher).message) - - return result.await() - } - - fun onDestroy() { - synchronized(this) { - for ((_, submission) in activeSubmissions) { - submission.cancel() - } - - activeSubmissions.clear() - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VpnPermission.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VpnPermission.kt deleted file mode 100644 index 143a01d719..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/VpnPermission.kt +++ /dev/null @@ -1,20 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.Messenger -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.MessageDispatcher -import net.mullvad.mullvadvpn.lib.ipc.Request - -class VpnPermission(private val connection: Messenger, eventDispatcher: MessageDispatcher<Event>) { - var onRequest: (() -> Unit)? = null - - init { - eventDispatcher.registerHandler(Event.VpnPermissionRequest::class) { _ -> - onRequest?.invoke() - } - } - - fun grant(isGranted: Boolean) { - connection.send(Request.VpnPermissionResponse(isGranted).message) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt index a4961bafe7..65822788cb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCase.kt @@ -4,8 +4,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.repository.InAppNotification import org.joda.time.DateTime @@ -13,19 +13,19 @@ class AccountExpiryNotificationUseCase( private val accountRepository: AccountRepository, ) { fun notifications(): Flow<List<InAppNotification>> = - accountRepository.accountExpiryState + accountRepository.accountData .map(::accountExpiryNotification) .map(::listOfNotNull) .distinctUntilChanged() - private fun accountExpiryNotification(accountExpiry: AccountExpiry) = - if (accountExpiry.isCloseToExpiring()) { - InAppNotification.AccountExpiry(accountExpiry.date() ?: DateTime.now()) + private fun accountExpiryNotification(accountData: AccountData?) = + if (accountData != null && accountData.expiryDate.isCloseToExpiring()) { + InAppNotification.AccountExpiry(accountData.expiryDate) } else null - private fun AccountExpiry.isCloseToExpiring(): Boolean { + private fun DateTime.isCloseToExpiring(): Boolean { val threeDaysFromNow = DateTime.now().plusDays(ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD_DAYS) - return this.date()?.isBefore(threeDaysFromNow) == true + return isBefore(threeDaysFromNow) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AvailableProvidersUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AvailableProvidersUseCase.kt new file mode 100644 index 0000000000..f79c0421f6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AvailableProvidersUseCase.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.repository.RelayListRepository + +class AvailableProvidersUseCase(private val relayListRepository: RelayListRepository) { + + fun availableProviders(): Flow<List<Provider>> = + relayListRepository.relayList.map { relayList -> + relayList + .flatMap(RelayItem.Location.Country::cities) + .flatMap(RelayItem.Location.City::relays) + .map(RelayItem.Location.Relay::provider) + .distinct() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt new file mode 100644 index 0000000000..265c127227 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/FilteredRelayListUseCase.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.flow.combine +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.relaylist.filterOnOwnershipAndProvider +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository + +class FilteredRelayListUseCase( + private val relayListRepository: RelayListRepository, + private val relayListFilterRepository: RelayListFilterRepository +) { + fun filteredRelayList() = + combine( + relayListRepository.relayList, + relayListFilterRepository.selectedOwnership, + relayListFilterRepository.selectedProviders, + ) { relayList, selectedOwnership, selectedProviders -> + relayList.filterOnOwnershipAndProvider( + selectedOwnership, + selectedProviders, + ) + } + + private fun List<RelayItem.Location.Country>.filterOnOwnershipAndProvider( + ownership: Constraint<Ownership>, + providers: Constraint<Providers> + ) = mapNotNull { it.filterOnOwnershipAndProvider(ownership, providers) } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/LastKnownLocationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/LastKnownLocationUseCase.kt new file mode 100644 index 0000000000..67bc12cc92 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/LastKnownLocationUseCase.kt @@ -0,0 +1,24 @@ +package net.mullvad.mullvadvpn.usecase + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy + +class LastKnownLocationUseCase( + connectionProxy: ConnectionProxy, + dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + val lastKnownDisconnectedLocation: Flow<GeoIpLocation?> = + connectionProxy.tunnelState + .filterIsInstance<TunnelState.Disconnected>() + .mapNotNull { it.location } + .stateIn(CoroutineScope(dispatcher), SharingStarted.Lazily, null) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt index 628cc555ec..06d26a76e8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt @@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.repository.InAppNotification class NewDeviceNotificationUseCase(private val deviceRepository: DeviceRepository) { @@ -12,7 +12,7 @@ class NewDeviceNotificationUseCase(private val deviceRepository: DeviceRepositor fun notifications() = combine( - deviceRepository.deviceState.map { it.deviceName() }.distinctUntilChanged(), + deviceRepository.deviceState.map { it?.displayName() }, _mutableShowNewDeviceNotification ) { deviceName, newDeviceCreated -> if (newDeviceCreated && deviceName != null) { 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 index 88ec42f986..a86124c8a9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCase.kt @@ -13,18 +13,15 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -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 net.mullvad.mullvadvpn.lib.model.ErrorStateCause +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import org.joda.time.DateTime class OutOfTimeUseCase( + private val connectionProxy: ConnectionProxy, private val repository: AccountRepository, - private val messageHandler: MessageHandler, scope: CoroutineScope ) { @@ -47,9 +44,8 @@ class OutOfTimeUseCase( } private fun isTunnelBlockedBecauseOutOfTime(): Flow<Boolean> = - messageHandler - .events<Event.TunnelStateChange>() - .map { it.tunnelState.isTunnelErrorStateDueToExpiredAccount() } + connectionProxy.tunnelState + .map { it.isTunnelErrorStateDueToExpiredAccount() } .onStart { emit(false) } private fun TunnelState.isTunnelErrorStateDueToExpiredAccount(): Boolean { @@ -58,11 +54,11 @@ class OutOfTimeUseCase( } private fun pastAccountExpiry(): Flow<Boolean?> = - repository.accountExpiryState + repository.accountData .flatMapLatest { - if (it is AccountExpiry.Available) { + if (it != null) { flow { - val millisUntilExpiry = it.expiryDateTime.millis - DateTime.now().millis + val millisUntilExpiry = it.expiryDate.millis - DateTime.now().millis if (millisUntilExpiry > 0) { emit(false) delay(millisUntilExpiry) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PortRangeUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PortRangeUseCase.kt deleted file mode 100644 index 2b104cda39..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PortRangeUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package net.mullvad.mullvadvpn.usecase - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import net.mullvad.mullvadvpn.model.PortRange -import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener - -class PortRangeUseCase(private val relayListListener: RelayListListener) { - fun portRanges(): Flow<List<PortRange>> = - relayListListener.relayListEvents - .map { it?.wireguardEndpointData?.portRanges ?: emptyList() } - .distinctUntilChanged() -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListFilterUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListFilterUseCase.kt deleted file mode 100644 index f480e6a23a..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListFilterUseCase.kt +++ /dev/null @@ -1,44 +0,0 @@ -package net.mullvad.mullvadvpn.usecase - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.model.Providers -import net.mullvad.mullvadvpn.model.RelayListCity -import net.mullvad.mullvadvpn.model.RelayListCountry -import net.mullvad.mullvadvpn.relaylist.Provider -import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener - -class RelayListFilterUseCase( - private val relayListListener: RelayListListener, - private val settingsRepository: SettingsRepository -) { - fun updateOwnershipAndProviderFilter( - ownership: Constraint<Ownership>, - providers: Constraint<Providers> - ) { - relayListListener.updateSelectedOwnershipAndProviderFilter(ownership, providers) - } - - fun selectedOwnership(): Flow<Constraint<Ownership>> = - settingsRepository.settingsUpdates.map { settings -> - settings?.relaySettings?.relayConstraints()?.ownership ?: Constraint.Any() - } - - fun selectedProviders(): Flow<Constraint<Providers>> = - settingsRepository.settingsUpdates.map { settings -> - settings?.relaySettings?.relayConstraints()?.providers ?: Constraint.Any() - } - - fun availableProviders(): Flow<List<Provider>> = - relayListListener.relayListEvents.map { relayList -> - relayList.countries - .flatMap(RelayListCountry::cities) - .flatMap(RelayListCity::relays) - .filter { relay -> relay.isWireguardRelay } - .map { relay -> Provider(relay.provider, relay.owned) } - .distinct() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt deleted file mode 100644 index b4197fe7b7..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt +++ /dev/null @@ -1,90 +0,0 @@ -package net.mullvad.mullvadvpn.usecase - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.LocationConstraint -import net.mullvad.mullvadvpn.model.RelaySettings -import net.mullvad.mullvadvpn.model.WireguardConstraints -import net.mullvad.mullvadvpn.relaylist.RelayItem -import net.mullvad.mullvadvpn.relaylist.RelayList -import net.mullvad.mullvadvpn.relaylist.filterOnOwnershipAndProvider -import net.mullvad.mullvadvpn.relaylist.findItemForGeographicLocationConstraint -import net.mullvad.mullvadvpn.relaylist.toRelayCountries -import net.mullvad.mullvadvpn.relaylist.toRelayItemLists -import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener - -class RelayListUseCase( - private val relayListListener: RelayListListener, - private val settingsRepository: SettingsRepository -) { - - fun updateSelectedRelayLocation(value: LocationConstraint) { - relayListListener.updateSelectedRelayLocation(value) - } - - fun updateSelectedWireguardConstraints(value: WireguardConstraints) { - relayListListener.updateSelectedWireguardConstraints(value) - } - - fun relayListWithSelection(): Flow<RelayList> = - combine(relayListListener.relayListEvents, settingsRepository.settingsUpdates) { - relayList, - settings -> - val ownership = - settings?.relaySettings?.relayConstraints()?.ownership ?: Constraint.Any() - val providers = - settings?.relaySettings?.relayConstraints()?.providers ?: Constraint.Any() - val relayCountries = relayList.toRelayCountries() - val customLists = - settings?.customLists?.customLists?.toRelayItemLists(relayCountries) ?: emptyList() - val relayCountriesFiltered = - relayCountries.mapNotNull { it.filterOnOwnershipAndProvider(ownership, providers) } - val selectedItem = - findSelectedRelayItem( - relaySettings = settings?.relaySettings, - relayCountries = relayCountries, - customLists = customLists, - ) - RelayList( - customLists = customLists, - allCountries = relayCountries, - filteredCountries = relayCountriesFiltered, - selectedItem = selectedItem - ) - } - - fun selectedRelayItem(): Flow<RelayItem?> = relayListWithSelection().map { it.selectedItem } - - fun fullRelayList(): Flow<List<RelayItem.Country>> = - relayListWithSelection().map { it.allCountries } - - fun customLists(): Flow<List<RelayItem.CustomList>> = - relayListWithSelection().map { it.customLists } - - fun fetchRelayList() { - relayListListener.fetchRelayList() - } - - private fun findSelectedRelayItem( - relaySettings: RelaySettings?, - relayCountries: List<RelayItem.Country>, - customLists: List<RelayItem.CustomList> - ): RelayItem? { - val locationConstraint = relaySettings?.relayConstraints()?.location - return if (locationConstraint is Constraint.Only) { - when (val location = locationConstraint.value) { - is LocationConstraint.CustomList -> { - customLists.firstOrNull { it.id == location.listId } - } - is LocationConstraint.Location -> { - relayCountries.findItemForGeographicLocationConstraint(location.location) - } - } - } else { - null - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationTitleUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationTitleUseCase.kt new file mode 100644 index 0000000000..a37e33492d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SelectedLocationTitleUseCase.kt @@ -0,0 +1,56 @@ +package net.mullvad.mullvadvpn.usecase + +import arrow.core.raise.nullable +import kotlinx.coroutines.flow.combine +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.relaylist.findByGeoLocationId +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository + +class SelectedLocationTitleUseCase( + private val customListsRepository: CustomListsRepository, + private val relayListRepository: RelayListRepository, +) { + fun selectedLocationTitle() = + combine( + customListsRepository.customLists, + relayListRepository.relayList, + relayListRepository.selectedLocation + ) { customLists, relayList, selectedLocation -> + if (selectedLocation is Constraint.Only) { + createRelayItemTitle(selectedLocation.value, relayList, customLists ?: emptyList()) + } else { + null + } + } + + private fun createRelayItemTitle( + relayItemId: RelayItemId, + relayCountries: List<RelayItem.Location.Country>, + customLists: List<CustomList> + ): String? = + when (relayItemId) { + is CustomListId -> customLists.firstOrNull { it.id == relayItemId }?.name?.value + is GeoLocationId.Hostname -> createRelayTitle(relayCountries, relayItemId) + is GeoLocationId.City -> relayCountries.findByGeoLocationId(relayItemId)?.name + is GeoLocationId.Country -> relayCountries.firstOrNull { it.id == relayItemId }?.name + } + + private fun createRelayTitle( + relayCountries: List<RelayItem.Location.Country>, + relayItemId: GeoLocationId.Hostname + ): String? = nullable { + val city = relayCountries.findByGeoLocationId(relayItemId.city).bind() + val relay = city.relays.firstOrNull { it.id == relayItemId }.bind() + + relay.formatTitle(city) + } + + private fun RelayItem.Location.Relay.formatTitle(city: RelayItem.Location.City) = + "${city.name} (${name})" +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt index dec794c86c..ce0878c517 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt @@ -2,28 +2,18 @@ package net.mullvad.mullvadvpn.usecase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.repository.InAppNotification -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -class TunnelStateNotificationUseCase( - private val serviceConnectionManager: ServiceConnectionManager, -) { +class TunnelStateNotificationUseCase(private val connectionProxy: ConnectionProxy) { fun notifications(): Flow<List<InAppNotification>> = - serviceConnectionManager.connectionState - .flatMapReadyConnectionOrDefault(flowOf(emptyList())) { - it.container.connectionProxy - .tunnelUiStateFlow() - .distinctUntilChanged() - .map(::tunnelStateNotification) - .map(::listOfNotNull) - } + connectionProxy.tunnelState + .distinctUntilChanged() + .map(::tunnelStateNotification) + .map(::listOfNotNull) .distinctUntilChanged() private fun tunnelStateNotification(tunnelUiState: TunnelState): InAppNotification? = @@ -41,7 +31,4 @@ class TunnelStateNotificationUseCase( is TunnelState.Connected, is TunnelState.Disconnected -> null } - - private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> = - callbackFlowFromNotifier(this.onUiStateChange) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt index 28496c4639..b7dc50a241 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt @@ -1,28 +1,24 @@ package net.mullvad.mullvadvpn.usecase import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.util.appVersionCallbackFlow -import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository class VersionNotificationUseCase( - private val serviceConnectionManager: ServiceConnectionManager, + private val appVersionInfoRepository: AppVersionInfoRepository, private val isVersionInfoNotificationEnabled: Boolean, ) { fun notifications() = - serviceConnectionManager.connectionState - .flatMapReadyConnectionOrDefault(flowOf(emptyList())) { - it.container.appVersionInfoCache.appVersionCallbackFlow().map { versionInfo -> - listOfNotNull( - unsupportedVersionNotification(versionInfo), - updateAvailableNotification(versionInfo) - ) - } + appVersionInfoRepository + .versionInfo() + .map { versionInfo -> + listOfNotNull( + unsupportedVersionNotification(versionInfo), + updateAvailableNotification(versionInfo) + ) } .distinctUntilChanged() @@ -31,7 +27,7 @@ class VersionNotificationUseCase( return null } - return if (versionInfo.isOutdated) { + return if (versionInfo.isUpdateAvailable) { InAppNotification.UpdateAvailable(versionInfo) } else null } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt index 16c86c0d59..180381f771 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt @@ -1,22 +1,30 @@ package net.mullvad.mullvadvpn.usecase.customlists +import arrow.core.Either +import arrow.core.raise.either import kotlinx.coroutines.flow.firstOrNull +import net.mullvad.mullvadvpn.compose.communication.Created import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult -import net.mullvad.mullvadvpn.model.CreateCustomListResult -import net.mullvad.mullvadvpn.model.CustomList -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.UpdateCustomListResult +import net.mullvad.mullvadvpn.compose.communication.CustomListSuccess +import net.mullvad.mullvadvpn.compose.communication.Deleted +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged +import net.mullvad.mullvadvpn.compose.communication.Renamed +import net.mullvad.mullvadvpn.lib.model.CreateCustomListError +import net.mullvad.mullvadvpn.lib.model.DeleteCustomListError +import net.mullvad.mullvadvpn.lib.model.GetCustomListError +import net.mullvad.mullvadvpn.lib.model.UpdateCustomListLocationsError +import net.mullvad.mullvadvpn.lib.model.UpdateCustomListNameError import net.mullvad.mullvadvpn.relaylist.getRelayItemsByCodes import net.mullvad.mullvadvpn.repository.CustomListsRepository -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.repository.RelayListRepository class CustomListActionUseCase( private val customListsRepository: CustomListsRepository, - private val relayListUseCase: RelayListUseCase + private val relayListRepository: RelayListRepository ) { - suspend fun performAction(action: CustomListAction): Result<CustomListResult> { + suspend fun performAction( + action: CustomListAction + ): Either<CustomListActionError, CustomListSuccess> { return when (action) { is CustomListAction.Create -> { performAction(action) @@ -33,86 +41,101 @@ class CustomListActionUseCase( } } - suspend fun performAction(action: CustomListAction.Rename): Result<CustomListResult.Renamed> = - when ( - val result = - customListsRepository.updateCustomListName(action.customListId, action.newName) - ) { - is UpdateCustomListResult.Ok -> - Result.success(CustomListResult.Renamed(undo = action.not())) - is UpdateCustomListResult.Error -> Result.failure(CustomListsException(result.error)) - } + suspend fun performAction(action: CustomListAction.Rename): Either<RenameError, Renamed> = + customListsRepository + .updateCustomListName(action.id, action.newName) + .map { Renamed(undo = action.not()) } + .mapLeft(::RenameError) + + suspend fun performAction( + action: CustomListAction.Create + ): Either<CreateWithLocationsError, Created> = either { + val customListId = + customListsRepository + .createCustomList(action.name) + .mapLeft(CreateWithLocationsError::Create) + .bind() + + val locationNames = + if (action.locations.isNotEmpty()) { + customListsRepository + .updateCustomListLocations(customListId, action.locations) + .mapLeft(CreateWithLocationsError::UpdateLocations) + .bind() - suspend fun performAction(action: CustomListAction.Create): Result<CustomListResult.Created> = - when (val result = customListsRepository.createCustomList(action.name)) { - is CreateCustomListResult.Ok -> { - if (action.locations.isNotEmpty()) { - customListsRepository.updateCustomListLocationsFromCodes( - result.id, - action.locations - ) - val locationNames = - relayListUseCase - .fullRelayList() - .firstOrNull() - ?.getRelayItemsByCodes(action.locations) - ?.map { it.name } - Result.success( - CustomListResult.Created( - id = result.id, - name = action.name, - locationName = locationNames?.firstOrNull(), - undo = action.not(result.id) - ) - ) - } else { - Result.success( - CustomListResult.Created( - id = result.id, - name = action.name, - locationName = null, - undo = action.not(result.id) - ) - ) - } + relayListRepository.relayList + .firstOrNull() + ?.getRelayItemsByCodes(action.locations) + ?.map { it.name } ?: raise(CreateWithLocationsError.UnableToFetchRelayList) + } else { + emptyList() } - is CreateCustomListResult.Error -> Result.failure(CustomListsException(result.error)) - } - fun performAction(action: CustomListAction.Delete): Result<CustomListResult.Deleted> { - val customList: CustomList = customListsRepository.getCustomListById(action.customListId)!! - val oldLocations = customList.locations() - val name = CustomListName.fromString(customList.name) - customListsRepository.deleteCustomList(action.customListId) - return Result.success( - CustomListResult.Deleted(undo = action.not(locations = oldLocations, name = name)) + Created( + id = customListId, + name = action.name, + locationNames = locationNames, + undo = action.not(customListId) ) } suspend fun performAction( + action: CustomListAction.Delete + ): Either<DeleteWithUndoError, Deleted> = either { + val customList = + customListsRepository + .getCustomListById(action.id) + .mapLeft(DeleteWithUndoError::Fetch) + .bind() + customListsRepository + .deleteCustomList(action.id) + .mapLeft(DeleteWithUndoError::Delete) + .bind() + Deleted(undo = action.not(locations = customList.locations, name = customList.name)) + } + + suspend fun performAction( action: CustomListAction.UpdateLocations - ): Result<CustomListResult.LocationsChanged> { - val customList = customListsRepository.getCustomListById(action.customListId)!! - val oldLocations = customList.locations() - val name = CustomListName.fromString(customList.name) - customListsRepository.updateCustomListLocationsFromCodes( - action.customListId, - action.locations - ) - return Result.success( - CustomListResult.LocationsChanged( - name = name, - undo = action.not(locations = oldLocations) - ) + ): Either<UpdateLocationsError, LocationsChanged> = either { + val customList = + customListsRepository + .getCustomListById(action.id) + .mapLeft(UpdateLocationsError::Fetch) + .bind() + customListsRepository + .updateCustomListLocations(action.id, action.locations) + .mapLeft(UpdateLocationsError::UpdateLocations) + .bind() + LocationsChanged( + name = customList.name, + undo = action.not(locations = customList.locations) ) } +} - private fun CustomList?.locations(): List<String> = - this?.locations?.map { - when (it) { - is GeographicLocationConstraint.City -> it.cityCode - is GeographicLocationConstraint.Country -> it.countryCode - is GeographicLocationConstraint.Hostname -> it.hostname - } - } ?: emptyList() +sealed interface CustomListActionError + +sealed interface CreateWithLocationsError : CustomListActionError { + + data class Create(val error: CreateCustomListError) : CreateWithLocationsError + + data class UpdateLocations(val error: UpdateCustomListLocationsError) : + CreateWithLocationsError + + data object UnableToFetchRelayList : CreateWithLocationsError +} + +sealed interface DeleteWithUndoError : CustomListActionError { + data class Fetch(val error: GetCustomListError) : DeleteWithUndoError + + data class Delete(val error: DeleteCustomListError) : DeleteWithUndoError +} + +data class RenameError(val error: UpdateCustomListNameError) : CustomListActionError + +sealed interface UpdateLocationsError : CustomListActionError { + + data class Fetch(val error: GetCustomListError) : UpdateLocationsError + + data class UpdateLocations(val error: UpdateCustomListLocationsError) : UpdateLocationsError } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListRelayItemsUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListRelayItemsUseCase.kt new file mode 100644 index 0000000000..d28bfe1d55 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListRelayItemsUseCase.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.usecase.customlists + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.mapNotNull +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.relaylist.getById +import net.mullvad.mullvadvpn.relaylist.getRelayItemsByCodes +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository + +class CustomListRelayItemsUseCase( + private val customListsRepository: CustomListsRepository, + private val relayListRepository: RelayListRepository +) { + fun getRelayItemLocationsForCustomList( + customListId: CustomListId + ): Flow<List<RelayItem.Location>> = + combine( + customListsRepository.customLists.mapNotNull { it?.getById(customListId) }, + relayListRepository.relayList + ) { customList, countries -> + countries.getRelayItemsByCodes(customList.locations) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsException.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsException.kt deleted file mode 100644 index 07c37f7333..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package net.mullvad.mullvadvpn.usecase.customlists - -import net.mullvad.mullvadvpn.model.CustomListsError - -class CustomListsException(val error: CustomListsError) : Throwable() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsRelayItemUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsRelayItemUseCase.kt new file mode 100644 index 0000000000..015aa8ab4f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsRelayItemUseCase.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.usecase.customlists + +import kotlinx.coroutines.flow.combine +import net.mullvad.mullvadvpn.relaylist.toRelayItemCustomList +import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository + +class CustomListsRelayItemUseCase( + private val customListsRepository: CustomListsRepository, + private val relayListRepository: RelayListRepository, +) { + + fun relayItemCustomLists() = + combine(customListsRepository.customLists, relayListRepository.relayList) { + customLists, + relayList -> + customLists?.map { it.toRelayItemCustomList(relayList) } ?: emptyList() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CacheExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CacheExtensions.kt deleted file mode 100644 index ae79c1364f..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CacheExtensions.kt +++ /dev/null @@ -1,20 +0,0 @@ -package net.mullvad.mullvadvpn.util - -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache - -fun AppVersionInfoCache.appVersionCallbackFlow() = callbackFlow { - this@appVersionCallbackFlow.onUpdate = { - trySend( - VersionInfo( - currentVersion = this@appVersionCallbackFlow.version, - upgradeVersion = this@appVersionCallbackFlow.upgradeVersion, - isOutdated = this@appVersionCallbackFlow.isOutdated, - isSupported = this@appVersionCallbackFlow.isSupported, - ) - ) - } - awaitClose { onUpdate = null } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DeviceStateExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DeviceStateExtensions.kt deleted file mode 100644 index 13b8f84599..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DeviceStateExtensions.kt +++ /dev/null @@ -1,21 +0,0 @@ -package net.mullvad.mullvadvpn.util - -import kotlin.reflect.KClass -import net.mullvad.mullvadvpn.model.DeviceState - -const val UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS = 2000L -private const val ZERO_DEBOUNCE_DELAY_MILLISECONDS = 0L - -fun DeviceState.addDebounceForUnknownState(delay: Long): Long { - return addDebounceForStates(delay, DeviceState.Unknown::class) -} - -fun <T> DeviceState.addDebounceForStates(delay: Long, vararg states: KClass<T>): Long where -T : DeviceState { - val result = states.any { this::class == it } - return if (result) { - delay - } else { - ZERO_DEBOUNCE_DELAY_MILLISECONDS - } -} 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 b3a8727df9..fbe44a5fea 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 @@ -3,37 +3,11 @@ package net.mullvad.mullvadvpn.util import kotlinx.coroutines.Deferred -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retryWhen -import kotlinx.coroutines.withTimeoutOrNull -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.talpid.util.EventNotifier - -fun <R> Flow<ServiceConnectionState>.flatMapReadyConnectionOrDefault( - default: Flow<R>, - transform: (value: ServiceConnectionState.ConnectedReady) -> Flow<R> -): Flow<R> { - return flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - transform.invoke(state) - } else { - default - } - } -} - -fun <T> callbackFlowFromNotifier(notifier: EventNotifier<T>) = callbackFlow { - val handler: (T) -> Unit = { value -> trySend(value) } - notifier.subscribe(this, handler) - awaitClose { notifier.unsubscribe(this) } -} inline fun <T1, T2, T3, T4, T5, T6, R> combine( flow: Flow<T1>, @@ -110,9 +84,6 @@ inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine( } } -suspend inline fun <T> Deferred<T>.awaitWithTimeoutOrNull(timeout: Long) = - withTimeoutOrNull(timeout) { await() } - fun <T> Deferred<T>.getOrDefault(default: T) = try { getCompleted() @@ -150,7 +121,3 @@ suspend inline fun <T> Flow<T>.retryWithExponentialBackOff( } class ExceptionWrapper(val item: Any) : Throwable() - -suspend fun <T> Flow<T>.firstOrNullWithTimeout(timeMillis: Long): T? { - return withTimeoutOrNull(timeMillis) { firstOrNull() } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GeoIpLocationExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GeoIpLocationExtensions.kt index b978caad53..d908f44158 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GeoIpLocationExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GeoIpLocationExtensions.kt @@ -1,6 +1,6 @@ package net.mullvad.mullvadvpn.util -import net.mullvad.mullvadvpn.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.GeoIpLocation fun GeoIpLocation.toOutAddress(): String = when { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/IntegerExtension.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/IntegerExtension.kt deleted file mode 100644 index a1a1d54b36..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/IntegerExtension.kt +++ /dev/null @@ -1,5 +0,0 @@ -package net.mullvad.mullvadvpn.util - -fun Int.isValidMtu(): Boolean { - return this in 1280..1420 -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortExtensions.kt index 0f0708707e..ac93b60d00 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortExtensions.kt @@ -1,8 +1,9 @@ package net.mullvad.mullvadvpn.util import net.mullvad.mullvadvpn.constant.WIREGUARD_PRESET_PORTS -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.Port +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange fun Constraint<Port>.hasValue(value: Int) = when (this) { @@ -16,8 +17,13 @@ fun Constraint<Port>.isCustom() = is Constraint.Only -> !WIREGUARD_PRESET_PORTS.contains(this.value.value) } -fun Constraint<Port>.toValueOrNull() = +fun Constraint<Port>.toPortOrNull() = when (this) { is Constraint.Any -> null - is Constraint.Only -> this.value.value + is Constraint.Only -> this.value } + +fun Port.inAnyOf(portRanges: List<PortRange>): Boolean = + portRanges.any { portRange -> this in portRange } + +fun List<PortRange>.asString() = joinToString(", ", transform = PortRange::toFormattedString) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortRangeExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortRangeExtensions.kt deleted file mode 100644 index 7b7fa8b104..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortRangeExtensions.kt +++ /dev/null @@ -1,19 +0,0 @@ -package net.mullvad.mullvadvpn.util - -import net.mullvad.mullvadvpn.model.PortRange - -fun List<PortRange>.isPortInValidRanges(port: Int) = - this.any { portRange -> portRange.from <= port && portRange.to >= port } - -fun List<PortRange>.asString() = buildString { - this@asString.forEachIndexed { index, range -> - if (index != 0) { - append(", ") - } - if (range.from == range.to) { - append(range.from) - } else { - append("${range.from}-${range.to}") - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt index 41e83465a1..f0cf46978b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt @@ -1,8 +1,14 @@ package net.mullvad.mullvadvpn.util +import android.text.Html +import androidx.core.text.HtmlCompat + fun String.appendHideNavOnPlayBuild(isPlayBuild: Boolean): String = if (isPlayBuild) { "$this?hide_nav" } else { this } + +fun String.removeHtmlTags(): String = + Html.fromHtml(this, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt index d39104e67a..d8c310b029 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/TunnelEndpointExtensions.kt @@ -1,7 +1,7 @@ package net.mullvad.mullvadvpn.util -import net.mullvad.talpid.net.TransportProtocol -import net.mullvad.talpid.net.TunnelEndpoint +import net.mullvad.mullvadvpn.lib.model.TransportProtocol +import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint fun TunnelEndpoint.toInAddress(): Triple<String, Int, TransportProtocol> { val relayEndpoint = this.obfuscation?.endpoint ?: this.endpoint 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 e3c0b226dd..0a497c22f6 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 @@ -11,22 +11,18 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.payment.model.ProductId -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.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.util.toPaymentState import org.joda.time.DateTime class AccountViewModel( private val accountRepository: AccountRepository, - private val serviceConnectionManager: ServiceConnectionManager, - private val paymentUseCase: PaymentUseCase, deviceRepository: DeviceRepository, + private val paymentUseCase: PaymentUseCase, private val isPlayBuild: Boolean, ) : ViewModel() { private val _uiSideEffect = Channel<UiSideEffect>() @@ -35,13 +31,13 @@ class AccountViewModel( val uiState: StateFlow<AccountUiState> = combine( deviceRepository.deviceState, - accountRepository.accountExpiryState, + accountRepository.accountData, paymentUseCase.paymentAvailability - ) { deviceState, accountExpiry, paymentAvailability -> + ) { deviceState, accountData, paymentAvailability -> AccountUiState( - deviceName = deviceState.deviceName() ?: "", - accountNumber = deviceState.token() ?: "", - accountExpiry = accountExpiry.date(), + deviceName = deviceState?.displayName() ?: "", + accountNumber = deviceState?.token()?.value ?: "", + accountExpiry = accountData?.expiryDate, showSitePayment = !isPlayBuild, billingPaymentState = paymentAvailability?.toPaymentState() ) @@ -56,17 +52,17 @@ class AccountViewModel( fun onManageAccountClick() { viewModelScope.launch { - _uiSideEffect.send( - UiSideEffect.OpenAccountManagementPageInBrowser( - serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" - ) - ) + accountRepository.getWebsiteAuthToken()?.let { wwwAuthToken -> + _uiSideEffect.send(UiSideEffect.OpenAccountManagementPageInBrowser(wwwAuthToken)) + } } } fun onLogoutClick() { - accountRepository.logout() - viewModelScope.launch { _uiSideEffect.send(UiSideEffect.NavigateToLogin) } + viewModelScope.launch { + accountRepository.logout() + _uiSideEffect.send(UiSideEffect.NavigateToLogin) + } } fun onCopyAccountNumber(accountNumber: String) { @@ -105,13 +101,13 @@ class AccountViewModel( } private fun updateAccountExpiry() { - accountRepository.fetchAccountExpiry() + viewModelScope.launch { accountRepository.getAccountData() } } sealed class UiSideEffect { data object NavigateToLogin : UiSideEffect() - data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect() + data class OpenAccountManagementPageInBrowser(val token: WebsiteAuthToken) : UiSideEffect() data class CopyAccountNumber(val accountNumber: String) : UiSideEffect() } @@ -127,9 +123,9 @@ data class AccountUiState( companion object { fun default() = AccountUiState( - deviceName = DeviceState.Unknown.deviceName(), - accountNumber = DeviceState.Unknown.token(), - accountExpiry = AccountExpiry.Missing.date(), + deviceName = null, + accountNumber = null, + accountExpiry = null, showSitePayment = false, billingPaymentState = PaymentState.Loading, ) 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 6b17592b8e..7b15e74a0e 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 @@ -7,12 +7,12 @@ 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.lib.model.BuildVersion import net.mullvad.mullvadvpn.repository.ChangelogRepository class ChangelogViewModel( private val changelogRepository: ChangelogRepository, - private val buildVersionCode: Int, + private val buildVersion: BuildVersion, private val alwaysShowChangelog: Boolean ) : ViewModel() { @@ -22,18 +22,18 @@ class ChangelogViewModel( init { if (shouldShowChangelog()) { val changelog = - Changelog(BuildConfig.VERSION_NAME, changelogRepository.getLastVersionChanges()) + Changelog(buildVersion.name, changelogRepository.getLastVersionChanges()) viewModelScope.launch { _uiSideEffect.emit(changelog) } } } fun markChangelogAsRead() { - changelogRepository.setVersionCodeOfMostRecentChangelogShowed(buildVersionCode) + changelogRepository.setVersionCodeOfMostRecentChangelogShowed(buildVersion.code) } private fun shouldShowChangelog(): Boolean = alwaysShowChangelog || - (changelogRepository.getVersionCodeOfMostRecentChangelogShowed() < buildVersionCode && + (changelogRepository.getVersionCodeOfMostRecentChangelogShowed() < buildVersion.code && changelogRepository.getLastVersionChanges().isNotEmpty()) } 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 bebb0d6e42..c98ce4fa59 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 @@ -4,57 +4,49 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.ConnectUiState -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.model.GeoIpLocation -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.ConnectError +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.lib.shared.VpnPermissionRepository import net.mullvad.mullvadvpn.repository.InAppNotificationController -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.usecase.RelayListUseCase -import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier +import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase import net.mullvad.mullvadvpn.util.combine import net.mullvad.mullvadvpn.util.daysFromNow import net.mullvad.mullvadvpn.util.toInAddress import net.mullvad.mullvadvpn.util.toOutAddress -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -@OptIn(FlowPreview::class) +@Suppress("LongParameterList") class ConnectViewModel( - private val serviceConnectionManager: ServiceConnectionManager, - accountRepository: AccountRepository, + private val accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, - private val inAppNotificationController: InAppNotificationController, + inAppNotificationController: InAppNotificationController, private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase, - private val relayListUseCase: RelayListUseCase, + selectedLocationTitleUseCase: SelectedLocationTitleUseCase, private val outOfTimeUseCase: OutOfTimeUseCase, private val paymentUseCase: PaymentUseCase, + private val connectionProxy: ConnectionProxy, + lastKnownLocationUseCase: LastKnownLocationUseCase, + private val vpnPermissionRepository: VpnPermissionRepository, private val isPlayBuild: Boolean ) : ViewModel() { private val _uiSideEffect = Channel<UiSideEffect>() @@ -62,124 +54,114 @@ class ConnectViewModel( val uiSideEffect = merge(_uiSideEffect.receiveAsFlow(), outOfTimeEffect(), revokedDeviceEffect()) - private val _shared: SharedFlow<ServiceConnectionContainer> = - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - flowOf(state.container) - } else { - emptyFlow() - } - } - .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) - + @OptIn(FlowPreview::class) val uiState: StateFlow<ConnectUiState> = - _shared - .flatMapLatest { serviceConnection -> - combine( - relayListUseCase.selectedRelayItem(), - inAppNotificationController.notifications, - serviceConnection.connectionProxy.tunnelUiStateFlow(), - serviceConnection.connectionProxy.tunnelRealStateFlow(), - serviceConnection.connectionProxy.lastKnownDisconnectedLocation(), - accountRepository.accountExpiryState, - deviceRepository.deviceState.map { it.deviceName() } - ) { - selectedRelayItem, - notifications, - tunnelUiState, - tunnelRealState, - lastKnownDisconnectedLocation, - accountExpiry, - deviceName -> - ConnectUiState( - location = - when (tunnelRealState) { - is TunnelState.Disconnected -> - tunnelRealState.location() ?: lastKnownDisconnectedLocation - is TunnelState.Connecting -> - tunnelRealState.location ?: selectedRelayItem?.location() - is TunnelState.Connected -> tunnelRealState.location - is TunnelState.Disconnecting -> lastKnownDisconnectedLocation - is TunnelState.Error -> null - }, - selectedRelayItem = selectedRelayItem, - tunnelUiState = tunnelUiState, - tunnelRealState = tunnelRealState, - inAddress = - when (tunnelRealState) { - is TunnelState.Connected -> tunnelRealState.endpoint.toInAddress() - is TunnelState.Connecting -> tunnelRealState.endpoint?.toInAddress() - else -> null - }, - outAddress = tunnelRealState.location()?.toOutAddress() ?: "", - showLocation = - when (tunnelUiState) { - is TunnelState.Disconnected -> true - is TunnelState.Disconnecting -> { - when (tunnelUiState.actionAfterDisconnect) { - ActionAfterDisconnect.Nothing -> false - ActionAfterDisconnect.Block -> true - ActionAfterDisconnect.Reconnect -> false - } + combine( + selectedLocationTitleUseCase.selectedLocationTitle(), + inAppNotificationController.notifications, + connectionProxy.tunnelState, + lastKnownLocationUseCase.lastKnownDisconnectedLocation, + accountRepository.accountData, + deviceRepository.deviceState.map { it?.displayName() } + ) { + selectedRelayItemTitle, + notifications, + tunnelState, + lastKnownDisconnectedLocation, + accountData, + deviceName -> + ConnectUiState( + location = + when (tunnelState) { + is TunnelState.Disconnected -> + tunnelState.location() ?: lastKnownDisconnectedLocation + is TunnelState.Connecting -> tunnelState.location + is TunnelState.Connected -> tunnelState.location + is TunnelState.Disconnecting -> lastKnownDisconnectedLocation + is TunnelState.Error -> null + }, + selectedRelayItemTitle = selectedRelayItemTitle, + tunnelState = tunnelState, + inAddress = + when (tunnelState) { + is TunnelState.Connected -> tunnelState.endpoint.toInAddress() + is TunnelState.Connecting -> tunnelState.endpoint?.toInAddress() + else -> null + }, + outAddress = tunnelState.location()?.toOutAddress() ?: "", + showLocation = + when (tunnelState) { + is TunnelState.Disconnected -> true + is TunnelState.Disconnecting -> { + when (tunnelState.actionAfterDisconnect) { + ActionAfterDisconnect.Nothing -> false + ActionAfterDisconnect.Block -> true + ActionAfterDisconnect.Reconnect -> false } - is TunnelState.Connecting -> false - is TunnelState.Connected -> false - is TunnelState.Error -> true - }, - inAppNotification = notifications.firstOrNull(), - deviceName = deviceName, - daysLeftUntilExpiry = accountExpiry.date()?.daysFromNow(), - isPlayBuild = isPlayBuild, - ) - } + } + is TunnelState.Connecting -> false + is TunnelState.Connected -> false + is TunnelState.Error -> true + }, + inAppNotification = notifications.firstOrNull(), + deviceName = deviceName, + daysLeftUntilExpiry = accountData?.expiryDate?.daysFromNow(), + isPlayBuild = isPlayBuild, + ) } .debounce(UI_STATE_DEBOUNCE_DURATION_MILLIS) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ConnectUiState.INITIAL) init { - viewModelScope.launch { - paymentUseCase.verifyPurchases { accountRepository.fetchAccountExpiry() } + paymentUseCase.verifyPurchases { + viewModelScope.launch { accountRepository.getAccountData() } + } } } - private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> = - callbackFlowFromNotifier(this.onUiStateChange) - - private fun ConnectionProxy.tunnelRealStateFlow(): Flow<TunnelState> = - callbackFlowFromNotifier(this.onStateChange) - - private fun ConnectionProxy.lastKnownDisconnectedLocation(): Flow<GeoIpLocation?> = - tunnelRealStateFlow() - .filterIsInstance<TunnelState.Disconnected>() - .filter { it.location != null } - .map { it.location } - .onStart { emit(null) } - fun onDisconnectClick() { - serviceConnectionManager.connectionProxy()?.disconnect() + viewModelScope.launch { connectionProxy.disconnect() } } fun onReconnectClick() { - serviceConnectionManager.connectionProxy()?.reconnect() + viewModelScope.launch { connectionProxy.reconnect() } } fun onConnectClick() { - serviceConnectionManager.connectionProxy()?.connect() + viewModelScope.launch { + connectionProxy.connect().onLeft { connectError -> + when (connectError) { + ConnectError.NoVpnPermission -> _uiSideEffect.send(UiSideEffect.NoVpnPermission) + is ConnectError.Unknown -> { + _uiSideEffect.send(UiSideEffect.ConnectError.Generic) + } + } + } + } + } + + fun requestVpnPermissionResult(hasVpnPermission: Boolean) { + viewModelScope.launch { + if (hasVpnPermission) { + connectionProxy.connect() + } else { + vpnPermissionRepository.getAlwaysOnVpnAppName()?.let { + _uiSideEffect.send(UiSideEffect.ConnectError.AlwaysOnVpn(it)) + } ?: _uiSideEffect.send(UiSideEffect.ConnectError.NoVpnPermission) + } + } } fun onCancelClick() { - serviceConnectionManager.connectionProxy()?.disconnect() + viewModelScope.launch { connectionProxy.disconnect() } } fun onManageAccountClick() { viewModelScope.launch { - _uiSideEffect.trySend( - UiSideEffect.OpenAccountManagementPageInBrowser( - serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" - ) - ) + accountRepository.getWebsiteAuthToken()?.let { wwwAuthToken -> + _uiSideEffect.send(UiSideEffect.OpenAccountManagementPageInBrowser(wwwAuthToken)) + } } } @@ -196,11 +178,21 @@ class ConnectViewModel( } sealed interface UiSideEffect { - data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect + data class OpenAccountManagementPageInBrowser(val token: WebsiteAuthToken) : UiSideEffect data object OutOfTime : UiSideEffect data object RevokedDevice : UiSideEffect + + data object NoVpnPermission : UiSideEffect + + sealed interface ConnectError : UiSideEffect { + data object Generic : ConnectError + + data object NoVpnPermission : ConnectError + + data class AlwaysOnVpn(val appName: String) : ConnectError + } } companion object { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt index f58916cd66..043f989598 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt @@ -10,16 +10,17 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.communication.Created import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult import net.mullvad.mullvadvpn.compose.state.CreateCustomListUiState -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.usecase.customlists.CreateWithLocationsError import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException class CreateCustomListDialogViewModel( - private val locationCode: String, + private val locationCode: GeoLocationId?, private val customListActionUseCase: CustomListActionUseCase, ) : ViewModel() { @@ -27,7 +28,7 @@ class CreateCustomListDialogViewModel( Channel<CreateCustomListDialogSideEffect>(1, BufferOverflow.DROP_OLDEST) val uiSideEffect = _uiSideEffect.receiveAsFlow() - private val _error = MutableStateFlow<CustomListsError?>(null) + private val _error = MutableStateFlow<CreateWithLocationsError?>(null) val uiState = _error @@ -40,32 +41,22 @@ class CreateCustomListDialogViewModel( .performAction( CustomListAction.Create( CustomListName.fromString(name), - if (locationCode.isNotEmpty()) { - listOf(locationCode) - } else { - emptyList() - } + listOfNotNull(locationCode) ) ) .fold( - onSuccess = { result -> - if (result.locationName != null) { + { _error.emit(it) }, + { + if (it.locationNames.isEmpty()) { _uiSideEffect.send( - CreateCustomListDialogSideEffect.ReturnWithResult(result) + CreateCustomListDialogSideEffect + .NavigateToCustomListLocationsScreen(it.id) ) } else { _uiSideEffect.send( - CreateCustomListDialogSideEffect - .NavigateToCustomListLocationsScreen(result.id) + CreateCustomListDialogSideEffect.ReturnWithResult(it) ) } - }, - onFailure = { error -> - if (error is CustomListsException) { - _error.emit(error.error) - } else { - _error.emit(CustomListsError.OtherError) - } } ) } @@ -78,9 +69,8 @@ class CreateCustomListDialogViewModel( sealed interface CreateCustomListDialogSideEffect { - data class NavigateToCustomListLocationsScreen(val customListId: String) : + data class NavigateToCustomListLocationsScreen(val customListId: CustomListId) : CreateCustomListDialogSideEffect - data class ReturnWithResult(val result: CustomListResult.Created) : - CreateCustomListDialogSideEffect + data class ReturnWithResult(val result: Created) : CreateCustomListDialogSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt index cdbcebbb83..581c11c397 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt @@ -7,37 +7,39 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.relaylist.descendants import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm -import net.mullvad.mullvadvpn.relaylist.getById -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.relaylist.withDescendants +import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.util.firstOrNullWithTimeout +import net.mullvad.mullvadvpn.usecase.customlists.CustomListRelayItemsUseCase class CustomListLocationsViewModel( - private val customListId: String, + private val customListId: CustomListId, private val newList: Boolean, - private val relayListUseCase: RelayListUseCase, + relayListRepository: RelayListRepository, + private val customListRelayItemsUseCase: CustomListRelayItemsUseCase, private val customListActionUseCase: CustomListActionUseCase ) : ViewModel() { private val _uiSideEffect = MutableSharedFlow<CustomListLocationsSideEffect>(replay = 1, extraBufferCapacity = 1) val uiSideEffect: SharedFlow<CustomListLocationsSideEffect> = _uiSideEffect - private val _initialLocations = MutableStateFlow<Set<RelayItem>>(emptySet()) - private val _selectedLocations = MutableStateFlow<Set<RelayItem>?>(null) + private val _initialLocations = MutableStateFlow<Set<RelayItem.Location>>(emptySet()) + private val _selectedLocations = MutableStateFlow<Set<RelayItem.Location>?>(null) private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) val uiState = - combine(relayListUseCase.fullRelayList(), _searchTerm, _selectedLocations) { + combine(relayListRepository.relayList, _searchTerm, _selectedLocations) { relayCountries, searchTerm, selectedLocations -> @@ -77,27 +79,32 @@ class CustomListLocationsViewModel( fun save() { viewModelScope.launch { _selectedLocations.value?.let { selectedLocations -> - val result = - customListActionUseCase.performAction( + customListActionUseCase + .performAction( CustomListAction.UpdateLocations( customListId, - selectedLocations.calculateLocationsToSave().map { it.code } + selectedLocations.calculateLocationsToSave().map { it.id } ) ) - _uiSideEffect.tryEmit( - // This is so that we don't show a snackbar after returning to the select - // location screen - if (newList) { - CustomListLocationsSideEffect.CloseScreen - } else { - CustomListLocationsSideEffect.ReturnWithResult(result.getOrThrow()) - } - ) + .fold( + { _uiSideEffect.tryEmit(CustomListLocationsSideEffect.Error) }, + { + _uiSideEffect.tryEmit( + // This is so that we don't show a snackbar after returning to the + // select location screen + if (newList) { + CustomListLocationsSideEffect.CloseScreen + } else { + CustomListLocationsSideEffect.ReturnWithResult(it) + } + ) + } + ) } } } - fun onRelaySelectionClick(relayItem: RelayItem, selected: Boolean) { + fun onRelaySelectionClick(relayItem: RelayItem.Location, selected: Boolean) { if (selected) { selectLocation(relayItem) } else { @@ -109,13 +116,7 @@ class CustomListLocationsViewModel( viewModelScope.launch { _searchTerm.emit(searchTerm) } } - private suspend fun awaitCustomListById(id: String): RelayItem.CustomList? = - relayListUseCase - .customLists() - .mapNotNull { customList -> customList.getById(id) } - .firstOrNullWithTimeout(GET_CUSTOM_LIST_TIMEOUT_MS) - - private fun selectLocation(relayItem: RelayItem) { + private fun selectLocation(relayItem: RelayItem.Location) { viewModelScope.launch { _selectedLocations.update { it?.plus(relayItem)?.plus(relayItem.descendants()) ?: setOf(relayItem) @@ -123,7 +124,7 @@ class CustomListLocationsViewModel( } } - private fun deselectLocation(relayItem: RelayItem) { + private fun deselectLocation(relayItem: RelayItem.Location) { viewModelScope.launch { _selectedLocations.update { val newSelectedLocations = it?.toMutableSet() ?: mutableSetOf() @@ -136,30 +137,31 @@ class CustomListLocationsViewModel( } } - private fun availableLocations(): List<RelayItem.Country> = + private fun availableLocations(): List<RelayItem.Location.Country> = (uiState.value as? CustomListLocationsUiState.Content.Data)?.availableLocations ?: emptyList() - private fun Set<RelayItem>.deselectParents(relayItem: RelayItem): Set<RelayItem> { + private fun Set<RelayItem.Location>.deselectParents( + relayItem: RelayItem.Location + ): Set<RelayItem.Location> { val availableLocations = availableLocations() val updateSelectionList = this.toMutableSet() when (relayItem) { - is RelayItem.City -> { + is RelayItem.Location.City -> { availableLocations - .find { it.code == relayItem.location.countryCode } + .find { it.id == relayItem.id.country } ?.let { updateSelectionList.remove(it) } } - is RelayItem.Relay -> { + is RelayItem.Location.Relay -> { availableLocations .flatMap { country -> country.cities } - .find { it.code == relayItem.location.cityCode } + .find { it.id == relayItem.id.city } ?.let { updateSelectionList.remove(it) } availableLocations - .find { it.code == relayItem.location.countryCode } + .find { it.id == relayItem.id.country } ?.let { updateSelectionList.remove(it) } } - is RelayItem.Country, - is RelayItem.CustomList -> { + is RelayItem.Location.Country -> { /* Do nothing */ } } @@ -167,20 +169,19 @@ class CustomListLocationsViewModel( return updateSelectionList } - private fun Set<RelayItem>.calculateLocationsToSave(): List<RelayItem> { + private fun Set<RelayItem.Location>.calculateLocationsToSave(): List<RelayItem.Location> { // We don't want to save children for a selected parent val saveSelectionList = this.toMutableList() this.forEach { relayItem -> when (relayItem) { - is RelayItem.Country -> { + is RelayItem.Location.Country -> { saveSelectionList.removeAll(relayItem.cities) saveSelectionList.removeAll(relayItem.relays) } - is RelayItem.City -> { + is RelayItem.Location.City -> { saveSelectionList.removeAll(relayItem.relays) } - is RelayItem.Relay, - is RelayItem.CustomList -> { + is RelayItem.Location.Relay -> { /* Do nothing */ } } @@ -188,25 +189,27 @@ class CustomListLocationsViewModel( return saveSelectionList } - private fun List<RelayItem>.selectChildren(): Set<RelayItem> = - (this + flatMap { it.descendants() }).toSet() - private suspend fun fetchInitialSelectedLocations() { - _selectedLocations.value = - awaitCustomListById(customListId)?.locations?.selectChildren().apply { - _initialLocations.value = this ?: emptySet() - } + val selectedLocations = + customListRelayItemsUseCase + .getRelayItemLocationsForCustomList(customListId) + .first() + .withDescendants() + .toSet() + + _initialLocations.value = selectedLocations + _selectedLocations.value = selectedLocations } companion object { private const val EMPTY_SEARCH_TERM = "" - private const val GET_CUSTOM_LIST_TIMEOUT_MS = 5000L } } sealed interface CustomListLocationsSideEffect { data object CloseScreen : CustomListLocationsSideEffect - data class ReturnWithResult(val result: CustomListResult.LocationsChanged) : - CustomListLocationsSideEffect + data class ReturnWithResult(val result: LocationsChanged) : CustomListLocationsSideEffect + + data object Error : CustomListLocationsSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModel.kt index 79a2ba61c6..3689ad7fc8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModel.kt @@ -3,23 +3,24 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.state.CustomListsUiState -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase class CustomListsViewModel( - relayListUseCase: RelayListUseCase, + customListsRepository: CustomListsRepository, private val customListActionUseCase: CustomListActionUseCase ) : ViewModel() { val uiState = - relayListUseCase - .customLists() - .map { CustomListsUiState.Content(it) } + customListsRepository.customLists + .filterNotNull() + .map(CustomListsUiState::Content) .stateIn( viewModelScope, started = SharingStarted.WhileSubscribed(), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt index e3c7f45664..79c2a133c2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt @@ -3,31 +3,54 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.Deleted +import net.mullvad.mullvadvpn.compose.state.DeleteCustomListUiState +import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.DeleteWithUndoError class DeleteCustomListConfirmationViewModel( - private val customListId: String, + private val customListId: CustomListId, private val customListActionUseCase: CustomListActionUseCase ) : ViewModel() { private val _uiSideEffect = Channel<DeleteCustomListConfirmationSideEffect>(Channel.BUFFERED) val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val _error = MutableStateFlow<DeleteWithUndoError?>(null) + + val uiState = + _error + .map { DeleteCustomListUiState(it) } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + DeleteCustomListUiState(null) + ) + fun deleteCustomList() { viewModelScope.launch { - val result = - customListActionUseCase - .performAction(CustomListAction.Delete(customListId)) - .getOrThrow() - _uiSideEffect.send(DeleteCustomListConfirmationSideEffect.ReturnWithResult(result)) + _error.emit(null) + customListActionUseCase + .performAction(CustomListAction.Delete(customListId)) + .fold( + { _error.tryEmit(it) }, + { + _uiSideEffect.send( + DeleteCustomListConfirmationSideEffect.ReturnWithResult(it) + ) + } + ) } } } -sealed class DeleteCustomListConfirmationSideEffect { - data class ReturnWithResult(val result: CustomListResult.Deleted) : - DeleteCustomListConfirmationSideEffect() +sealed interface DeleteCustomListConfirmationSideEffect { + data class ReturnWithResult(val result: Deleted) : DeleteCustomListConfirmationSideEffect } 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 7b6c092ded..d2c8780606 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 @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.viewmodel -import android.content.res.Resources import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher @@ -8,111 +7,87 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.state.DeviceListItemUiState +import net.mullvad.mullvadvpn.compose.state.DeviceItemUiState import net.mullvad.mullvadvpn.compose.state.DeviceListUiState -import net.mullvad.mullvadvpn.lib.common.util.parseAsDateTime -import net.mullvad.mullvadvpn.model.Device -import net.mullvad.mullvadvpn.model.DeviceList -import net.mullvad.mullvadvpn.model.RemoveDeviceResult -import net.mullvad.mullvadvpn.repository.DeviceRepository - -typealias DeviceId = String +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.model.GetDeviceListError +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository class DeviceListViewModel( private val deviceRepository: DeviceRepository, - private val resources: Resources, - private val dispatcher: CoroutineDispatcher = Dispatchers.Default + private val token: AccountToken, + private val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ViewModel() { - private val _loadingDevices = MutableStateFlow<List<DeviceId>>(emptyList()) + private val loadingDevices = MutableStateFlow<Set<DeviceId>>(emptySet()) + private val deviceList = MutableStateFlow<List<Device>>(emptyList()) + private val loading = MutableStateFlow(true) + private val error = MutableStateFlow<GetDeviceListError?>(null) private val _uiSideEffect = Channel<DeviceListSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() - private var cachedDeviceList: List<Device>? = null - - val uiState = - combine(deviceRepository.deviceList, _loadingDevices) { deviceList, loadingDevices -> - val devices = - if (deviceList is DeviceList.Available) { - deviceList.devices.also { cachedDeviceList = it } - } else { - cachedDeviceList - } - val deviceUiItems = - devices - ?.sortedBy { it.created.parseAsDateTime() } - ?.map { device -> - DeviceListItemUiState( - device, - loadingDevices.any { loadingDevice -> device.id == loadingDevice } - ) - } - val isLoading = devices == null - DeviceListUiState( - deviceUiItems = deviceUiItems ?: emptyList(), - isLoading = isLoading, - ) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), DeviceListUiState.INITIAL) - - 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(deviceIdToRemove) - - if (result == null) { - _uiSideEffect.send( - DeviceListSideEffect.ShowToast( - resources.getString(R.string.failed_to_remove_device) + val uiState: StateFlow<DeviceListUiState> = + combine( + loadingDevices, + deviceList.map { it.sortedBy { it.creationDate } }, + loading, + error + ) { loadingDevices, devices, loading, error -> + when { + loading -> DeviceListUiState.Loading + error != null -> DeviceListUiState.Error(error) + else -> + DeviceListUiState.Content( + devices.map { DeviceItemUiState(it, loadingDevices.contains(it.id)) } ) - ) - refreshDeviceList(accountToken) } } - } - } + .onStart { fetchDevices() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), DeviceListUiState.Loading) - fun refreshDeviceState() = deviceRepository.refreshDeviceState() - - fun refreshDeviceList(accountToken: String) = deviceRepository.refreshDeviceList(accountToken) + fun fetchDevices() = + viewModelScope.launch { + error.value = null + loading.value = true + deviceRepository.deviceList(token).fold({ error.value = it }, { deviceList.value = it }) + loading.value = false + } - private fun setLoadingDevice(deviceId: DeviceId) { - _loadingDevices.value = _loadingDevices.value.toMutableList().apply { add(deviceId) } - } + fun removeDevice(deviceIdToRemove: DeviceId) = + viewModelScope.launch(dispatcher) { + setLoadingState(deviceIdToRemove, true) + deviceRepository + .removeDevice(token, deviceIdToRemove) + .fold( + { + _uiSideEffect.send(DeviceListSideEffect.FailedToRemoveDevice) + setLoadingState(deviceIdToRemove, false) + deviceRepository.deviceList(token).onRight { deviceList.value = it } + }, + { removeDeviceFromState(deviceIdToRemove) } + ) + } - private fun clearLoadingDevice(deviceId: DeviceId) { - _loadingDevices.value = _loadingDevices.value.toMutableList().apply { remove(deviceId) } + private fun setLoadingState(deviceId: DeviceId, isLoading: Boolean) { + loadingDevices.update { if (isLoading) it + deviceId else it - deviceId } } - companion object { - private const val DEVICE_REMOVAL_TIMEOUT_MILLIS = 5000L + private fun removeDeviceFromState(deviceId: DeviceId) { + deviceList.update { devices -> devices.filter { item -> item.id != deviceId } } + loadingDevices.update { it - deviceId } } } sealed interface DeviceListSideEffect { - data class ShowToast(val text: String) : DeviceListSideEffect + data object FailedToRemoveDevice : DeviceListSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt index 4cb02c748f..8d526ba9b2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt @@ -7,37 +7,28 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy -import net.mullvad.talpid.util.callbackFlowFromSubscription +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy -// TODO: Refactor ConnectionProxy to be easily injectable rather than injecting -// ServiceConnectionManager here. class DeviceRevokedViewModel( - private val serviceConnectionManager: ServiceConnectionManager, private val accountRepository: AccountRepository, + private val connectionProxy: ConnectionProxy, dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { val uiState = - serviceConnectionManager.connectionState - .map { connectionState -> connectionState.readyContainer()?.connectionProxy } - .flatMapLatest { proxy -> - proxy?.onUiStateChange?.callbackFlowFromSubscription(this)?.map { - if (it.isSecured()) { - DeviceRevokedUiState.SECURED - } else { - DeviceRevokedUiState.UNSECURED - } - } ?: flowOf(DeviceRevokedUiState.UNKNOWN) + connectionProxy.tunnelState + .map { + if (it.isSecured()) { + DeviceRevokedUiState.SECURED + } else { + DeviceRevokedUiState.UNSECURED + } } .stateIn( scope = CoroutineScope(dispatcher), @@ -49,12 +40,10 @@ class DeviceRevokedViewModel( val uiSideEffect = _uiSideEffect.receiveAsFlow() fun onGoToLoginClicked() { - serviceConnectionManager.connectionProxy()?.let { proxy -> - if (proxy.state.isSecured()) { - proxy.disconnect() - } + viewModelScope.launch { + connectionProxy.disconnect() + accountRepository.logout() } - accountRepository.logout() viewModelScope.launch { _uiSideEffect.send(DeviceRevokedSideEffect.NavigateToLogin) } } 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 index 4703e1cbf9..cc377b0bab 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DnsDialogViewModel.kt @@ -15,13 +15,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.constant.EMPTY_STRING -import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.lib.model.Settings import net.mullvad.mullvadvpn.repository.SettingsRepository import org.apache.commons.validator.routines.InetAddressValidator sealed interface DnsDialogSideEffect { data object Complete : DnsDialogSideEffect + + data object Error : DnsDialogSideEffect } data class DnsDialogViewModelState( @@ -116,25 +117,25 @@ class DnsDialogViewModel( val address = InetAddress.getByName(uiState.value.ipAddress) - repository.updateCustomDnsList { - it.toMutableList().apply { - if (index != null) { - set(index, address) - } else { - add(address) - } + if (index != null) { + repository.setCustomDns(index = index, address = address) + } else { + repository.addCustomDns(address = address) } - } - - _uiSideEffect.send(DnsDialogSideEffect.Complete) + .fold( + { _uiSideEffect.send(DnsDialogSideEffect.Error) }, + { _uiSideEffect.send(DnsDialogSideEffect.Complete) } + ) } fun onRemoveDnsClick() = viewModelScope.launch(dispatcher) { - repository.updateCustomDnsList { - it.filter { it.hostAddress != uiState.value.ipAddress } - } - _uiSideEffect.send(DnsDialogSideEffect.Complete) + repository + .deleteCustomDns(InetAddress.getByName(uiState.value.ipAddress)) + .fold( + { _uiSideEffect.send(DnsDialogSideEffect.Error) }, + { _uiSideEffect.send(DnsDialogSideEffect.Complete) } + ) } private fun String.isValidIp(): Boolean { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt index 9a8d3d2f62..7c45bed0d7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt @@ -11,16 +11,16 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult -import net.mullvad.mullvadvpn.compose.state.UpdateCustomListUiState -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.compose.communication.Renamed +import net.mullvad.mullvadvpn.compose.state.EditCustomListNameUiState +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException +import net.mullvad.mullvadvpn.usecase.customlists.RenameError class EditCustomListNameDialogViewModel( - private val customListId: String, - private val initialName: String, + private val customListId: CustomListId, + private val initialName: CustomListName, private val customListActionUseCase: CustomListActionUseCase ) : ViewModel() { @@ -28,15 +28,15 @@ class EditCustomListNameDialogViewModel( Channel<EditCustomListNameDialogSideEffect>(1, BufferOverflow.DROP_OLDEST) val uiSideEffect = _uiSideEffect.receiveAsFlow() - private val _error = MutableStateFlow<CustomListsError?>(null) + private val _error = MutableStateFlow<RenameError?>(null) val uiState = _error - .map { UpdateCustomListUiState(name = initialName, error = it) } + .map { EditCustomListNameUiState(name = initialName.value, error = it) } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - UpdateCustomListUiState(name = initialName) + EditCustomListNameUiState(name = initialName.value) ) fun updateCustomListName(name: String) { @@ -44,24 +44,14 @@ class EditCustomListNameDialogViewModel( customListActionUseCase .performAction( CustomListAction.Rename( - customListId = customListId, - name = CustomListName.fromString(initialName), + id = customListId, + name = initialName, newName = CustomListName.fromString(name) ) ) .fold( - onSuccess = { result -> - _uiSideEffect.send( - EditCustomListNameDialogSideEffect.ReturnWithResult(result) - ) - }, - onFailure = { exception -> - if (exception is CustomListsException) { - _error.emit(exception.error) - } else { - _error.emit(CustomListsError.OtherError) - } - } + { _error.emit(it) }, + { _uiSideEffect.send(EditCustomListNameDialogSideEffect.ReturnWithResult(it)) } ) } } @@ -72,6 +62,5 @@ class EditCustomListNameDialogViewModel( } sealed interface EditCustomListNameDialogSideEffect { - data class ReturnWithResult(val result: CustomListResult.Renamed) : - EditCustomListNameDialogSideEffect + data class ReturnWithResult(val result: Renamed) : EditCustomListNameDialogSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModel.kt index 81232e63d5..adfacceb4e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModel.kt @@ -6,18 +6,18 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.compose.state.EditCustomListState -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.repository.CustomListsRepository class EditCustomListViewModel( - private val customListId: String, - relayListUseCase: RelayListUseCase + private val customListId: CustomListId, + customListsRepository: CustomListsRepository ) : ViewModel() { val uiState = - relayListUseCase - .customLists() + customListsRepository.customLists .map { customLists -> customLists - .find { it.id == customListId } + ?.find { it.id == customListId } ?.let { EditCustomListState.Content( id = it.id, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt index 0d39ffa625..6e139f4d7f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModel.kt @@ -16,12 +16,14 @@ import net.mullvad.mullvadvpn.compose.state.toConstraintProviders import net.mullvad.mullvadvpn.compose.state.toNullableOwnership import net.mullvad.mullvadvpn.compose.state.toOwnershipConstraint import net.mullvad.mullvadvpn.compose.state.toSelectedProviders -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.relaylist.Provider -import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase class FilterViewModel( - private val relayListFilterUseCase: RelayListFilterUseCase, + private val availableProvidersUseCase: AvailableProvidersUseCase, + private val relayListFilterRepository: RelayListFilterRepository ) : ViewModel() { private val _uiSideEffect = Channel<FilterScreenSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() @@ -33,14 +35,14 @@ class FilterViewModel( viewModelScope.launch { selectedProviders.value = combine( - relayListFilterUseCase.availableProviders(), - relayListFilterUseCase.selectedProviders(), + availableProvidersUseCase.availableProviders(), + relayListFilterRepository.selectedProviders, ) { allProviders, selectedConstraintProviders -> selectedConstraintProviders.toSelectedProviders(allProviders) } .first() - val ownershipConstraint = relayListFilterUseCase.selectedOwnership().first() + val ownershipConstraint = relayListFilterRepository.selectedOwnership.first() selectedOwnership.value = ownershipConstraint.toNullableOwnership() } } @@ -48,7 +50,7 @@ class FilterViewModel( val uiState: StateFlow<RelayFilterState> = combine( selectedOwnership, - relayListFilterUseCase.availableProviders(), + availableProvidersUseCase.availableProviders(), selectedProviders, ) { selectedOwnership, allProviders, selectedProviders -> RelayFilterState( @@ -84,7 +86,7 @@ class FilterViewModel( viewModelScope.launch { selectedProviders.value = if (isChecked) { - relayListFilterUseCase.availableProviders().first() + availableProvidersUseCase.availableProviders().first() } else { emptyList() } @@ -97,7 +99,7 @@ class FilterViewModel( selectedProviders.value.toConstraintProviders(uiState.value.allProviders) viewModelScope.launch { - relayListFilterUseCase.updateOwnershipAndProviderFilter( + relayListFilterRepository.updateSelectedOwnershipAndProviderFilter( newSelectedOwnership, newSelectedProviders ) 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 9af9d700ce..e568021177 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 @@ -11,9 +11,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -24,16 +24,11 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.Idle import net.mullvad.mullvadvpn.compose.state.LoginState.Loading import net.mullvad.mullvadvpn.compose.state.LoginState.Success import net.mullvad.mullvadvpn.compose.state.LoginUiState -import net.mullvad.mullvadvpn.constant.LOGIN_TIMEOUT_MILLIS -import net.mullvad.mullvadvpn.model.AccountCreationResult -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.AccountToken -import net.mullvad.mullvadvpn.model.LoginResult -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.LoginAccountError +import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase -import net.mullvad.mullvadvpn.util.awaitWithTimeoutOrNull import net.mullvad.mullvadvpn.util.getOrDefault private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L @@ -50,7 +45,6 @@ sealed interface LoginUiSideEffect { class LoginViewModel( private val accountRepository: AccountRepository, - private val deviceRepository: DeviceRepository, private val newDeviceNotificationUseCase: NewDeviceNotificationUseCase, private val connectivityUseCase: ConnectivityUseCase, private val dispatcher: CoroutineDispatcher = Dispatchers.IO @@ -61,27 +55,42 @@ class LoginViewModel( private val _uiSideEffect = Channel<LoginUiSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val _mutableAccountHistory: MutableStateFlow<AccountToken?> = MutableStateFlow(null) + private val _uiState = combine( _loginInput, - accountRepository.accountHistory, + _mutableAccountHistory, _loginState, - ) { loginInput, accountHistoryState, loginState -> - LoginUiState( - loginInput, - accountHistoryState.accountToken()?.let(::AccountToken), - loginState - ) + ) { loginInput, historyAccountToken, loginState -> + LoginUiState(loginInput, historyAccountToken, loginState) } + val uiState: StateFlow<LoginUiState> = - _uiState.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), LoginUiState.INITIAL) + _uiState + .onStart { + viewModelScope.launch { + _mutableAccountHistory.update { accountRepository.fetchAccountHistory() } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), LoginUiState.INITIAL) - fun clearAccountHistory() = accountRepository.clearAccountHistory() + fun clearAccountHistory() = + viewModelScope.launch { + accountRepository.clearAccountHistory() + _mutableAccountHistory.update { null } + _mutableAccountHistory.update { accountRepository.fetchAccountHistory() } + } fun createAccount() { _loginState.value = Loading.CreatingAccount viewModelScope.launch(dispatcher) { - accountRepository.createAccount().mapToUiState()?.let { _loginState.value = it } + accountRepository + .createAccount() + .fold( + { _loginState.value = Idle(LoginError.UnableToCreateAccount) }, + { _uiSideEffect.send(LoginUiSideEffect.NavigateToWelcome) } + ) } } @@ -94,76 +103,68 @@ class LoginViewModel( viewModelScope.launch(dispatcher) { // Ensure we always take at least MINIMUM_LOADING_SPINNER_TIME_MILLIS to show the // loading indicator - val loginDeferred = async { accountRepository.login(accountToken) } + val result = async { accountRepository.login(AccountToken(accountToken)) } + delay(MINIMUM_LOADING_SPINNER_TIME_MILLIS) val uiState = - // If timed out will go to the else branch - when (val result = loginDeferred.awaitWithTimeoutOrNull(LOGIN_TIMEOUT_MILLIS)) { - LoginResult.Ok -> { - newDeviceNotificationUseCase.newDeviceCreated() - launch { - val isOutOfTimeDeferred = async { - accountRepository.accountExpiryState - .filterIsInstance<AccountExpiry.Available>() - .map { it.expiryDateTime.isBeforeNow } - .first() - } - delay(1000) - val isOutOfTime = isOutOfTimeDeferred.getOrDefault(false) - if (isOutOfTime) { - _uiSideEffect.send(LoginUiSideEffect.NavigateToOutOfTime) - } else { - _uiSideEffect.send(LoginUiSideEffect.NavigateToConnect) - } + result + .await() + .fold( + { it.toUiState() }, + { + onSuccessfulLogin() + Success } - Success - } - LoginResult.InvalidAccount -> Idle(LoginError.InvalidCredentials) - LoginResult.MaxDevicesReached -> { - // TODO this refresh process should be handled by DeviceListScreen. - val refreshResult = - deviceRepository.refreshAndAwaitDeviceListWithTimeout( - accountToken = accountToken, - shouldClearCache = true, - shouldOverrideCache = true, - timeoutMillis = 5000L - ) + ) - if (refreshResult.isAvailable()) { - // Navigate to device list - - _uiSideEffect.send( - LoginUiSideEffect.TooManyDevices(AccountToken(accountToken)) - ) - Idle() - } else { - // Failed to fetch devices list - Idle(LoginError.Unknown(result.toString())) - } - } - else -> Idle(LoginError.Unknown(result.toString())) - } _loginState.update { uiState } } } + private suspend fun onSuccessfulLogin() { + newDeviceNotificationUseCase.newDeviceCreated() + + viewModelScope.launch(dispatcher) { + // Find if user is out of time + val isOutOfTimeDeferred = async { + accountRepository.accountData.mapNotNull { it?.expiryDate?.isBeforeNow }.first() + } + + // Always show successful login for some time. + delay(SHOW_SUCCESSFUL_LOGIN_MILLIS) + + // Get the result of isOutOfTime or assume not out of time + val isOutOfTime = isOutOfTimeDeferred.getOrDefault(false) + + if (isOutOfTime) { + _uiSideEffect.send(LoginUiSideEffect.NavigateToOutOfTime) + } else { + _uiSideEffect.send(LoginUiSideEffect.NavigateToConnect) + } + } + } + fun onAccountNumberChange(accountNumber: String) { _loginInput.value = accountNumber.filter { it.isDigit() } // If there is an error, clear it _loginState.update { if (it is Idle) Idle() else it } } - private suspend fun AccountCreationResult.mapToUiState(): LoginState? { - return if (this is AccountCreationResult.Success) { - _uiSideEffect.send(LoginUiSideEffect.NavigateToWelcome) - null - } else { - Idle(LoginError.UnableToCreateAccount) + private suspend fun LoginAccountError.toUiState(): LoginState = + when (this) { + LoginAccountError.InvalidAccount -> Idle(LoginError.InvalidCredentials) + is LoginAccountError.MaxDevicesReached -> + Idle().also { _uiSideEffect.send(LoginUiSideEffect.TooManyDevices(accountToken)) } + LoginAccountError.RpcError, + is LoginAccountError.Unknown -> Idle(LoginError.Unknown(this.toString())) } - } private fun isInternetAvailable(): Boolean { return connectivityUseCase.isInternetAvailable() } + + companion object { + private const val SHOW_SUCCESSFUL_LOGIN_MILLIS = 1000L + } } 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 index 4b6e8ed767..9d1a17207c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MtuDialogViewModel.kt @@ -5,34 +5,77 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.lib.model.Mtu import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.util.isValidMtu class MtuDialogViewModel( private val repository: SettingsRepository, + private val initialMtu: Mtu?, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { + private val _mtuInput = MutableStateFlow(initialMtu?.value?.toString() ?: "") + private val _isValidMtu = MutableStateFlow(true) + val uiState: StateFlow<MtuDialogUiState> = + combine(_mtuInput, _isValidMtu, ::createState) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + createState(_mtuInput.value, _isValidMtu.value) + ) + private val _uiSideEffect = Channel<MtuDialogSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() - fun onSaveClick(mtuValue: Int) = + private fun createState(mtuInput: String, isValidMtuInput: Boolean) = + MtuDialogUiState( + mtuInput = mtuInput, + isValidInput = isValidMtuInput, + showResetToDefault = initialMtu != null + ) + + fun onInputChanged(value: String) { + _mtuInput.value = value + _isValidMtu.value = Mtu.fromString(value).isRight() + } + + fun onSaveClick(mtuValue: String) = viewModelScope.launch(dispatcher) { - if (mtuValue.isValidMtu()) { - repository.setWireguardMtu(mtuValue) - } - _uiSideEffect.send(MtuDialogSideEffect.Complete) + val mtu = Mtu.fromString(mtuValue).getOrNull() ?: return@launch + repository + .setWireguardMtu(mtu) + .fold( + { _uiSideEffect.send(MtuDialogSideEffect.Error) }, + { _uiSideEffect.send(MtuDialogSideEffect.Complete) } + ) } fun onRestoreClick() = viewModelScope.launch(dispatcher) { - repository.setWireguardMtu(null) - _uiSideEffect.send(MtuDialogSideEffect.Complete) + repository + .resetWireguardMtu() + .fold( + { _uiSideEffect.send(MtuDialogSideEffect.Error) }, + { _uiSideEffect.send(MtuDialogSideEffect.Complete) } + ) } } sealed interface MtuDialogSideEffect { data object Complete : MtuDialogSideEffect + + data object Error : MtuDialogSideEffect } + +data class MtuDialogUiState( + val mtuInput: String, + val isValidInput: Boolean, + val showResetToDefault: Boolean +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt index eff31be0ee..f8863f2433 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/NoDaemonViewModel.kt @@ -22,12 +22,12 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.destinations.PrivacyDisclaimerDestination import net.mullvad.mullvadvpn.compose.destinations.SplashDestination -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.lib.daemon.grpc.GrpcConnectivityState +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService private val noServiceDestinations = listOf(SplashDestination, PrivacyDisclaimerDestination) -class NoDaemonViewModel(serviceConnectionManager: ServiceConnectionManager) : +class NoDaemonViewModel(managementService: ManagementService) : ViewModel(), LifecycleEventObserver, NavController.OnDestinationChangedListener { private val lifecycleFlow: MutableSharedFlow<Lifecycle.Event> = MutableSharedFlow() @@ -35,7 +35,7 @@ class NoDaemonViewModel(serviceConnectionManager: ServiceConnectionManager) : @OptIn(FlowPreview::class) val uiSideEffect = - combine(lifecycleFlow, serviceConnectionManager.connectionState, destinationFlow) { + combine(lifecycleFlow, managementService.connectionState, destinationFlow) { event, connEvent, destination -> @@ -66,7 +66,7 @@ class NoDaemonViewModel(serviceConnectionManager: ServiceConnectionManager) : private fun toDaemonState( lifecycleEvent: Lifecycle.Event, - serviceState: ServiceConnectionState, + serviceState: GrpcConnectivityState, currentDestination: DestinationSpec<*> ): DaemonState { // In these destinations we don't care about showing the NoDaemonScreen @@ -77,9 +77,11 @@ class NoDaemonViewModel(serviceConnectionManager: ServiceConnectionManager) : return if (lifecycleEvent.targetState.isAtLeast(Lifecycle.State.STARTED)) { // If we are started we want to show the overlay if we are not connected to daemon when (serviceState) { - is ServiceConnectionState.ConnectedNotReady, - ServiceConnectionState.Disconnected -> DaemonState.Show - is ServiceConnectionState.ConnectedReady -> DaemonState.Hidden.Connected + GrpcConnectivityState.Connecting, + GrpcConnectivityState.Shutdown, + GrpcConnectivityState.TransientFailure, + GrpcConnectivityState.Idle -> DaemonState.Show + GrpcConnectivityState.Ready -> DaemonState.Hidden.Connected } } else { // If we are stopped we intentionally stop service and don't care about showing overlay. diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt index 3c70717e47..66e9a719eb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt @@ -4,13 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.receiveAsFlow @@ -18,25 +14,20 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import net.mullvad.mullvadvpn.util.toPaymentState class OutOfTimeViewModel( private val accountRepository: AccountRepository, - private val serviceConnectionManager: ServiceConnectionManager, - private val deviceRepository: DeviceRepository, + deviceRepository: DeviceRepository, private val paymentUseCase: PaymentUseCase, private val outOfTimeUseCase: OutOfTimeUseCase, + private val connectionProxy: ConnectionProxy, private val pollAccountExpiry: Boolean = true, private val isPlayBuild: Boolean ) : ViewModel() { @@ -45,27 +36,17 @@ class OutOfTimeViewModel( val uiSideEffect = merge(_uiSideEffect.receiveAsFlow(), notOutOfTimeEffect()) val uiState = - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - flowOf(state.container) - } else { - emptyFlow() - } - } - .flatMapLatest { serviceConnection -> - combine( - serviceConnection.connectionProxy.tunnelStateFlow(), - deviceRepository.deviceState, - paymentUseCase.paymentAvailability, - ) { tunnelState, deviceState, paymentAvailability -> - OutOfTimeUiState( - tunnelState = tunnelState, - deviceName = deviceState.deviceName() ?: "", - showSitePayment = !isPlayBuild, - billingPaymentState = paymentAvailability?.toPaymentState(), - ) - } + combine( + connectionProxy.tunnelState, + deviceRepository.deviceState, + paymentUseCase.paymentAvailability, + ) { tunnelState, deviceState, paymentAvailability -> + OutOfTimeUiState( + tunnelState = tunnelState, + deviceName = deviceState?.displayName() ?: "", + showSitePayment = !isPlayBuild, + billingPaymentState = paymentAvailability?.toPaymentState(), + ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), OutOfTimeUiState()) @@ -80,21 +61,16 @@ class OutOfTimeViewModel( fetchPaymentAvailability() } - private fun ConnectionProxy.tunnelStateFlow(): Flow<TunnelState> = - callbackFlowFromNotifier(this.onStateChange) - fun onSitePaymentClick() { viewModelScope.launch { - _uiSideEffect.send( - UiSideEffect.OpenAccountView( - serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" - ) - ) + accountRepository.getWebsiteAuthToken()?.let { wwwAuthToken -> + _uiSideEffect.send(UiSideEffect.OpenAccountView(wwwAuthToken)) + } } } fun onDisconnectClick() { - viewModelScope.launch { serviceConnectionManager.connectionProxy()?.disconnect() } + viewModelScope.launch { connectionProxy.disconnect() } } private fun verifyPurchases() { @@ -114,8 +90,7 @@ class OutOfTimeViewModel( // If the payment was successful we want to update the account expiry. If not successful we // should check payment availability and verify any purchases to handle potential errors. if (success) { - updateAccountExpiry() - // _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) + viewModelScope.launch { updateAccountExpiry() } } else { fetchPaymentAvailability() verifyPurchases() // Attempt to verify again @@ -125,8 +100,8 @@ class OutOfTimeViewModel( } } - private fun updateAccountExpiry() { - accountRepository.fetchAccountExpiry() + private suspend fun updateAccountExpiry() { + accountRepository.getAccountData() } private fun notOutOfTimeEffect() = @@ -138,7 +113,7 @@ class OutOfTimeViewModel( } sealed interface UiSideEffect { - data class OpenAccountView(val token: String) : UiSideEffect + data class OpenAccountView(val token: WebsiteAuthToken) : UiSideEffect data object OpenConnectScreen : UiSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt index 4afa12219a..f7bbd73907 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.lib.model.ClearAllOverridesError import net.mullvad.mullvadvpn.repository.RelayOverridesRepository class ResetServerIpOverridesConfirmationViewModel( @@ -15,11 +16,26 @@ class ResetServerIpOverridesConfirmationViewModel( fun clearAllOverrides() = viewModelScope.launch { - relayOverridesRepository.clearAllOverrides() - _uiSideEffect.send(ResetServerIpOverridesConfirmationUiSideEffect.OverridesCleared) + relayOverridesRepository + .clearAllOverrides() + .fold( + { + _uiSideEffect.send( + ResetServerIpOverridesConfirmationUiSideEffect.OverridesError(it) + ) + }, + { + _uiSideEffect.send( + ResetServerIpOverridesConfirmationUiSideEffect.OverridesCleared + ) + } + ) } } sealed class ResetServerIpOverridesConfirmationUiSideEffect { data object OverridesCleared : ResetServerIpOverridesConfirmationUiSideEffect() + + data class OverridesError(val error: ClearAllOverridesError) : + ResetServerIpOverridesConfirmationUiSideEffect() } 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 27edb95457..2ab757bd78 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 @@ -5,52 +5,59 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.state.toNullableOwnership import net.mullvad.mullvadvpn.compose.state.toSelectedProviders -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.relaylist.Provider -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.relaylist.descendants import net.mullvad.mullvadvpn.relaylist.filterOnOwnershipAndProvider import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm -import net.mullvad.mullvadvpn.relaylist.toLocationConstraint -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy -import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase +import net.mullvad.mullvadvpn.util.combine class SelectLocationViewModel( - private val serviceConnectionManager: ServiceConnectionManager, - private val relayListUseCase: RelayListUseCase, - private val relayListFilterUseCase: RelayListFilterUseCase, - private val customListActionUseCase: CustomListActionUseCase + private val relayListFilterRepository: RelayListFilterRepository, + availableProvidersUseCase: AvailableProvidersUseCase, + customListsRelayItemUseCase: CustomListsRelayItemUseCase, + private val customListActionUseCase: CustomListActionUseCase, + filteredRelayListUseCase: FilteredRelayListUseCase, + private val relayListRepository: RelayListRepository ) : ViewModel() { private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) @Suppress("DestructuringDeclarationWithTooManyEntries") val uiState = combine( - relayListUseCase.relayListWithSelection(), + filteredRelayListUseCase.filteredRelayList(), + customListsRelayItemUseCase.relayItemCustomLists(), + relayListRepository.selectedLocation, _searchTerm, - relayListFilterUseCase.selectedOwnership(), - relayListFilterUseCase.availableProviders(), - relayListFilterUseCase.selectedProviders(), + relayListFilterRepository.selectedOwnership, + availableProvidersUseCase.availableProviders(), + relayListFilterRepository.selectedProviders, ) { - (customLists, _, relayCountries, selectedItem), + relayCountries, + customLists, + selectedItem, searchTerm, selectedOwnership, allProviders, selectedConstraintProviders -> + val selectRelayItemId = selectedItem.getOrNull() val selectedOwnershipItem = selectedOwnership.toNullableOwnership() val selectedProvidersCount = when (selectedConstraintProviders) { @@ -58,21 +65,21 @@ class SelectLocationViewModel( is Constraint.Only -> filterSelectedProvidersByOwnership( selectedConstraintProviders.toSelectedProviders(allProviders), - selectedOwnershipItem + selectedOwnershipItem, ) .size } val filteredRelayCountries = - relayCountries.filterOnSearchTerm(searchTerm, selectedItem) + relayCountries.filterOnSearchTerm(searchTerm, selectRelayItemId) val filteredCustomLists = - customLists.filterOnSearchTerm(searchTerm).map { customList -> - customList.filterOnOwnershipAndProvider( - selectedOwnership, - selectedConstraintProviders + customLists + .filterOnSearchTerm(searchTerm) + .filterOnOwnershipAndProvider( + ownership = selectedOwnership, + providers = selectedConstraintProviders, ) - } SelectLocationUiState.Content( searchTerm = searchTerm, @@ -81,7 +88,7 @@ class SelectLocationViewModel( filteredCustomLists = filteredCustomLists, customLists = customLists, countries = filteredRelayCountries, - selectedItem = selectedItem, + selectedItem = selectRelayItemId, ) } .stateIn( @@ -93,15 +100,16 @@ class SelectLocationViewModel( private val _uiSideEffect = Channel<SelectLocationSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() - init { - viewModelScope.launch { relayListUseCase.fetchRelayList() } - } - fun selectRelay(relayItem: RelayItem) { - val locationConstraint = relayItem.toLocationConstraint() - relayListUseCase.updateSelectedRelayLocation(locationConstraint) - serviceConnectionManager.connectionProxy()?.connect() - _uiSideEffect.trySend(SelectLocationSideEffect.CloseScreen) + viewModelScope.launch { + val locationConstraint = relayItem.id + relayListRepository + .updateSelectedRelayLocation(locationConstraint) + .fold( + { _uiSideEffect.trySend(SelectLocationSideEffect.GenericError) }, + { _uiSideEffect.trySend(SelectLocationSideEffect.CloseScreen) }, + ) + } } fun onSearchTermInput(searchTerm: String) { @@ -112,41 +120,27 @@ class SelectLocationViewModel( selectedProviders: List<Provider>, selectedOwnership: Ownership? ): List<Provider> = - when (selectedOwnership) { - Ownership.MullvadOwned -> selectedProviders.filter { it.mullvadOwned } - Ownership.Rented -> selectedProviders.filterNot { it.mullvadOwned } - else -> selectedProviders - } + if (selectedOwnership == null) selectedProviders + else selectedProviders.filter { it.ownership == selectedOwnership } fun removeOwnerFilter() { - viewModelScope.launch { - relayListFilterUseCase.updateOwnershipAndProviderFilter( - Constraint.Any(), - relayListFilterUseCase.selectedProviders().first(), - ) - } + viewModelScope.launch { relayListFilterRepository.updateSelectedOwnership(Constraint.Any) } } fun removeProviderFilter() { - viewModelScope.launch { - relayListFilterUseCase.updateOwnershipAndProviderFilter( - relayListFilterUseCase.selectedOwnership().first(), - Constraint.Any(), - ) - } + viewModelScope.launch { relayListFilterRepository.updateSelectedProviders(Constraint.Any) } } - fun addLocationToList(item: RelayItem, customList: RelayItem.CustomList) { + fun addLocationToList(item: RelayItem.Location, customList: RelayItem.CustomList) { viewModelScope.launch { val newLocations = - (customList.locations + item).filter { it !in item.descendants() }.map { it.code } - val result = - customListActionUseCase.performAction( - CustomListAction.UpdateLocations(customList.id, newLocations) + (customList.locations + item).filter { it !in item.descendants() }.map { it.id } + customListActionUseCase + .performAction(CustomListAction.UpdateLocations(customList.id, newLocations)) + .fold( + { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, + { _uiSideEffect.send(SelectLocationSideEffect.LocationAddedToCustomList(it)) }, ) - _uiSideEffect.send( - SelectLocationSideEffect.LocationAddedToCustomList(result.getOrThrow()) - ) } } @@ -154,19 +148,29 @@ class SelectLocationViewModel( viewModelScope.launch { customListActionUseCase.performAction(action) } } - fun removeLocationFromList(item: RelayItem, customList: RelayItem.CustomList) { + fun removeLocationFromList(item: RelayItem.Location, customList: RelayItem.CustomList) { viewModelScope.launch { - val newLocations = (customList.locations - item).map { it.code } - val result = - customListActionUseCase.performAction( - CustomListAction.UpdateLocations(customList.id, newLocations) + val newLocations = (customList.locations - item).map { it.id } + customListActionUseCase + .performAction(CustomListAction.UpdateLocations(customList.id, newLocations)) + .fold( + { _uiSideEffect.send(SelectLocationSideEffect.GenericError) }, + { + _uiSideEffect.send( + SelectLocationSideEffect.LocationRemovedFromCustomList(it) + ) + } ) - _uiSideEffect.send( - SelectLocationSideEffect.LocationRemovedFromCustomList(result.getOrThrow()) - ) } } + private fun List<RelayItem.CustomList>.filterOnOwnershipAndProvider( + ownership: Constraint<Ownership>, + providers: Constraint<Providers> + ): List<RelayItem.CustomList> = map { item -> + item.filterOnOwnershipAndProvider(ownership, providers) + } + companion object { private const val EMPTY_SEARCH_TERM = "" } @@ -175,9 +179,9 @@ class SelectLocationViewModel( sealed interface SelectLocationSideEffect { data object CloseScreen : SelectLocationSideEffect - data class LocationAddedToCustomList(val result: CustomListResult.LocationsChanged) : - SelectLocationSideEffect + data class LocationAddedToCustomList(val result: LocationsChanged) : SelectLocationSideEffect + + class LocationRemovedFromCustomList(val result: LocationsChanged) : SelectLocationSideEffect - class LocationRemovedFromCustomList(val result: CustomListResult.LocationsChanged) : - SelectLocationSideEffect + data object GenericError : SelectLocationSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt index 5a77727b18..069eda8dc8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModel.kt @@ -5,29 +5,20 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import java.io.InputStreamReader -import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import net.mullvad.mullvadvpn.model.SettingsPatchError +import net.mullvad.mullvadvpn.lib.model.SettingsPatchError import net.mullvad.mullvadvpn.repository.RelayOverridesRepository -import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState class ServerIpOverridesViewModel( - private val serviceConnectionManager: ServiceConnectionManager, - relayOverridesRepository: RelayOverridesRepository, - private val settingsRepository: SettingsRepository, + private val relayOverridesRepository: RelayOverridesRepository, private val contentResolver: ContentResolver, ) : ViewModel() { @@ -56,21 +47,17 @@ class ServerIpOverridesViewModel( fun importText(json: String) = viewModelScope.launch { applySettingsPatch(json) } private suspend fun applySettingsPatch(json: String) { - // Wait for daemon to come online since we might be disconnected (due to File picker being - // open - // and we disconnect from daemon in paused state) - val connResult = - withTimeoutOrNull(5.seconds) { - serviceConnectionManager.connectionState - .filterIsInstance(ServiceConnectionState.ConnectedReady::class) - .first() - } - if (connResult != null) { - // Apply patch - val result = settingsRepository.applySettingsPatch(json) - _uiSideEffect.send(ServerIpOverridesUiSideEffect.ImportResult(result.error)) - } else { - // Service never came online, at this point we should already display daemon overlay + // Since we are currently using waitForReady this will just wait to apply until gRPC is + // ready + viewModelScope.launch { + relayOverridesRepository + .applySettingsPatch(json) + .fold( + { error -> + _uiSideEffect.send(ServerIpOverridesUiSideEffect.ImportResult(error)) + }, + { _uiSideEffect.send(ServerIpOverridesUiSideEffect.ImportResult(null)) } + ) } } } 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 b836894cb7..5150af2747 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 @@ -7,26 +7,25 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.compose.state.SettingsUiState -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.repository.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository class SettingsViewModel( deviceRepository: DeviceRepository, - serviceConnectionManager: ServiceConnectionManager, + appVersionInfoRepository: AppVersionInfoRepository, isPlayBuild: Boolean ) : ViewModel() { private val vmState: StateFlow<SettingsUiState> = - combine(deviceRepository.deviceState, serviceConnectionManager.connectionState) { + combine(deviceRepository.deviceState, appVersionInfoRepository.versionInfo()) { deviceState, versionInfo -> - val cachedVersionInfo = versionInfo.readyContainer()?.appVersionInfoCache SettingsUiState( isLoggedIn = deviceState is DeviceState.LoggedIn, - appVersion = cachedVersionInfo?.version ?: "", + appVersion = versionInfo.currentVersion, isUpdateAvailable = - cachedVersionInfo?.let { it.isSupported.not() || it.isOutdated } ?: false, + versionInfo.let { it.isSupported.not() || it.isUpdateAvailable }, isPlayBuild = isPlayBuild ) } 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 index 83442059da..bd34161e2c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt @@ -10,17 +10,15 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_TIMEOUT_MS -import net.mullvad.mullvadvpn.model.AccountAndDevice -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.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository class SplashViewModel( private val privacyDisclaimerRepository: PrivacyDisclaimerRepository, - private val deviceRepository: DeviceRepository, private val accountRepository: AccountRepository, + private val deviceRepository: DeviceRepository, ) : ViewModel() { val uiSideEffect = flow { emit(getStartDestination()) } @@ -34,12 +32,10 @@ class SplashViewModel( deviceRepository.deviceState .map { when (it) { - DeviceState.Initial -> null - is DeviceState.LoggedIn -> - ValidStartDeviceState.LoggedIn(it.accountAndDevice) + is DeviceState.LoggedIn -> ValidStartDeviceState.LoggedIn DeviceState.LoggedOut -> ValidStartDeviceState.LoggedOut DeviceState.Revoked -> ValidStartDeviceState.Revoked - DeviceState.Unknown -> null + null -> null } } .filterNotNull() @@ -48,38 +44,30 @@ class SplashViewModel( return when (deviceState) { ValidStartDeviceState.LoggedOut -> SplashUiSideEffect.NavigateToLogin ValidStartDeviceState.Revoked -> SplashUiSideEffect.NavigateToRevoked - is ValidStartDeviceState.LoggedIn -> getLoggedInStartDestination() + 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 { - accountRepository.accountExpiryState.first { it !is AccountExpiry.Missing } - } + val expiry = viewModelScope.async { accountRepository.accountData.filterNotNull().first() } - val accountExpiry = select { + val accountData = select { expiry.onAwait { it } // If we don't get a response within 1 second, assume the account expiry is Missing - onTimeout(ACCOUNT_EXPIRY_TIMEOUT_MS) { AccountExpiry.Missing } + onTimeout(ACCOUNT_EXPIRY_TIMEOUT_MS) { null } } - return when (accountExpiry) { - is AccountExpiry.Available -> { - if (accountExpiry.expiryDateTime.isBeforeNow) { - SplashUiSideEffect.NavigateToOutOfTime - } else { - SplashUiSideEffect.NavigateToConnect - } - } - AccountExpiry.Missing -> SplashUiSideEffect.NavigateToConnect + return if (accountData != null && accountData.expiryDate.isBeforeNow) { + SplashUiSideEffect.NavigateToOutOfTime + } else { + SplashUiSideEffect.NavigateToConnect } } } private sealed interface ValidStartDeviceState { - data class LoggedIn(val accountAndDevice: AccountAndDevice) : ValidStartDeviceState + data object LoggedIn : ValidStartDeviceState data object Revoked : ValidStartDeviceState 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 833117c046..b43e046e57 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 @@ -3,69 +3,46 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -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.ApplicationsProvider import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling -import net.mullvad.mullvadvpn.ui.serviceconnection.splitTunneling +import net.mullvad.mullvadvpn.lib.model.AppId +import net.mullvad.mullvadvpn.repository.SplitTunnelingRepository class SplitTunnelingViewModel( private val appsProvider: ApplicationsProvider, - private val serviceConnectionManager: ServiceConnectionManager, + private val splitTunnelingRepository: SplitTunnelingRepository, private val dispatcher: CoroutineDispatcher ) : ViewModel() { private val allApps = MutableStateFlow<List<AppData>?>(null) private val showSystemApps = MutableStateFlow(false) - private val _shared: SharedFlow<ServiceConnectionContainer> = - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - flowOf(state.container) - } else { - emptyFlow() - } - } - .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) - - private val vmState = - _shared - .flatMapLatest { serviceConnection -> - combine( - serviceConnection.splitTunneling.excludedAppsCallbackFlow(), - serviceConnection.splitTunneling.enabledCallbackFlow(), - allApps, - showSystemApps, - ) { excludedApps, enabled, allApps, showSystemApps -> - SplitTunnelingViewModelState( - excludedApps = excludedApps, - enabled = enabled, - allApps = allApps, - showSystemApps = showSystemApps - ) - } + private val vmState: StateFlow<SplitTunnelingViewModelState> = + combine( + splitTunnelingRepository.excludedApps, + splitTunnelingRepository.splitTunnelingEnabled, + allApps, + showSystemApps, + ) { excludedApps, enabled, allApps, showSystemApps -> + SplitTunnelingViewModelState( + excludedApps = excludedApps, + enabled = enabled, + allApps = allApps, + showSystemApps = showSystemApps, + ) } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - SplitTunnelingViewModelState() + SplitTunnelingViewModelState(), ) val uiState = @@ -74,33 +51,28 @@ class SplitTunnelingViewModel( .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - SplitTunnelingUiState.Loading(enabled = false) + SplitTunnelingUiState.Loading(enabled = false), ) init { viewModelScope.launch(dispatcher) { fetchApps() } } - override fun onCleared() { - serviceConnectionManager.splitTunneling()?.persist() - super.onCleared() - } - fun onEnableSplitTunneling(isEnabled: Boolean) { viewModelScope.launch(dispatcher) { - serviceConnectionManager.splitTunneling()?.enableSplitTunneling(isEnabled) + splitTunnelingRepository.enableSplitTunneling(isEnabled) } } fun onIncludeAppClick(packageName: String) { viewModelScope.launch(dispatcher) { - serviceConnectionManager.splitTunneling()?.includeApp(packageName) + splitTunnelingRepository.includeApp(AppId(packageName)) } } fun onExcludeAppClick(packageName: String) { viewModelScope.launch(dispatcher) { - serviceConnectionManager.splitTunneling()?.excludeApp(packageName) + splitTunnelingRepository.excludeApp(AppId(packageName)) } } @@ -111,14 +83,4 @@ class SplitTunnelingViewModel( private suspend fun fetchApps() { appsProvider.getAppsList().let { appsList -> allApps.emit(appsList) } } - - private fun SplitTunneling.excludedAppsCallbackFlow() = callbackFlow { - excludedAppsChange = { apps -> trySend(apps) } - awaitClose { emptySet<String>() } - } - - private fun SplitTunneling.enabledCallbackFlow() = callbackFlow { - enabledChange = { isEnabled -> trySend(isEnabled) } - awaitClose() - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt index bc16662f00..89dde0decb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModelState.kt @@ -2,10 +2,11 @@ package net.mullvad.mullvadvpn.viewmodel import net.mullvad.mullvadvpn.applist.AppData import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState +import net.mullvad.mullvadvpn.lib.model.AppId data class SplitTunnelingViewModelState( val enabled: Boolean = false, - val excludedApps: Set<String> = emptySet(), + val excludedApps: Set<AppId> = emptySet(), val allApps: List<AppData>? = null, val showSystemApps: Boolean = false ) { @@ -13,7 +14,7 @@ data class SplitTunnelingViewModelState( return allApps ?.partition { appData -> if (enabled) { - excludedApps.contains(appData.packageName) + excludedApps.contains(AppId(appData.packageName)) } else { false } 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 8022332650..3d67b42bd1 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 @@ -1,67 +1,40 @@ package net.mullvad.mullvadvpn.viewmodel -import android.content.res.Resources import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.state.VoucherDialogState import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState import net.mullvad.mullvadvpn.constant.VOUCHER_LENGTH -import net.mullvad.mullvadvpn.model.VoucherSubmissionError -import net.mullvad.mullvadvpn.model.VoucherSubmissionResult -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.voucherRedeemer +import net.mullvad.mullvadvpn.lib.model.RedeemVoucherError +import net.mullvad.mullvadvpn.lib.shared.VoucherRepository import net.mullvad.mullvadvpn.util.VoucherRegexHelper -class VoucherDialogViewModel( - private val serviceConnectionManager: ServiceConnectionManager, - private val resources: Resources -) : ViewModel() { +class VoucherDialogViewModel(private val voucherRepository: VoucherRepository) : ViewModel() { private val vmState = MutableStateFlow<VoucherDialogState>(VoucherDialogState.Default) private val voucherInput = MutableStateFlow("") - private val _shared: SharedFlow<ServiceConnectionContainer> = - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - flowOf(state.container) - } else { - emptyFlow() - } - } - .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) - val uiState = - _shared - .flatMapLatest { - combine(vmState, voucherInput) { state, input -> - VoucherDialogUiState(voucherInput = input, voucherState = state) - } + combine(vmState, voucherInput) { state, input -> + VoucherDialogUiState(voucherInput = input, voucherState = state) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), VoucherDialogUiState.INITIAL) fun onRedeem(voucherCode: String) { vmState.update { VoucherDialogState.Verifying } viewModelScope.launch { - when (val result = serviceConnectionManager.voucherRedeemer()?.submit(voucherCode)) { - is VoucherSubmissionResult.Ok -> handleAddedTime(result.submission.timeAdded) - is VoucherSubmissionResult.Error -> setError(result.error) - null -> vmState.update { VoucherDialogState.Default } - } + voucherRepository + .submitVoucher(voucherCode) + .fold( + { error -> setError(error) }, + { success -> handleAddedTime(success.timeAdded) } + ) } } @@ -81,18 +54,7 @@ class VoucherDialogViewModel( viewModelScope.launch { vmState.update { VoucherDialogState.Success(timeAdded) } } } - private fun setError(error: VoucherSubmissionError) { - viewModelScope.launch { - val message = - resources.getString( - when (error) { - VoucherSubmissionError.InvalidVoucher -> R.string.invalid_voucher - VoucherSubmissionError.VoucherAlreadyUsed -> R.string.voucher_already_used - VoucherSubmissionError.RpcError, - VoucherSubmissionError.OtherError -> R.string.error_occurred - } - ) - vmState.update { VoucherDialogState.Error(message) } - } + private fun setError(error: RedeemVoucherError) { + viewModelScope.launch { vmState.update { VoucherDialogState.Error(error) } } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnPermissionViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnPermissionViewModel.kt new file mode 100644 index 0000000000..cd9a52efa1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnPermissionViewModel.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.lib.common.constant.KEY_REQUEST_VPN_PERMISSION +import net.mullvad.mullvadvpn.lib.intent.IntentProvider +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy + +class VpnPermissionViewModel( + intentProvider: IntentProvider, + private val connectionProxy: ConnectionProxy +) : ViewModel() { + val uiSideEffect: Flow<VpnPermissionSideEffect> = + intentProvider.intents + .filter { it?.action == KEY_REQUEST_VPN_PERMISSION } + .distinctUntilChanged() + .map { VpnPermissionSideEffect.ShowDialog } + .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) + + fun connect() { + viewModelScope.launch { connectionProxy.connectWithoutPermissionCheck() } + } +} + +sealed interface VpnPermissionSideEffect { + data object ShowDialog : VpnPermissionSideEffect +} 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 ba487c5a40..864d402fb3 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 @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.viewmodel -import android.content.res.Resources import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -19,36 +18,35 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.DefaultDnsOptions -import net.mullvad.mullvadvpn.model.DnsState -import net.mullvad.mullvadvpn.model.ObfuscationSettings -import net.mullvad.mullvadvpn.model.Port -import net.mullvad.mullvadvpn.model.QuantumResistantState -import net.mullvad.mullvadvpn.model.RelaySettings -import net.mullvad.mullvadvpn.model.SelectedObfuscation -import net.mullvad.mullvadvpn.model.Settings -import net.mullvad.mullvadvpn.model.Udp2TcpObfuscationSettings -import net.mullvad.mullvadvpn.model.WireguardConstraints +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions +import net.mullvad.mullvadvpn.lib.model.DnsState +import net.mullvad.mullvadvpn.lib.model.ObfuscationSettings +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.QuantumResistantState +import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation +import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.Udp2TcpObfuscationSettings +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.usecase.PortRangeUseCase -import net.mullvad.mullvadvpn.usecase.RelayListUseCase import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsUseCase import net.mullvad.mullvadvpn.util.isCustom sealed interface VpnSettingsSideEffect { - data class ShowToast(val message: String) : VpnSettingsSideEffect + sealed interface ShowToast : VpnSettingsSideEffect { + data object ApplySettingsWarning : ShowToast + + data object GenericError : ShowToast + } data object NavigateToDnsDialog : VpnSettingsSideEffect } class VpnSettingsViewModel( private val repository: SettingsRepository, - private val resources: Resources, - portRangeUseCase: PortRangeUseCase, - private val relayListUseCase: RelayListUseCase, + private val relayListRepository: RelayListRepository, private val systemVpnSettingsUseCase: SystemVpnSettingsUseCase, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { @@ -59,12 +57,12 @@ class VpnSettingsViewModel( private val customPort = MutableStateFlow<Constraint<Port>?>(null) private val vmState = - combine(repository.settingsUpdates, portRangeUseCase.portRanges(), customPort) { + combine(repository.settingsUpdates, relayListRepository.portRanges, customPort) { settings, portRanges, customWgPort -> VpnSettingsViewModelState( - mtuValue = settings?.mtuString() ?: "", + mtuValue = settings?.tunnelOptions?.wireguard?.mtu, isAutoConnectEnabled = settings?.autoConnect ?: false, isLocalNetworkSharingEnabled = settings?.allowLan ?: false, isCustomDnsEnabled = settings?.isCustomDnsEnabled() ?: false, @@ -74,7 +72,7 @@ class VpnSettingsViewModel( selectedObfuscation = settings?.selectedObfuscationSettings() ?: SelectedObfuscation.Off, quantumResistant = settings?.quantumResistant() ?: QuantumResistantState.Off, - selectedWireguardPort = settings?.getWireguardPort() ?: Constraint.Any(), + selectedWireguardPort = settings?.getWireguardPort() ?: Constraint.Any, customWireguardPort = customWgPort, availablePortRanges = portRanges, systemVpnSettingsAvailable = @@ -111,11 +109,19 @@ class VpnSettingsViewModel( } fun onToggleAutoConnect(isEnabled: Boolean) { - viewModelScope.launch(dispatcher) { repository.setAutoConnect(isEnabled) } + viewModelScope.launch(dispatcher) { + repository.setAutoConnect(isEnabled).onLeft { + _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) + } + } } fun onToggleLocalNetworkSharing(isEnabled: Boolean) { - viewModelScope.launch(dispatcher) { repository.setLocalNetworkSharing(isEnabled) } + viewModelScope.launch(dispatcher) { + repository.setLocalNetworkSharing(isEnabled).onLeft { + _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) + } + } } fun onDnsDialogDismissed() { @@ -125,11 +131,21 @@ class VpnSettingsViewModel( } fun onToggleCustomDns(enable: Boolean) { - repository.setDnsState(if (enable) DnsState.Custom else DnsState.Default) - if (enable && vmState.value.customDnsList.isEmpty()) { - viewModelScope.launch { _uiSideEffect.send(VpnSettingsSideEffect.NavigateToDnsDialog) } - } else if (vmState.value.customDnsList.isNotEmpty()) { - showApplySettingChangesWarningToast() + viewModelScope.launch { + repository + .setDnsState(if (enable) DnsState.Custom else DnsState.Default) + .fold( + { _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) }, + { + if (enable && vmState.value.customDnsList.isEmpty()) { + viewModelScope.launch { + _uiSideEffect.send(VpnSettingsSideEffect.NavigateToDnsDialog) + } + } else if (vmState.value.customDnsList.isNotEmpty()) { + showApplySettingChangesWarningToast() + } + } + ) } } @@ -176,25 +192,33 @@ class VpnSettingsViewModel( } fun onStopEvent() { - if (vmState.value.customDnsList.isEmpty()) { - repository.setDnsState(DnsState.Default) + viewModelScope.launch { + if (vmState.value.customDnsList.isEmpty()) { + repository.setDnsState(DnsState.Default).onLeft { + _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) + } + } } } fun onSelectObfuscationSetting(selectedObfuscation: SelectedObfuscation) { viewModelScope.launch(dispatcher) { - repository.setObfuscationOptions( - ObfuscationSettings( - selectedObfuscation = selectedObfuscation, - udp2tcp = Udp2TcpObfuscationSettings(Constraint.Any()) + repository + .setObfuscationOptions( + ObfuscationSettings( + selectedObfuscation = selectedObfuscation, + udp2tcp = Udp2TcpObfuscationSettings(Constraint.Any) + ) ) - ) + .onLeft { _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) } } } fun onSelectQuantumResistanceSetting(quantumResistant: QuantumResistantState) { viewModelScope.launch(dispatcher) { - repository.setWireguardQuantumResistant(quantumResistant) + repository.setWireguardQuantumResistant(quantumResistant).onLeft { + _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) + } } } @@ -202,26 +226,34 @@ class VpnSettingsViewModel( if (port.isCustom()) { customPort.update { port } } - relayListUseCase.updateSelectedWireguardConstraints(WireguardConstraints(port = port)) + viewModelScope.launch { + relayListRepository.updateSelectedWireguardConstraints( + WireguardConstraints(port = port) + ) + } } fun resetCustomPort() { customPort.update { null } // If custom port was selected, update selection to be any. if (vmState.value.selectedWireguardPort.isCustom()) { - relayListUseCase.updateSelectedWireguardConstraints( - WireguardConstraints(port = Constraint.Any()) - ) + viewModelScope.launch { + relayListRepository.updateSelectedWireguardConstraints( + WireguardConstraints(port = Constraint.Any) + ) + } } } private fun updateDefaultDnsOptionsViaRepository(contentBlockersOption: DefaultDnsOptions) = viewModelScope.launch(dispatcher) { - repository.setDnsOptions( - isCustomDnsEnabled = vmState.value.isCustomDnsEnabled, - dnsList = vmState.value.customDnsList.map { it.address }.asInetAddressList(), - contentBlockersOptions = contentBlockersOption - ) + repository + .setDnsOptions( + isCustomDnsEnabled = vmState.value.isCustomDnsEnabled, + dnsList = vmState.value.customDnsList.map { it.address }.asInetAddressList(), + contentBlockersOptions = contentBlockersOption + ) + .onLeft { _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) } } private fun List<String>.asInetAddressList(): List<InetAddress> { @@ -239,8 +271,6 @@ class VpnSettingsViewModel( } } - private fun Settings.mtuString() = tunnelOptions.wireguard.mtu?.toString() ?: EMPTY_STRING - private fun Settings.quantumResistant() = tunnelOptions.wireguard.quantumResistant private fun Settings.isCustomDnsEnabled() = tunnelOptions.dnsOptions.state == DnsState.Custom @@ -252,11 +282,7 @@ class VpnSettingsViewModel( private fun Settings.selectedObfuscationSettings() = obfuscationSettings.selectedObfuscation private fun Settings.getWireguardPort() = - when (relaySettings) { - RelaySettings.CustomTunnelEndpoint -> Constraint.Any() - is RelaySettings.Normal -> - (relaySettings as RelaySettings.Normal).relayConstraints.wireguardConstraints.port - } + relaySettings.relayConstraints.wireguardConstraints.port private fun InetAddress.isLocalAddress(): Boolean { return isLinkLocalAddress || isSiteLocalAddress @@ -264,14 +290,14 @@ class VpnSettingsViewModel( fun showApplySettingChangesWarningToast() { viewModelScope.launch { - _uiSideEffect.send( - VpnSettingsSideEffect.ShowToast( - resources.getString(R.string.settings_changes_effect_warning_short) - ) - ) + _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.ApplySettingsWarning) } } + fun showGenericErrorToast() { + viewModelScope.launch { _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) } + } + 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 91866d5cc2..f8e4f0b799 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,15 +1,16 @@ package net.mullvad.mullvadvpn.viewmodel import net.mullvad.mullvadvpn.compose.state.VpnSettingsUiState -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.DefaultDnsOptions -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.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.DefaultDnsOptions +import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange +import net.mullvad.mullvadvpn.lib.model.QuantumResistantState +import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation data class VpnSettingsViewModelState( - val mtuValue: String, + val mtuValue: Mtu?, val isAutoConnectEnabled: Boolean, val isLocalNetworkSharingEnabled: Boolean, val isCustomDnsEnabled: Boolean, @@ -39,11 +40,9 @@ data class VpnSettingsViewModelState( ) companion object { - private const val EMPTY_STRING = "" - fun default() = VpnSettingsViewModelState( - mtuValue = EMPTY_STRING, + mtuValue = null, isAutoConnectEnabled = false, isLocalNetworkSharingEnabled = false, isCustomDnsEnabled = false, @@ -51,7 +50,7 @@ data class VpnSettingsViewModelState( contentBlockersOptions = DefaultDnsOptions(), selectedObfuscation = SelectedObfuscation.Auto, quantumResistant = QuantumResistantState.Off, - selectedWireguardPort = Constraint.Any(), + selectedWireguardPort = Constraint.Any, customWireguardPort = null, availablePortRanges = emptyList(), systemVpnSettingsAvailable = false diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt index 0f6b23a306..208c9d871b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt @@ -2,19 +2,13 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow @@ -22,25 +16,18 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS -import net.mullvad.mullvadvpn.util.addDebounceForUnknownState -import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import net.mullvad.mullvadvpn.util.toPaymentState -@OptIn(FlowPreview::class) class WelcomeViewModel( private val accountRepository: AccountRepository, - private val deviceRepository: DeviceRepository, - private val serviceConnectionManager: ServiceConnectionManager, + deviceRepository: DeviceRepository, private val paymentUseCase: PaymentUseCase, + connectionProxy: ConnectionProxy, private val pollAccountExpiry: Boolean = true, private val isPlayBuild: Boolean ) : ViewModel() { @@ -48,30 +35,18 @@ class WelcomeViewModel( val uiSideEffect = merge(_uiSideEffect.receiveAsFlow(), hasAddedTimeEffect()) val uiState = - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - flowOf(state.container) - } else { - emptyFlow() - } - } - .flatMapLatest { serviceConnection -> - combine( - serviceConnection.connectionProxy.tunnelUiStateFlow(), - deviceRepository.deviceState.debounce { - it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) - }, - paymentUseCase.paymentAvailability, - ) { tunnelState, deviceState, paymentAvailability -> - WelcomeUiState( - tunnelState = tunnelState, - accountNumber = deviceState.token(), - deviceName = deviceState.deviceName(), - showSitePayment = !isPlayBuild, - billingPaymentState = paymentAvailability?.toPaymentState(), - ) - } + combine( + connectionProxy.tunnelState, + deviceRepository.deviceState.filterNotNull(), + paymentUseCase.paymentAvailability, + ) { tunnelState, accountState, paymentAvailability -> + WelcomeUiState( + tunnelState = tunnelState, + accountNumber = accountState.token(), + deviceName = accountState.displayName(), + showSitePayment = !isPlayBuild, + billingPaymentState = paymentAvailability?.toPaymentState(), + ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), WelcomeUiState()) @@ -87,22 +62,17 @@ class WelcomeViewModel( } private fun hasAddedTimeEffect() = - accountRepository.accountExpiryState - .mapNotNull { it.date() } - .filter { it.minusHours(MIN_HOURS_PAST_ACCOUNT_EXPIRY).isAfterNow } + accountRepository.accountData + .filterNotNull() + .filter { it.expiryDate.minusHours(MIN_HOURS_PAST_ACCOUNT_EXPIRY).isAfterNow } .onEach { paymentUseCase.resetPurchaseResult() } .map { UiSideEffect.OpenConnectScreen } - private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> = - callbackFlowFromNotifier(this.onUiStateChange) - fun onSitePaymentClick() { viewModelScope.launch { - _uiSideEffect.send( - UiSideEffect.OpenAccountView( - serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" - ) - ) + accountRepository.getWebsiteAuthToken()?.let { token -> + _uiSideEffect.send(UiSideEffect.OpenAccountView(token)) + } } } @@ -123,7 +93,7 @@ class WelcomeViewModel( // If the payment was successful we want to update the account expiry. If not successful we // should check payment availability and verify any purchases to handle potential errors. if (success) { - updateAccountExpiry() + viewModelScope.launch { updateAccountExpiry() } // Emission of out of time navigation is handled by launch in onStart } else { fetchPaymentAvailability() @@ -134,12 +104,12 @@ class WelcomeViewModel( } } - private fun updateAccountExpiry() { - accountRepository.fetchAccountExpiry() + private suspend fun updateAccountExpiry() { + accountRepository.getAccountData() } sealed interface UiSideEffect { - data class OpenAccountView(val token: String) : UiSideEffect + data class OpenAccountView(val token: WebsiteAuthToken) : UiSideEffect data object OpenConnectScreen : UiSideEffect } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt index 9767d3930a..c9cfb0e75c 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt @@ -12,13 +12,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase -import net.mullvad.talpid.tunnel.ErrorState import org.joda.time.DateTime import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt deleted file mode 100644 index eb66c2d4f9..0000000000 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparatorTest.kt +++ /dev/null @@ -1,256 +0,0 @@ -package net.mullvad.mullvadvpn.relaylist - -import io.mockk.mockk -import io.mockk.unmockkAll -import net.mullvad.mullvadvpn.model.Ownership -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class RelayNameComparatorTest { - - @AfterEach - fun tearDown() { - unmockkAll() - } - - @Test - fun `given two relays with same prefix but different numbers comparator should return lowest number first`() { - val relay9 = - RelayItem.Relay( - name = "se9-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relay10 = - RelayItem.Relay( - name = "se10-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - - relay9 assertOrderBothDirection relay10 - } - - @Test - fun `given two relays with same name with number in name comparator should return 0`() { - val relay9a = - RelayItem.Relay( - name = "se9-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relay9b = - RelayItem.Relay( - name = "se9-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - - assertTrue(RelayNameComparator.compare(relay9a, relay9b) == 0) - assertTrue(RelayNameComparator.compare(relay9b, relay9a) == 0) - } - - @Test - fun `comparator should be able to handle name of only numbers`() { - val relay001 = - RelayItem.Relay( - name = "001", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relay1 = - RelayItem.Relay( - name = "1", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relay3 = - RelayItem.Relay( - name = "3", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relay100 = - RelayItem.Relay( - name = "100", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - - relay001 assertOrderBothDirection relay1 - relay001 assertOrderBothDirection relay3 - relay1 assertOrderBothDirection relay3 - relay3 assertOrderBothDirection relay100 - } - - @Test - fun `given two relays with same name and without number comparator should return 0`() { - val relay9a = - RelayItem.Relay( - name = "se-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relay9b = - RelayItem.Relay( - name = "se-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - - assertTrue(RelayNameComparator.compare(relay9a, relay9b) == 0) - assertTrue(RelayNameComparator.compare(relay9b, relay9a) == 0) - } - - @Test - fun `given two relays with leading zeroes comparator should return lowest number first`() { - val relay001 = - RelayItem.Relay( - name = "se001-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relay005 = - RelayItem.Relay( - name = "se005-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - - relay001 assertOrderBothDirection relay005 - } - - @Test - fun `given 4 relays comparator should sort by prefix then number`() { - val relayAr2 = - RelayItem.Relay( - name = "ar2-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relayAr8 = - RelayItem.Relay( - name = "ar8-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relaySe5 = - RelayItem.Relay( - name = "se5-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relaySe10 = - RelayItem.Relay( - name = "se10-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - - relayAr2 assertOrderBothDirection relayAr8 - relayAr8 assertOrderBothDirection relaySe5 - relaySe5 assertOrderBothDirection relaySe10 - } - - @Test - fun `given two relays with same prefix and number comparator should sort by suffix`() { - val relay2c = - RelayItem.Relay( - name = "se2-cloud", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relay2w = - RelayItem.Relay( - name = "se2-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - - relay2c assertOrderBothDirection relay2w - } - - @Test - fun `given two relays with same prefix, but one with no suffix, the one with no suffix should come first`() { - val relay22a = - RelayItem.Relay( - name = "se22", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - val relay22b = - RelayItem.Relay( - name = "se22-wireguard", - location = mockk(), - locationName = "mock", - active = false, - providerName = "Provider", - ownership = Ownership.MullvadOwned - ) - - relay22a assertOrderBothDirection relay22b - } - - private infix fun RelayItem.Relay.assertOrderBothDirection(other: RelayItem.Relay) { - assertTrue(RelayNameComparator.compare(this, other) < 0) - assertTrue(RelayNameComparator.compare(other, this) > 0) - } -} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepositoryTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepositoryTest.kt index 9c2ac615c3..4b8a524e5c 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepositoryTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepositoryTest.kt @@ -1,271 +1,236 @@ package net.mullvad.mullvadvpn.repository +import arrow.core.left +import arrow.core.right +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.MessageHandler -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.lib.ipc.events -import net.mullvad.mullvadvpn.model.CreateCustomListResult -import net.mullvad.mullvadvpn.model.CustomList -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.model.CustomListsError -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.RelayList -import net.mullvad.mullvadvpn.model.Settings -import net.mullvad.mullvadvpn.model.UpdateCustomListResult -import net.mullvad.mullvadvpn.relaylist.getGeographicLocationConstraintByCode -import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListAlreadyExists +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.GetCustomListError +import net.mullvad.mullvadvpn.lib.model.NameAlreadyExists +import net.mullvad.mullvadvpn.lib.model.Settings import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class CustomListsRepositoryTest { - private val mockMessageHandler: MessageHandler = mockk() - private val mockSettingsRepository: SettingsRepository = mockk() - private val mockRelayListListener: RelayListListener = mockk() - private val customListsRepository = - CustomListsRepository( - messageHandler = mockMessageHandler, - settingsRepository = mockSettingsRepository, - relayListListener = mockRelayListListener - ) + private val mockManagementService: ManagementService = mockk() + private lateinit var customListsRepository: CustomListsRepository - private val settingsFlow: MutableStateFlow<Settings?> = MutableStateFlow(null) - private val relayListFlow: MutableStateFlow<RelayList> = MutableStateFlow(mockk()) + private val settingsFlow: MutableStateFlow<Settings> = MutableStateFlow(mockk(relaxed = true)) @BeforeEach fun setup() { mockkStatic(RELAY_LIST_EXTENSIONS) - every { mockSettingsRepository.settingsUpdates } returns settingsFlow - every { mockRelayListListener.relayListEvents } returns relayListFlow + every { mockManagementService.settings } returns settingsFlow + customListsRepository = + CustomListsRepository( + managementService = mockManagementService, + dispatcher = UnconfinedTestDispatcher() + ) } @Test - fun `get custom list by id should return custom list when id matches custom list in settings`() { - // Arrange - val mockCustomList: CustomList = mockk() - val mockSettings: Settings = mockk() - val customListId = "1" - settingsFlow.value = mockSettings - every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) - every { mockCustomList.id } returns customListId + fun `get custom list by id should return custom list when id matches custom list in settings`() = + runTest { + // Arrange + val customListId = CustomListId("1") + val mockCustomList = + CustomList( + id = customListId, + name = mockk(relaxed = true), + locations = mockk(relaxed = true) + ) + val mockSettings: Settings = mockk() + every { mockSettings.customLists } returns listOf(mockCustomList) + settingsFlow.value = mockSettings - // Act - val result = customListsRepository.getCustomListById(customListId) + // Act + val result = customListsRepository.getCustomListById(customListId) - // Assert - assertEquals(mockCustomList, result) - } + // Assert + assertEquals(mockCustomList, result.getOrNull()) + } + + @Test + fun `get custom list by id should return get custom list error when id does not matches custom list in settings`() = + runTest { + // Arrange + val customListId = CustomListId("1") + val mockCustomList = + CustomList( + id = customListId, + name = mockk(relaxed = true), + locations = mockk(relaxed = true) + ) + val mockSettings: Settings = mockk() + val otherCustomListId = CustomListId("2") + every { mockSettings.customLists } returns listOf(mockCustomList) + settingsFlow.value = mockSettings + + // Act + val result = customListsRepository.getCustomListById(otherCustomListId) + + // Assert + assertEquals(GetCustomListError(otherCustomListId), result.leftOrNull()) + } @Test - fun `get custom list by id should return null when id does not matches custom list in settings`() { + fun `create custom list should return id when creation is successful`() = runTest { // Arrange - val mockCustomList: CustomList = mockk() - val mockSettings: Settings = mockk() - val customListId = "1" - val otherCustomListId = "2" - settingsFlow.value = mockSettings - every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) - every { mockCustomList.id } returns customListId + val customListId = CustomListId("1") + val expectedResult = customListId.right() + val customListName = CustomListName.fromString("CUSTOM") + coEvery { mockManagementService.createCustomList(customListName) } returns expectedResult // Act - val result = customListsRepository.getCustomListById(otherCustomListId) + val result = customListsRepository.createCustomList(customListName) // Assert - assertNull(result) + assertEquals(expectedResult, result) } @Test - fun `create custom list should return Ok when creation is successful`() = runTest { + fun `create custom list should return lists exists error from management service`() = runTest { // Arrange - val customListId = "1" - val expectedResult = CreateCustomListResult.Ok(customListId) - val customListName = "CUSTOM" - every { - mockMessageHandler.trySendRequest(Request.CreateCustomList(customListName)) - } returns true - every { mockMessageHandler.events<Event.CreateCustomListResultEvent>() } returns - flowOf(Event.CreateCustomListResultEvent(expectedResult)) + val expectedResult = CustomListAlreadyExists.left() + val customListName = CustomListName.fromString("CUSTOM") + coEvery { mockManagementService.createCustomList(customListName) } returns expectedResult // Act - val result = - customListsRepository.createCustomList(CustomListName.fromString(customListName)) + val result = customListsRepository.createCustomList(customListName) // Assert assertEquals(expectedResult, result) } @Test - fun `create custom list should return lists exists when lists exists error event is received`() = + fun `update custom list name should return success when call ManagementService is successful`() = runTest { // Arrange - val expectedResult = CreateCustomListResult.Error(CustomListsError.CustomListExists) - val customListName = "CUSTOM" - every { - mockMessageHandler.trySendRequest(Request.CreateCustomList(customListName)) - } returns true - every { mockMessageHandler.events<Event.CreateCustomListResultEvent>() } returns - flowOf(Event.CreateCustomListResultEvent(expectedResult)) + val customListId = CustomListId("1") + val expectedResult = Unit.right() + val customListName = CustomListName.fromString("CUSTOM") + val mockSettings: Settings = mockk() + val mockCustomList = + CustomList( + id = customListId, + name = mockk(relaxed = true), + locations = mockk(relaxed = true) + ) + every { mockSettings.customLists } returns listOf(mockCustomList) + settingsFlow.value = mockSettings + coEvery { mockManagementService.updateCustomList(any<CustomList>()) } returns + expectedResult // Act - val result = - customListsRepository.createCustomList(CustomListName.fromString(customListName)) + val result = customListsRepository.updateCustomListName(customListId, customListName) // Assert assertEquals(expectedResult, result) } @Test - fun `update custom list name should return ok when list updated event is received`() = runTest { - // Arrange - val customListId = "1" - val expectedResult = UpdateCustomListResult.Ok - val customListName = "CUSTOM" - val mockSettings: Settings = mockk() - val mockCustomList: CustomList = mockk() - val updatedCustomList: CustomList = mockk() - settingsFlow.value = mockSettings - every { mockCustomList.id } returns customListId - every { mockCustomList.copy(customListId, customListName, any()) } returns updatedCustomList - every { - mockMessageHandler.trySendRequest(Request.UpdateCustomList(updatedCustomList)) - } returns true - every { mockMessageHandler.events<Event.UpdateCustomListResultEvent>() } returns - flowOf(Event.UpdateCustomListResultEvent(expectedResult)) - every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) - - // Act - val result = - customListsRepository.updateCustomListName( - customListId, - CustomListName.fromString(customListName) - ) - - // Assert - assertEquals(expectedResult, result) - } - - @Test fun `update custom list name should return list exists error when list exists error is received`() = runTest { // Arrange - val customListId = "1" - val expectedResult = UpdateCustomListResult.Error(CustomListsError.CustomListExists) - val customListName = "CUSTOM" + val customListId = CustomListId("1") + val customListName = CustomListName.fromString("CUSTOM") + val expectedResult = NameAlreadyExists(customListName.value).left() val mockSettings: Settings = mockk() - val mockCustomList: CustomList = mockk() - val updatedCustomList: CustomList = mockk() + val mockCustomList = + CustomList( + id = customListId, + name = CustomListName.fromString("OLD CUSTOM"), + locations = emptyList() + ) + val updatedCustomList = + CustomList(id = customListId, name = customListName, locations = emptyList()) + every { mockSettings.customLists } returns listOf(mockCustomList) settingsFlow.value = mockSettings - every { mockCustomList.id } returns customListId - every { mockCustomList.copy(customListId, customListName, any()) } returns - updatedCustomList - every { - mockMessageHandler.trySendRequest(Request.UpdateCustomList(updatedCustomList)) - } returns true - every { mockMessageHandler.events<Event.UpdateCustomListResultEvent>() } returns - flowOf(Event.UpdateCustomListResultEvent(expectedResult)) - every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) + coEvery { mockManagementService.updateCustomList(updatedCustomList) } returns + expectedResult // Act - val result = - customListsRepository.updateCustomListName( - customListId, - CustomListName.fromString(customListName) - ) + val result = customListsRepository.updateCustomListName(customListId, customListName) // Assert assertEquals(expectedResult, result) } @Test - fun `when delete custom lists is called a delete custom event should be sent`() = runTest { - // Arrange - val customListId = "1" - every { mockMessageHandler.trySendRequest(Request.DeleteCustomList(customListId)) } returns - true + fun `when delete custom lists is called Managementservice delete custom list should be called`() = + runTest { + // Arrange + val customListId = CustomListId("1") + coEvery { mockManagementService.deleteCustomList(customListId) } returns Unit.right() - // Act - customListsRepository.deleteCustomList(customListId) + // Act + customListsRepository.deleteCustomList(customListId) - // Assert - verify { mockMessageHandler.trySendRequest(Request.DeleteCustomList(customListId)) } - } + // Assert + coVerify { mockManagementService.deleteCustomList(customListId) } + } @Test - fun `update custom list locations should return ok when list exists and ok updated list event is received`() = + fun `update custom list locations should return successful when list exists and update is successful`() = runTest { // Arrange - val expectedResult = UpdateCustomListResult.Ok - val customListId = "1" - val customListName = "CUSTOM" - val locationCode = "AB" + val expectedResult = Unit.right() + val customListId = CustomListId("1") + val customListName = CustomListName.fromString("CUSTOM") + val location = GeoLocationId.Country("se") val mockSettings: Settings = mockk() - val mockRelayList: RelayList = mockk() - val mockCustomList: CustomList = mockk() - val updatedCustomList: CustomList = mockk() - val mockLocationConstraint: GeographicLocationConstraint = mockk() + val mockCustomList = + CustomList(id = customListId, name = customListName, locations = emptyList()) + val updatedCustomList = + CustomList(id = customListId, name = customListName, locations = listOf(location)) + every { mockSettings.customLists } returns listOf(mockCustomList) settingsFlow.value = mockSettings - relayListFlow.value = mockRelayList - every { mockCustomList.id } returns customListId - every { mockCustomList.name } returns customListName - every { - mockCustomList.copy( - customListId, - customListName, - arrayListOf(mockLocationConstraint) - ) - } returns updatedCustomList - every { - mockMessageHandler.trySendRequest(Request.UpdateCustomList(updatedCustomList)) - } returns true - every { mockMessageHandler.events<Event.UpdateCustomListResultEvent>() } returns - flowOf(Event.UpdateCustomListResultEvent(expectedResult)) - every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) - every { mockRelayList.getGeographicLocationConstraintByCode(locationCode) } returns - mockLocationConstraint + coEvery { mockManagementService.updateCustomList(updatedCustomList) } returns + Unit.right() // Act val result = - customListsRepository.updateCustomListLocationsFromCodes( - customListId, - listOf(locationCode) - ) + customListsRepository.updateCustomListLocations(customListId, listOf(location)) // Assert assertEquals(expectedResult, result) } @Test - fun `update custom list locations should return other error when list does not exist`() = + fun `update custom list locations should return get custom list error when list does not exist`() = runTest { // Arrange - val expectedResult = UpdateCustomListResult.Error(CustomListsError.OtherError) - val mockCustomList: CustomList = mockk() val mockSettings: Settings = mockk() - val customListId = "1" - val otherCustomListId = "2" - val locationCode = "AB" - val mockRelayList: RelayList = mockk() - val mockLocationConstraint: GeographicLocationConstraint = mockk() + val customListId = CustomListId("1") + val otherCustomListId = CustomListId("2") + val expectedResult = GetCustomListError(otherCustomListId).left() + val mockCustomList = + CustomList( + id = customListId, + name = CustomListName.fromString("name"), + locations = emptyList() + ) + val locationId = GeoLocationId.Country("se") + every { mockSettings.customLists } returns listOf(mockCustomList) settingsFlow.value = mockSettings - relayListFlow.value = mockRelayList - every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList) - every { mockCustomList.id } returns customListId - every { mockRelayList.getGeographicLocationConstraintByCode(locationCode) } returns - mockLocationConstraint // Act val result = - customListsRepository.updateCustomListLocationsFromCodes( + customListsRepository.updateCustomListLocations( otherCustomListId, - listOf(locationCode) + listOf(locationId) ) // Assert diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/RelayListFilterRepositoryTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/RelayListFilterRepositoryTest.kt new file mode 100644 index 0000000000..c8027240a2 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/RelayListFilterRepositoryTest.kt @@ -0,0 +1,174 @@ +package net.mullvad.mullvadvpn.repository + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.ProviderId +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.lib.model.SetWireguardConstraintsError +import net.mullvad.mullvadvpn.lib.model.Settings +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class RelayListFilterRepositoryTest { + private val mockManagementService: ManagementService = mockk() + + private lateinit var relayListFilterRepository: RelayListFilterRepository + + private val settingsFlow = MutableStateFlow(mockk<Settings>(relaxed = true)) + + @BeforeEach + fun setUp() { + every { mockManagementService.settings } returns settingsFlow + relayListFilterRepository = + RelayListFilterRepository( + managementService = mockManagementService, + dispatcher = UnconfinedTestDispatcher() + ) + } + + @Test + fun `when settings is updated selected ownership should update`() = runTest { + // Arrange + val mockSettings: Settings = mockk() + val selectedOwnership: Constraint<Ownership> = Constraint.Only(Ownership.MullvadOwned) + every { mockSettings.relaySettings.relayConstraints.ownership } returns selectedOwnership + + // Act, Assert + relayListFilterRepository.selectedOwnership.test { + assertEquals(Constraint.Any, awaitItem()) + settingsFlow.emit(mockSettings) + assertEquals(selectedOwnership, awaitItem()) + } + } + + @Test + fun `when settings is updated selected providers should update`() = runTest { + // Arrange + val mockSettings: Settings = mockk() + val selectedProviders: Constraint<Providers> = + Constraint.Only(Providers(setOf(ProviderId("Prove")))) + every { mockSettings.relaySettings.relayConstraints.providers } returns selectedProviders + + // Act, Assert + relayListFilterRepository.selectedProviders.test { + assertEquals(Constraint.Any, awaitItem()) + settingsFlow.emit(mockSettings) + assertEquals(selectedProviders, awaitItem()) + } + } + + @Test + fun `when successfully updating selected ownership and filter should return successful`() = + runTest { + // Arrange + val ownership = Constraint.Any + val providers = Constraint.Any + coEvery { mockManagementService.setOwnershipAndProviders(ownership, providers) } returns + Unit.right() + + // Act + val result = + relayListFilterRepository.updateSelectedOwnershipAndProviderFilter( + ownership, + providers + ) + + // Assert + coVerify { mockManagementService.setOwnershipAndProviders(ownership, providers) } + assertEquals(Unit.right(), result) + } + + @Test + fun `when failing to update selected ownership and filter should return SetWireguardConstraintsError`() = + runTest { + // Arrange + val ownership = Constraint.Any + val providers = Constraint.Any + val error = SetWireguardConstraintsError.Unknown(mockk()) + coEvery { mockManagementService.setOwnershipAndProviders(ownership, providers) } returns + error.left() + + // Act + val result = + relayListFilterRepository.updateSelectedOwnershipAndProviderFilter( + ownership, + providers + ) + + // Assert + coVerify { mockManagementService.setOwnershipAndProviders(ownership, providers) } + assertEquals(error.left(), result) + } + + @Test + fun `when successfully updating selected ownership should return successful`() = runTest { + // Arrange + val ownership = Constraint.Only(Ownership.Rented) + coEvery { mockManagementService.setOwnership(ownership) } returns Unit.right() + + // Act + val result = relayListFilterRepository.updateSelectedOwnership(ownership) + + // Assert + coVerify { mockManagementService.setOwnership(ownership) } + assertEquals(Unit.right(), result) + } + + @Test + fun `when failing to update selected ownership should return SetWireguardConstraintsError`() = + runTest { + // Arrange + val ownership = Constraint.Only(Ownership.Rented) + val error = SetWireguardConstraintsError.Unknown(mockk()) + coEvery { mockManagementService.setOwnership(ownership) } returns error.left() + + // Act + val result = relayListFilterRepository.updateSelectedOwnership(ownership) + + // Assert + coVerify { mockManagementService.setOwnership(ownership) } + assertEquals(error.left(), result) + } + + @Test + fun `when successfully updating selected providers should return successful`() = runTest { + // Arrange + val providers = Constraint.Only(Providers(setOf(ProviderId("Mopp")))) + coEvery { mockManagementService.setProviders(providers) } returns Unit.right() + + // Act + val result = relayListFilterRepository.updateSelectedProviders(providers) + + // Assert + coVerify { mockManagementService.setProviders(providers) } + assertEquals(Unit.right(), result) + } + + @Test + fun `when failing to update selected providers should return SetWireguardConstraintsError`() = + runTest { + // Arrange + val providers = Constraint.Only(Providers(setOf(ProviderId("Mopp")))) + val error = SetWireguardConstraintsError.Unknown(mockk()) + coEvery { mockManagementService.setProviders(providers) } returns error.left() + + // Act + val result = relayListFilterRepository.updateSelectedProviders(providers) + + // Assert + coVerify { mockManagementService.setProviders(providers) } + assertEquals(error.left(), result) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxyTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxyTest.kt deleted file mode 100644 index 8fd21c5533..0000000000 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ConnectionProxyTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.DeadObjectException -import android.os.Looper -import android.os.Messenger -import android.util.Log -import io.mockk.MockKAnnotations -import io.mockk.Runs -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.just -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.mockkStatic -import io.mockk.slot -import io.mockk.unmockkAll -import kotlin.reflect.KClass -import kotlin.test.assertEquals -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher -import net.mullvad.mullvadvpn.lib.ipc.Request -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class ConnectionProxyTest { - - @MockK private lateinit var mockedMainLooper: Looper - - @MockK private lateinit var connection: Messenger - - @MockK private lateinit var mockedDispatchingHandler: EventDispatcher - lateinit var connectionProxy: ConnectionProxy - - @BeforeEach - fun setup() { - mockkStatic(Looper::class) - mockkStatic(Log::class) - MockKAnnotations.init(this) - mockkObject(Request.Connect, Request.Disconnect) - every { Request.Connect.message } returns mockk() - every { Request.Disconnect.message } returns mockk() - every { Looper.getMainLooper() } returns mockedMainLooper - every { Log.e(any(), any()) } returns mockk(relaxed = true) - } - - @AfterEach - fun tearDown() { - unmockkAll() - } - - @Test - fun `initialize connection proxy should work`() { - // Arrange - val eventType = slot<KClass<Event.TunnelStateChange>>() - every { mockedDispatchingHandler.registerHandler(capture(eventType), any()) } just Runs - // Create ConnectionProxy instance and assert initial Event type - connectionProxy = ConnectionProxy(connection, mockedDispatchingHandler) - assertEquals(Event.TunnelStateChange::class, eventType.captured.java.kotlin) - } - - @Test - fun `normal connect and disconnect should not crash`() { - // Arrange - every { connection.send(any()) } just Runs - every { mockedDispatchingHandler.registerHandler(any<KClass<Event>>(), any()) } just Runs - // Act and Assert no crashes - connectionProxy = ConnectionProxy(connection, mockedDispatchingHandler) - connectionProxy.connect() - connectionProxy.disconnect() - } - - @Test - fun `connect should catch DeadObjectException`() { - // Arrange - every { connection.send(any()) } throws DeadObjectException() - every { mockedDispatchingHandler.registerHandler(any<KClass<Event>>(), any()) } just Runs - // Act and Assert no crashes - connectionProxy = ConnectionProxy(connection, mockedDispatchingHandler) - connectionProxy.connect() - } -} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSourceTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSourceTest.kt deleted file mode 100644 index 81b518199c..0000000000 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSourceTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection - -import android.os.DeadObjectException -import android.os.Looper -import android.os.Messenger -import io.mockk.MockKAnnotations -import io.mockk.Runs -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.just -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.mockkStatic -import io.mockk.unmockkAll -import kotlin.reflect.KClass -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher -import net.mullvad.mullvadvpn.lib.ipc.Request -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class ServiceConnectionDeviceDataSourceTest { - @MockK private lateinit var mockedMainLooper: Looper - - @MockK private lateinit var mockedDispatchingHandler: EventDispatcher - - @MockK private lateinit var connection: Messenger - - lateinit var serviceConnectionDeviceDataSource: ServiceConnectionDeviceDataSource - - @BeforeEach - fun setup() { - mockkStatic(Looper::class) - mockkStatic(android.util.Log::class) - MockKAnnotations.init(this) - mockkObject(Request.GetDevice, Request.RefreshDeviceState) - every { Request.GetDevice.message } returns mockk() - every { Request.RefreshDeviceState.message } returns mockk() - every { Looper.getMainLooper() } returns mockedMainLooper - every { android.util.Log.e(any(), any()) } returns mockk(relaxed = true) - } - - @AfterEach - fun tearDown() { - unmockkAll() - } - - @Test - fun `get device should work`() { - // Arrange - every { connection.send(any()) } just Runs - every { mockedDispatchingHandler.registerHandler(any<KClass<Event>>(), any()) } just Runs - // Act and Assert no crashes - serviceConnectionDeviceDataSource = - ServiceConnectionDeviceDataSource(connection, mockedDispatchingHandler) - serviceConnectionDeviceDataSource.getDevice() - } - - @Test - fun `get device should catch DeadObjectException`() { - // Arrange - every { connection.send(any()) } throws DeadObjectException() - every { mockedDispatchingHandler.registerHandler(any<KClass<Event>>(), any()) } just Runs - // Act and Assert no crashes - serviceConnectionDeviceDataSource = - ServiceConnectionDeviceDataSource(connection, mockedDispatchingHandler) - serviceConnectionDeviceDataSource.getDevice() - } -} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt index 39bfae63d8..11d574b663 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryNotificationUseCaseTest.kt @@ -10,8 +10,8 @@ import kotlin.test.assertTrue import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.repository.InAppNotification import org.joda.time.DateTime import org.junit.jupiter.api.AfterEach @@ -22,7 +22,7 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) class AccountExpiryNotificationUseCaseTest { - private val accountExpiry = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) + private val accountExpiry = MutableStateFlow<AccountData?>(null) private lateinit var accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase @BeforeEach @@ -30,7 +30,7 @@ class AccountExpiryNotificationUseCaseTest { MockKAnnotations.init(this) val accountRepository = mockk<AccountRepository>() - every { accountRepository.accountExpiryState } returns accountExpiry + every { accountRepository.accountData } returns accountExpiry accountExpiryNotificationUseCase = AccountExpiryNotificationUseCase(accountRepository) } @@ -53,11 +53,11 @@ class AccountExpiryNotificationUseCaseTest { // Arrange, Act, Assert accountExpiryNotificationUseCase.notifications().test { assertTrue { awaitItem().isEmpty() } - val closeToExpiry = AccountExpiry.Available(DateTime.now().plusDays(2)) + val closeToExpiry = AccountData(mockk(relaxed = true), DateTime.now().plusDays(2)) accountExpiry.value = closeToExpiry assertEquals( - listOf(InAppNotification.AccountExpiry(closeToExpiry.expiryDateTime)), + listOf(InAppNotification.AccountExpiry(closeToExpiry.expiryDate)), awaitItem() ) } @@ -68,7 +68,7 @@ class AccountExpiryNotificationUseCaseTest { // Arrange, Act, Assert accountExpiryNotificationUseCase.notifications().test { assertTrue { awaitItem().isEmpty() } - accountExpiry.value = AccountExpiry.Available(DateTime.now().plusDays(4)) + accountExpiry.value = AccountData(mockk(relaxed = true), DateTime.now().plusDays(4)) expectNoEvents() } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt index 4dfb95768b..bb19d42d13 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt @@ -1,78 +1,80 @@ package net.mullvad.mullvadvpn.usecase +import arrow.core.left +import arrow.core.right import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import kotlin.test.assertIs -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.communication.Created import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult -import net.mullvad.mullvadvpn.model.CreateCustomListResult -import net.mullvad.mullvadvpn.model.CustomList -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.model.CustomListsError -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.UpdateCustomListResult -import net.mullvad.mullvadvpn.relaylist.RelayItem -import net.mullvad.mullvadvpn.relaylist.getRelayItemsByCodes +import net.mullvad.mullvadvpn.compose.communication.Deleted +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged +import net.mullvad.mullvadvpn.compose.communication.Renamed +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListAlreadyExists +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.NameAlreadyExists +import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.repository.CustomListsRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.usecase.customlists.CreateWithLocationsError import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException +import net.mullvad.mullvadvpn.usecase.customlists.RenameError import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class CustomListActionUseCaseTest { private val mockCustomListsRepository: CustomListsRepository = mockk() - private val mockRelayListUseCase: RelayListUseCase = mockk() + private val mockRelayListRepository: RelayListRepository = mockk() private val customListActionUseCase = CustomListActionUseCase( customListsRepository = mockCustomListsRepository, - relayListUseCase = mockRelayListUseCase + relayListRepository = mockRelayListRepository ) + private val relayListFlow = MutableStateFlow(emptyList<RelayItem.Location.Country>()) + @BeforeEach fun setup() { mockkStatic(RELAY_LIST_EXTENSIONS) + every { mockRelayListRepository.relayList } returns relayListFlow } @Test fun `create action should return success when ok`() = runTest { // Arrange val name = CustomListName.fromString("test") - val locationCode = "AB" + val locationId = GeoLocationId.Country("se") val locationName = "Acklaba" - val createdId = "1" - val action = CustomListAction.Create(name = name, locations = listOf(locationCode)) + val createdId = CustomListId("1") + val action = CustomListAction.Create(name = name, locations = listOf(locationId)) val expectedResult = - Result.success( - CustomListResult.Created( + Created( id = createdId, name = name, - locationName = locationName, + locationNames = listOf(locationName), undo = action.not(createdId) ) - ) - val relayItem = - RelayItem.Country( - name = locationName, - code = locationCode, - expanded = false, - cities = emptyList() - ) - val mockLocations: List<RelayItem.Country> = listOf(relayItem) - coEvery { mockCustomListsRepository.createCustomList(name) } returns - CreateCustomListResult.Ok(createdId) + .right() + coEvery { mockCustomListsRepository.createCustomList(name) } returns createdId.right() coEvery { - mockCustomListsRepository.updateCustomListLocationsFromCodes( - createdId, - listOf(locationCode) + mockCustomListsRepository.updateCustomListLocations(createdId, listOf(locationId)) + } returns Unit.right() + relayListFlow.value = + listOf( + RelayItem.Location.Country( + id = locationId, + name = locationName, + expanded = false, + cities = emptyList() + ) ) - } returns UpdateCustomListResult.Ok - coEvery { mockRelayListUseCase.fullRelayList() } returns flowOf(mockLocations) - every { mockLocations.getRelayItemsByCodes(listOf(locationCode)) } returns mockLocations // Act val result = customListActionUseCase.performAction(action) @@ -85,20 +87,17 @@ class CustomListActionUseCaseTest { fun `create action should return error when name already exists`() = runTest { // Arrange val name = CustomListName.fromString("test") - val locationCode = "AB" - val action = CustomListAction.Create(name = name, locations = listOf(locationCode)) - val expectedError = CustomListsError.CustomListExists + val locationId = GeoLocationId.Country("AB") + val action = CustomListAction.Create(name = name, locations = listOf(locationId)) + val expectedError = CreateWithLocationsError.Create(CustomListAlreadyExists).left() coEvery { mockCustomListsRepository.createCustomList(name) } returns - CreateCustomListResult.Error(CustomListsError.CustomListExists) + CustomListAlreadyExists.left() // Act val result = customListActionUseCase.performAction(action) // Assert - assertIs<Result<CustomListsException>>(result) - val exception = result.exceptionOrNull() - assertIs<CustomListsException>(exception) - assertEquals(expectedError, exception.error) + assertEquals(expectedError, result) } @Test @@ -106,13 +105,12 @@ class CustomListActionUseCaseTest { // Arrange val name = CustomListName.fromString("test") val newName = CustomListName.fromString("test2") - val customListId = "1" - val action = - CustomListAction.Rename(customListId = customListId, name = name, newName = newName) - val expectedResult = Result.success(CustomListResult.Renamed(undo = action.not())) + val customListId = CustomListId("1") + val action = CustomListAction.Rename(id = customListId, name = name, newName = newName) + val expectedResult = Renamed(undo = action.not()).right() coEvery { mockCustomListsRepository.updateCustomListName(id = customListId, name = newName) - } returns UpdateCustomListResult.Ok + } returns Unit.right() // Act val result = customListActionUseCase.performAction(action) @@ -126,45 +124,38 @@ class CustomListActionUseCaseTest { // Arrange val name = CustomListName.fromString("test") val newName = CustomListName.fromString("test2") - val customListId = "1" - val action = - CustomListAction.Rename(customListId = customListId, name = name, newName = newName) - val expectedError = CustomListsError.CustomListExists + val customListId = CustomListId("1") + val action = CustomListAction.Rename(id = customListId, name = name, newName = newName) coEvery { mockCustomListsRepository.updateCustomListName(id = customListId, name = newName) - } returns UpdateCustomListResult.Error(expectedError) + } returns NameAlreadyExists(newName.value).left() + + val expectedError = RenameError(NameAlreadyExists(newName.value)).left() // Act val result = customListActionUseCase.performAction(action) // Assert - assertIs<Result<CustomListsException>>(result) - val exception = result.exceptionOrNull() - assertIs<CustomListsException>(exception) - assertEquals(expectedError, exception.error) + assertEquals(expectedError, result) } @Test fun `delete action should return successful with deleted list`() = runTest { // Arrange - val mockCustomList: CustomList = mockk() - val mockLocation: GeographicLocationConstraint.Country = mockk() - val mockLocations: ArrayList<GeographicLocationConstraint> = arrayListOf(mockLocation) + val mockLocation: GeoLocationId.Country = mockk() + val mockLocations: List<GeoLocationId> = listOf(mockLocation) val name = CustomListName.fromString("test") - val customListId = "1" - val locationCode = "AB" - val action = CustomListAction.Delete(customListId = customListId) + val customListId = CustomListId("1") + val mockCustomList = CustomList(id = customListId, name = name, locations = mockLocations) + val location = GeoLocationId.Country("AB") + val action = CustomListAction.Delete(id = customListId) val expectedResult = - Result.success( - CustomListResult.Deleted( - undo = action.not(name = name, locations = listOf(locationCode)) - ) - ) - every { mockCustomList.locations } returns mockLocations - every { mockCustomList.name } returns name.value - every { mockLocation.countryCode } returns locationCode - coEvery { mockCustomListsRepository.deleteCustomList(id = customListId) } returns true - every { mockCustomListsRepository.getCustomListById(customListId) } returns mockCustomList + Deleted(undo = action.not(name = name, locations = listOf(location))).right() + every { mockLocation.countryCode } returns location.countryCode + coEvery { mockCustomListsRepository.deleteCustomList(id = customListId) } returns + Unit.right() + coEvery { mockCustomListsRepository.getCustomListById(customListId) } returns + mockCustomList.right() // Act val result = customListActionUseCase.performAction(action) @@ -177,35 +168,20 @@ class CustomListActionUseCaseTest { fun `update locations action should return success with changed locations`() = runTest { // Arrange val name = CustomListName.fromString("test") - val oldLocationCodes = listOf("AB", "CD") - val newLocationCodes = listOf("EF", "GH") - val oldLocations: ArrayList<GeographicLocationConstraint> = - arrayListOf( - GeographicLocationConstraint.Country("AB"), - GeographicLocationConstraint.Country("CD") - ) - val customListId = "1" - val customList = CustomList(id = customListId, name = name.value, locations = oldLocations) - val action = - CustomListAction.UpdateLocations( - customListId = customListId, - locations = newLocationCodes - ) + val newLocations = listOf(GeoLocationId.Country("EF"), GeoLocationId.Country("GH")) + val oldLocations: ArrayList<GeoLocationId> = + arrayListOf(GeoLocationId.Country("AB"), GeoLocationId.Country("CD")) + val customListId = CustomListId("1") + val customList = CustomList(id = customListId, name = name, locations = oldLocations) + val action = CustomListAction.UpdateLocations(id = customListId, locations = newLocations) val expectedResult = - Result.success( - CustomListResult.LocationsChanged( - name = name, - undo = action.not(locations = oldLocationCodes) - ) - ) - coEvery { mockCustomListsRepository.getCustomListById(customListId) } returns customList + LocationsChanged(name = name, undo = action.not(locations = oldLocations)).right() + coEvery { mockCustomListsRepository.getCustomListById(customListId) } returns + customList.right() coEvery { - mockCustomListsRepository.updateCustomListLocationsFromCodes( - customListId, - newLocationCodes - ) - } returns UpdateCustomListResult.Ok + mockCustomListsRepository.updateCustomListLocations(customListId, newLocations) + } returns Unit.right() // Act val result = customListActionUseCase.performAction(action) diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt index 691bb99131..b55da83f51 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceUseNotificationCaseTest.kt @@ -10,11 +10,13 @@ import kotlin.test.assertTrue import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.AccountAndDevice -import net.mullvad.mullvadvpn.model.Device -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.repository.InAppNotification +import org.joda.time.DateTime import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -25,9 +27,14 @@ class NewDeviceUseNotificationCaseTest { private val deviceName = "Frank Zebra" private val deviceState = - MutableStateFlow<DeviceState>( + MutableStateFlow<DeviceState?>( DeviceState.LoggedIn( - accountAndDevice = AccountAndDevice("", Device("", deviceName, byteArrayOf(), "")) + AccountToken("1234123412341234"), + Device( + id = DeviceId.fromString(UUID), + name = deviceName, + creationDate = DateTime.now() + ) ) ) private lateinit var newDeviceNotificationUseCase: NewDeviceNotificationUseCase @@ -79,4 +86,8 @@ class NewDeviceUseNotificationCaseTest { assertEquals(awaitItem(), emptyList()) } } + + companion object { + private const val UUID = "12345678-1234-5678-1234-567812345678" + } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt index 326e183445..088c9a435c 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/OutOfTimeUseCaseTest.kt @@ -9,21 +9,19 @@ import kotlin.time.Duration.Companion.days import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -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.ErrorState -import net.mullvad.talpid.tunnel.ErrorStateCause +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.model.ErrorState +import net.mullvad.mullvadvpn.lib.model.ErrorStateCause +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import org.joda.time.DateTime import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -31,10 +29,10 @@ import org.junit.jupiter.api.Test class OutOfTimeUseCaseTest { private val mockAccountRepository: AccountRepository = mockk() - private val mockMessageHandler: MessageHandler = mockk() + private val mockConnectionProxy: ConnectionProxy = mockk() - private lateinit var events: Channel<Event.TunnelStateChange> - private lateinit var expiry: MutableStateFlow<AccountExpiry> + private lateinit var events: Channel<TunnelState> + private lateinit var expiry: MutableStateFlow<AccountData?> private val dispatcher = StandardTestDispatcher() private val scope = TestScope(dispatcher) @@ -44,15 +42,14 @@ class OutOfTimeUseCaseTest { @BeforeEach fun setup() { events = Channel() - expiry = MutableStateFlow(AccountExpiry.Missing) - every { mockAccountRepository.accountExpiryState } returns expiry - every { mockMessageHandler.events<Event.TunnelStateChange>() } returns - events.receiveAsFlow() + expiry = MutableStateFlow(null) + every { mockAccountRepository.accountData } returns expiry + every { mockConnectionProxy.tunnelState } returns events.consumeAsFlow() Dispatchers.setMain(dispatcher) outOfTimeUseCase = - OutOfTimeUseCase(mockAccountRepository, mockMessageHandler, scope.backgroundScope) + OutOfTimeUseCase(mockConnectionProxy, mockAccountRepository, scope.backgroundScope) } @AfterEach @@ -73,14 +70,13 @@ class OutOfTimeUseCaseTest { fun `tunnel is blocking because out of time should emit true`() = scope.runTest { // Arrange - // Act, Assert val errorStateCause = ErrorStateCause.AuthFailed("[EXPIRED_ACCOUNT]") val tunnelStateError = TunnelState.Error(ErrorState(errorStateCause, true)) - val errorChange = Event.TunnelStateChange(tunnelStateError) + // Act, Assert outOfTimeUseCase.isOutOfTime.test { assertEquals(null, awaitItem()) - events.send(errorChange) + events.send(tunnelStateError) assertEquals(true, awaitItem()) } } @@ -89,16 +85,16 @@ class OutOfTimeUseCaseTest { fun `tunnel is connected should emit false`() = scope.runTest { // Arrange - val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusDays(1)) + val expiredAccountExpiry = + AccountData(mockk(relaxed = true), DateTime.now().plusDays(1)) val tunnelStateChanges = listOf( - TunnelState.Disconnected(), - TunnelState.Connected(mockk(), null), - TunnelState.Connecting(null, null), - TunnelState.Disconnecting(mockk()), - TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, false)), - ) - .map(Event::TunnelStateChange) + TunnelState.Disconnected(), + TunnelState.Connected(mockk(), null), + TunnelState.Connecting(null, null), + TunnelState.Disconnecting(mockk()), + TunnelState.Error(ErrorState(ErrorStateCause.StartTunnelError, false)), + ) // Act, Assert outOfTimeUseCase.isOutOfTime.test { @@ -118,7 +114,8 @@ class OutOfTimeUseCaseTest { fun `account expiry that has expired should emit true`() = scope.runTest { // Arrange - val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().minusDays(1)) + val expiredAccountExpiry = + AccountData(mockk(relaxed = true), DateTime.now().minusDays(1)) // Act, Assert outOfTimeUseCase.isOutOfTime.test { assertEquals(null, awaitItem()) @@ -131,7 +128,8 @@ class OutOfTimeUseCaseTest { fun `account expiry that has not expired should emit false`() = scope.runTest { // Arrange - val notExpiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusDays(1)) + val notExpiredAccountExpiry = + AccountData(mockk(relaxed = true), DateTime.now().plusDays(1)) // Act, Assert outOfTimeUseCase.isOutOfTime.test { @@ -145,7 +143,8 @@ class OutOfTimeUseCaseTest { fun `account that expires without new expiry event should emit true`() = runTest(dispatcher) { // Arrange - val expiredAccountExpiry = AccountExpiry.Available(DateTime.now().plusSeconds(100)) + val expiredAccountExpiry = + AccountData(mockk(relaxed = true), DateTime.now().plusSeconds(100)) // Act, Assert outOfTimeUseCase.isOutOfTime.test { // Initial event @@ -167,9 +166,10 @@ class OutOfTimeUseCaseTest { @Test fun `account that is about to expire but is refilled should emit false`() = runTest { // Arrange - val initialAccountExpiry = AccountExpiry.Available(DateTime.now().plusSeconds(100)) + val initialAccountExpiry = + AccountData(mockk(relaxed = true), DateTime.now().plusSeconds(100)) val updatedExpiry = - AccountExpiry.Available(initialAccountExpiry.expiryDateTime.plusDays(30)) + AccountData(mockk(relaxed = true), initialAccountExpiry.expiryDate.plusDays(30)) // Act, Assert outOfTimeUseCase.isOutOfTime.test { @@ -196,9 +196,10 @@ class OutOfTimeUseCaseTest { @Test fun `expired account that is refilled should emit false`() = runTest { // Arrange - val initialAccountExpiry = AccountExpiry.Available(DateTime.now().plusSeconds(100)) + val initialAccountExpiry = + AccountData(mockk(relaxed = true), DateTime.now().plusSeconds(100)) val updatedExpiry = - AccountExpiry.Available(initialAccountExpiry.expiryDateTime.plusDays(30)) + AccountData(mockk(relaxed = true), initialAccountExpiry.expiryDate.plusDays(30)) // Act, Assert outOfTimeUseCase.isOutOfTime.test { // Initial event diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt index 82126099d8..a2e8db36fd 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt @@ -10,15 +10,11 @@ import kotlin.test.assertTrue import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.ErrorState +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.repository.InAppNotification -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.tunnel.ErrorState -import net.mullvad.talpid.util.EventNotifier import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -27,26 +23,19 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) class TunnelStateNotificationUseCaseTest { - private val mockServiceConnectionManager: ServiceConnectionManager = mockk() - private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() private val mockConnectionProxy: ConnectionProxy = mockk() - private val serviceConnectionState = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) private lateinit var tunnelStateNotificationUseCase: TunnelStateNotificationUseCase - private val eventNotifierTunnelUiState = EventNotifier<TunnelState>(TunnelState.Disconnected()) + private val tunnelState = MutableStateFlow<TunnelState>(TunnelState.Disconnected()) @BeforeEach fun setup() { MockKAnnotations.init(this) - every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState - - every { mockServiceConnectionManager.connectionState } returns serviceConnectionState - every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy + every { mockConnectionProxy.tunnelState } returns tunnelState tunnelStateNotificationUseCase = - TunnelStateNotificationUseCase(serviceConnectionManager = mockServiceConnectionManager) + TunnelStateNotificationUseCase(connectionProxy = mockConnectionProxy) } @AfterEach @@ -65,10 +54,8 @@ class TunnelStateNotificationUseCaseTest { tunnelStateNotificationUseCase.notifications().test { // Arrange, Act assertEquals(emptyList(), awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) val errorState: ErrorState = mockk() - eventNotifierTunnelUiState.notify(TunnelState.Error(errorState)) + tunnelState.emit(TunnelState.Error(errorState)) // Assert assertEquals(listOf(InAppNotification.TunnelStateError(errorState)), awaitItem()) @@ -81,11 +68,7 @@ class TunnelStateNotificationUseCaseTest { tunnelStateNotificationUseCase.notifications().test { // Arrange, Act assertEquals(emptyList(), awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - eventNotifierTunnelUiState.notify( - TunnelState.Disconnecting(ActionAfterDisconnect.Block) - ) + tunnelState.emit(TunnelState.Disconnecting(ActionAfterDisconnect.Block)) // Assert assertEquals(listOf(InAppNotification.TunnelStateBlocked), awaitItem()) diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt index fbc677b461..1630ed757f 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt @@ -4,7 +4,6 @@ import app.cash.turbine.test import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -13,11 +12,7 @@ import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.util.appVersionCallbackFlow +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -26,39 +21,22 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) class VersionNotificationUseCaseTest { - private val mockServiceConnectionManager: ServiceConnectionManager = mockk() - private lateinit var mockAppVersionInfoCache: AppVersionInfoCache - private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() + private val mockAppVersionInfoRepository: AppVersionInfoRepository = mockk() - private val serviceConnectionState = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) private val versionInfo = MutableStateFlow( - VersionInfo( - currentVersion = null, - upgradeVersion = null, - isOutdated = false, - isSupported = true - ) + VersionInfo(currentVersion = "", isSupported = true, suggestedUpgradeVersion = null) ) private lateinit var versionNotificationUseCase: VersionNotificationUseCase @BeforeEach fun setup() { MockKAnnotations.init(this) - mockkStatic(CACHE_EXTENSION_CLASS) - mockAppVersionInfoCache = - mockk<AppVersionInfoCache>().apply { - every { appVersionCallbackFlow() } returns versionInfo - } - - every { mockServiceConnectionManager.connectionState } returns serviceConnectionState - every { mockServiceConnectionContainer.appVersionInfoCache } returns mockAppVersionInfoCache - every { mockAppVersionInfoCache.onUpdate = any() } answers {} + every { mockAppVersionInfoRepository.versionInfo() } returns versionInfo versionNotificationUseCase = VersionNotificationUseCase( - serviceConnectionManager = mockServiceConnectionManager, + appVersionInfoRepository = mockAppVersionInfoRepository, isVersionInfoNotificationEnabled = true ) } @@ -80,9 +58,11 @@ class VersionNotificationUseCaseTest { versionNotificationUseCase.notifications().test { // Arrange, Act val upgradeVersionInfo = - VersionInfo("1.0", "1.1", isOutdated = true, isSupported = true) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + VersionInfo( + currentVersion = "1.0", + isSupported = true, + suggestedUpgradeVersion = "1.1" + ) awaitItem() versionInfo.value = upgradeVersionInfo @@ -100,9 +80,11 @@ class VersionNotificationUseCaseTest { versionNotificationUseCase.notifications().test { // Arrange, Act val upgradeVersionInfo = - VersionInfo("1.0", "", isOutdated = false, isSupported = false) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + VersionInfo( + currentVersion = "1.0", + isSupported = false, + suggestedUpgradeVersion = null + ) awaitItem() versionInfo.value = upgradeVersionInfo @@ -113,8 +95,4 @@ class VersionNotificationUseCaseTest { ) } } - - companion object { - private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt" - } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt index 61758c2d1d..362fc457f5 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt @@ -8,7 +8,6 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll -import io.mockk.verify import kotlin.test.assertEquals import kotlin.test.assertIs import kotlinx.coroutines.flow.MutableStateFlow @@ -16,20 +15,18 @@ import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult -import net.mullvad.mullvadvpn.model.AccountAndDevice -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.Device -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import org.joda.time.DateTime import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -39,43 +36,35 @@ import org.junit.jupiter.api.extension.ExtendWith class AccountViewModelTest { private val mockAccountRepository: AccountRepository = mockk(relaxUnitFun = true) - private val mockServiceConnectionManager: ServiceConnectionManager = mockk() - private val mockDeviceRepository: DeviceRepository = mockk() - private val mockAuthTokenCache: AuthTokenCache = mockk() + private val mockDeviceRepository: DeviceRepository = mockk(relaxUnitFun = true) private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true) - private val deviceState: MutableStateFlow<DeviceState> = MutableStateFlow(DeviceState.Initial) + private val deviceState: MutableStateFlow<DeviceState?> = MutableStateFlow(null) private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null) private val purchaseResult = MutableStateFlow<PurchaseResult?>(null) - private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing) + private val accountExpiryState = MutableStateFlow(null) - private val dummyAccountAndDevice: AccountAndDevice = - AccountAndDevice( + private val dummyDevice = + Device(id = DeviceId.fromString(UUID), name = "fake_name", creationDate = DateTime.now()) + private val dummyAccountToken: AccountToken = + AccountToken( DUMMY_DEVICE_NAME, - Device( - id = "fake_id", - name = "fake_name", - pubkey = byteArrayOf(), - created = "mock_date" - ) ) private lateinit var viewModel: AccountViewModel @BeforeEach fun setup() { - mockkStatic(CACHE_EXTENSION_CLASS) mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) - every { mockServiceConnectionManager.authTokenCache() } returns mockAuthTokenCache + every { mockAccountRepository.accountData } returns accountExpiryState every { mockDeviceRepository.deviceState } returns deviceState - every { mockAccountRepository.accountExpiryState } returns accountExpiryState coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability + coEvery { mockAccountRepository.getAccountData() } returns null viewModel = AccountViewModel( accountRepository = mockAccountRepository, - serviceConnectionManager = mockServiceConnectionManager, deviceRepository = mockDeviceRepository, paymentUseCase = mockPaymentUseCase, isPlayBuild = false @@ -92,7 +81,8 @@ class AccountViewModelTest { // Act, Assert viewModel.uiState.test { awaitItem() // Default state - deviceState.value = DeviceState.LoggedIn(accountAndDevice = dummyAccountAndDevice) + deviceState.value = + DeviceState.LoggedIn(accountToken = dummyAccountToken, device = dummyDevice) val result = awaitItem() assertEquals(DUMMY_DEVICE_NAME, result.accountNumber) } @@ -104,7 +94,7 @@ class AccountViewModelTest { viewModel.onLogoutClick() // Assert - verify { mockAccountRepository.logout() } + coVerify { mockAccountRepository.logout() } } @Test @@ -184,7 +174,7 @@ class AccountViewModelTest { viewModel.onClosePurchaseResultDialog(success = true) // Assert - verify { mockAccountRepository.fetchAccountExpiry() } + coVerify { mockAccountRepository.getAccountData() } } @Test @@ -221,9 +211,9 @@ class AccountViewModelTest { } companion object { - private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt" private const val PURCHASE_RESULT_EXTENSIONS_CLASS = "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" private const val DUMMY_DEVICE_NAME = "fake_name" + private const val UUID = "12345678-1234-5678-1234-567812345678" } } 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 46126f5ad8..7888f02a4d 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 @@ -6,12 +6,11 @@ import io.mockk.Runs import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just -import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.assertEquals import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.BuildVersion import net.mullvad.mullvadvpn.repository.ChangelogRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -25,10 +24,11 @@ class ChangelogViewModelTest { private lateinit var viewModel: ChangelogViewModel + private val buildVersion = BuildVersion("1.0", 10) + @BeforeEach fun setup() { MockKAnnotations.init(this) - mockkStatic(EVENT_NOTIFIER_EXTENSION_CLASS) every { mockedChangelogRepository.setVersionCodeOfMostRecentChangelogShowed(any()) } just Runs } @@ -42,8 +42,8 @@ class ChangelogViewModelTest { fun `given up to date version code uiSideEffect should not emit`() = runTest { // Arrange every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns - buildVersionCode - viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false) + buildVersion.code + viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersion, false) // If we have the most up to date version code, we should not show the changelog dialog viewModel.uiSideEffect.test { expectNoEvents() } @@ -58,13 +58,10 @@ class ChangelogViewModelTest { version every { mockedChangelogRepository.getLastVersionChanges() } returns changes - viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false) + viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersion, false) // Given a new version with a change log we should return it viewModel.uiSideEffect.test { - assertEquals( - awaitItem(), - Changelog(version = BuildConfig.VERSION_NAME, changes = changes) - ) + assertEquals(awaitItem(), Changelog(version = buildVersion.name, changes = changes)) } } @@ -74,14 +71,8 @@ class ChangelogViewModelTest { every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns -1 every { mockedChangelogRepository.getLastVersionChanges() } returns emptyList() - viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersionCode, false) + viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersion, false) // Given a new version with a change log we should not return it viewModel.uiSideEffect.test { expectNoEvents() } } - - companion object { - private const val EVENT_NOTIFIER_EXTENSION_CLASS = - "net.mullvad.talpid.util.EventNotifierExtensionsKt" - private const val buildVersionCode = 10 - } } 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 7e207a15a4..2de7724c69 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 @@ -2,12 +2,13 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import arrow.core.right import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll -import io.mockk.verify import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull @@ -15,33 +16,31 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.model.GeoIpLocation -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.relaylist.RelayItem -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.ErrorState +import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.lib.shared.VpnPermissionRepository import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController -import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache -import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.usecase.LastKnownLocationUseCase import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.usecase.RelayListUseCase -import net.mullvad.mullvadvpn.util.appVersionCallbackFlow -import net.mullvad.talpid.tunnel.ErrorState -import net.mullvad.talpid.util.EventNotifier +import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase +import net.mullvad.mullvadvpn.util.toInAddress +import net.mullvad.mullvadvpn.util.toOutAddress import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -54,23 +53,12 @@ class ConnectViewModelTest { private lateinit var viewModel: ConnectViewModel private val serviceConnectionState = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) - private val versionInfo = - MutableStateFlow( - VersionInfo( - currentVersion = null, - upgradeVersion = null, - isOutdated = false, - isSupported = true - ) - ) - private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) - private val deviceState = MutableStateFlow<DeviceState>(DeviceState.Initial) + MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Unbound) + private val accountExpiryState = MutableStateFlow<AccountData?>(null) + private val device = MutableStateFlow<DeviceState?>(null) private val notifications = MutableStateFlow<List<InAppNotification>>(emptyList()) // Service connections - private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() - private lateinit var mockAppVersionInfoCache: AppVersionInfoCache private val mockConnectionProxy: ConnectionProxy = mockk() private val mockLocation: GeoIpLocation = mockk(relaxed = true) @@ -83,66 +71,62 @@ class ConnectViewModelTest { // In App Notifications private val mockInAppNotificationController: InAppNotificationController = mockk() - // Relay list use case - private val mockRelayListUseCase: RelayListUseCase = mockk() + // Select location use case + private val mockSelectedLocationTitleUseCase: SelectedLocationTitleUseCase = mockk() // Payment use case private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true) - // Event notifiers - private val eventNotifierTunnelUiState = EventNotifier<TunnelState>(TunnelState.Disconnected()) - private val eventNotifierTunnelRealState = - EventNotifier<TunnelState>(TunnelState.Disconnected()) - // Flows - private val selectedRelayItemFlow = MutableStateFlow<RelayItem?>(null) + private val tunnelState = MutableStateFlow<TunnelState>(TunnelState.Disconnected()) + private val selectedRelayItemFlow = MutableStateFlow<String?>(null) // Out Of Time Use Case private val outOfTimeUseCase: OutOfTimeUseCase = mockk() private val outOfTimeViewFlow = MutableStateFlow(false) + // Last known location + private val mockLastKnownLocationUseCase: LastKnownLocationUseCase = mockk() + + // VpnPermissionRepository + private val mockVpnPermissionRepository: VpnPermissionRepository = mockk(relaxed = true) + @BeforeEach fun setup() { - mockkStatic(CACHE_EXTENSION_CLASS) - mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) - - mockAppVersionInfoCache = - mockk<AppVersionInfoCache>().apply { - every { appVersionCallbackFlow() } returns versionInfo - } + mockkStatic(TUNNEL_ENDPOINT_EXTENSIONS) + mockkStatic(GEO_IP_LOCATIONS_EXTENSIONS) every { mockServiceConnectionManager.connectionState } returns serviceConnectionState - every { mockServiceConnectionContainer.appVersionInfoCache } returns mockAppVersionInfoCache - every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy - every { mockAccountRepository.accountExpiryState } returns accountExpiryState + every { mockAccountRepository.accountData } returns accountExpiryState - every { mockDeviceRepository.deviceState } returns deviceState + every { mockDeviceRepository.deviceState } returns device every { mockInAppNotificationController.notifications } returns notifications - every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState - every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState + every { mockConnectionProxy.tunnelState } returns tunnelState - every { mockLocation.country } returns "dummy country" + every { mockLastKnownLocationUseCase.lastKnownDisconnectedLocation } returns flowOf(null) - // Listeners - every { mockAppVersionInfoCache.onUpdate = any() } answers {} + every { mockLocation.country } returns "dummy country" // Flows - every { mockRelayListUseCase.selectedRelayItem() } returns selectedRelayItemFlow + every { mockSelectedLocationTitleUseCase.selectedLocationTitle() } returns + selectedRelayItemFlow every { outOfTimeUseCase.isOutOfTime } returns outOfTimeViewFlow viewModel = ConnectViewModel( - serviceConnectionManager = mockServiceConnectionManager, accountRepository = mockAccountRepository, deviceRepository = mockDeviceRepository, inAppNotificationController = mockInAppNotificationController, - relayListUseCase = mockRelayListUseCase, newDeviceNotificationUseCase = mockk(), outOfTimeUseCase = outOfTimeUseCase, paymentUseCase = mockPaymentUseCase, + selectedLocationTitleUseCase = mockSelectedLocationTitleUseCase, + connectionProxy = mockConnectionProxy, + lastKnownLocationUseCase = mockLastKnownLocationUseCase, + vpnPermissionRepository = mockVpnPermissionRepository, isPlayBuild = false ) } @@ -164,46 +148,41 @@ class ConnectViewModelTest { viewModel.uiState.test { assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - eventNotifierTunnelRealState.notify(tunnelRealStateTestItem) + tunnelState.emit(tunnelRealStateTestItem) val result = awaitItem() - assertEquals(tunnelRealStateTestItem, result.tunnelRealState) + assertEquals(tunnelRealStateTestItem, result.tunnelState) } } @Test - fun `given change in tunnelUiState uiState should emit new tunnelUiState`() = runTest { - val tunnelUiStateTestItem = TunnelState.Connected(mockk(), mockk()) + fun `given change in tunnelState uiState should emit new tunnelState`() = runTest { + // Arrange + val tunnelEndpoint: TunnelEndpoint = mockk() + val location: GeoIpLocation = mockk() + val tunnelStateTestItem = TunnelState.Connected(tunnelEndpoint, location) + every { tunnelEndpoint.toInAddress() } returns mockk(relaxed = true) + every { location.toOutAddress() } returns "1.1.1.1" + every { location.hostname } returns "hostname" + // Act, Assert viewModel.uiState.test { assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - eventNotifierTunnelUiState.notify(tunnelUiStateTestItem) + tunnelState.emit(tunnelStateTestItem) val result = awaitItem() - assertEquals(tunnelUiStateTestItem, result.tunnelUiState) + assertEquals(tunnelStateTestItem, result.tunnelState) } } @Test fun `given RelayListUseCase returns new selectedRelayItem uiState should emit new selectedRelayItem`() = runTest { - val selectedRelayItem = - RelayItem.Country( - name = "Name", - code = "Code", - expanded = false, - cities = emptyList() - ) - selectedRelayItemFlow.value = selectedRelayItem + val selectedRelayItemTitle = "Item" + selectedRelayItemFlow.value = selectedRelayItemTitle viewModel.uiState.test { assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) val result = awaitItem() - assertEquals(selectedRelayItem, result.selectedRelayItem) + assertEquals(selectedRelayItemTitle, result.selectedRelayItemTitle) } } @@ -223,15 +202,13 @@ class ConnectViewModelTest { // Act, Assert viewModel.uiState.test { assertEquals(ConnectUiState.INITIAL, awaitItem()) - eventNotifierTunnelRealState.notify(TunnelState.Disconnected(null)) + tunnelState.emit(TunnelState.Disconnected(null)) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Start of with no location assertNull(awaitItem().location) // After updated we show latest - eventNotifierTunnelRealState.notify(TunnelState.Disconnected(locationTestItem)) + tunnelState.emit(TunnelState.Disconnected(locationTestItem)) assertEquals(locationTestItem, awaitItem().location) } } @@ -245,8 +222,6 @@ class ConnectViewModelTest { // Act, Assert viewModel.uiState.test { assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) expectNoEvents() val result = awaitItem() assertEquals(locationTestItem, result.location) @@ -255,34 +230,50 @@ class ConnectViewModelTest { @Test fun `onDisconnectClick should invoke disconnect on ConnectionProxy`() = runTest { - val mockConnectionProxy: ConnectionProxy = mockk(relaxed = true) - every { mockServiceConnectionManager.connectionProxy() } returns mockConnectionProxy + // Arrange + coEvery { mockConnectionProxy.disconnect() } returns true + + // Act viewModel.onDisconnectClick() - verify { mockConnectionProxy.disconnect() } + + // Assert + coVerify { mockConnectionProxy.disconnect() } } @Test fun `onReconnectClick should invoke reconnect on ConnectionProxy`() = runTest { - val mockConnectionProxy: ConnectionProxy = mockk(relaxed = true) - every { mockServiceConnectionManager.connectionProxy() } returns mockConnectionProxy + // Arrange + coEvery { mockConnectionProxy.reconnect() } returns true + + // Act viewModel.onReconnectClick() - verify { mockConnectionProxy.reconnect() } + + // Assert + coVerify { mockConnectionProxy.reconnect() } } @Test fun `onConnectClick should invoke connect on ConnectionProxy`() = runTest { - val mockConnectionProxy: ConnectionProxy = mockk(relaxed = true) - every { mockServiceConnectionManager.connectionProxy() } returns mockConnectionProxy + // Arrange + coEvery { mockConnectionProxy.connect() } returns true.right() + + // Act viewModel.onConnectClick() - verify { mockConnectionProxy.connect() } + + // Asser + coVerify { mockConnectionProxy.connect() } } @Test fun `onCancelClick should invoke disconnect on ConnectionProxy`() = runTest { - val mockConnectionProxy: ConnectionProxy = mockk(relaxed = true) - every { mockServiceConnectionManager.connectionProxy() } returns mockConnectionProxy + // Arrange + coEvery { mockConnectionProxy.disconnect() } returns true + + // Act viewModel.onCancelClick() - verify { mockConnectionProxy.disconnect() } + + // Assert + coVerify { mockConnectionProxy.disconnect() } } @Test @@ -292,15 +283,13 @@ class ConnectViewModelTest { val mockErrorState: ErrorState = mockk() val expectedConnectNotificationState = InAppNotification.TunnelStateError(mockErrorState) - val tunnelUiState = TunnelState.Error(mockErrorState) + val tunnelStateError = TunnelState.Error(mockErrorState) notifications.value = listOf(expectedConnectNotificationState) // Act, Assert viewModel.uiState.test { assertEquals(ConnectUiState.INITIAL, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) - eventNotifierTunnelUiState.notify(tunnelUiState) + tunnelState.emit(tunnelStateError) val result = awaitItem() assertEquals(expectedConnectNotificationState, result.inAppNotification) } @@ -310,10 +299,8 @@ class ConnectViewModelTest { fun `onShowAccountClick call should result in uiSideEffect emitting OpenAccountManagementPageInBrowser`() = runTest { // Arrange - val mockToken = "4444 5555 6666 7777" - val mockAuthTokenCache: AuthTokenCache = mockk(relaxed = true) - every { mockServiceConnectionManager.authTokenCache() } returns mockAuthTokenCache - coEvery { mockAuthTokenCache.fetchAuthToken() } returns mockToken + val mockToken = WebsiteAuthToken.fromString("154c4cc94810fddac78398662b7fa0c7") + coEvery { mockAccountRepository.getWebsiteAuthToken() } returns mockToken // Act, Assert viewModel.uiSideEffect.test { @@ -332,8 +319,6 @@ class ConnectViewModelTest { // Act viewModel.uiState.test { awaitItem() - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) outOfTimeViewFlow.value = true awaitItem() } @@ -343,8 +328,9 @@ class ConnectViewModelTest { } companion object { - private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt" - private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = - "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt" + private const val TUNNEL_ENDPOINT_EXTENSIONS = + "net.mullvad.mullvadvpn.util.TunnelEndpointExtensionsKt" + private const val GEO_IP_LOCATIONS_EXTENSIONS = + "net.mullvad.mullvadvpn.util.GeoIpLocationExtensionsKt" } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt index 7b14db3ffb..83675794f5 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt @@ -1,17 +1,22 @@ package net.mullvad.mullvadvpn.viewmodel import app.cash.turbine.test +import arrow.core.left +import arrow.core.right import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlin.test.assertIs import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.communication.Created import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.lib.model.CustomListAlreadyExists +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.usecase.customlists.CreateWithLocationsError import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -25,13 +30,13 @@ class CreateCustomListDialogViewModelTest { fun `when successfully creating a list with locations should emit return with result side effect`() = runTest { // Arrange - val expectedResult: CustomListResult.Created = mockk() + val expectedResult: Created = mockk() val customListName = "list" - val viewModel = createViewModelWithLocationCode("AB") + val viewModel = createViewModelWithLocationCode(GeoLocationId.Country("AB")) coEvery { mockCustomListActionUseCase.performAction(any<CustomListAction.Create>()) - } returns Result.success(expectedResult) - every { expectedResult.locationName } returns "locationName" + } returns expectedResult.right() + every { expectedResult.locationNames } returns listOf("locationName") // Act, Assert viewModel.uiSideEffect.test { @@ -46,19 +51,23 @@ class CreateCustomListDialogViewModelTest { fun `when successfully creating a list without locations should emit with navigate to location screen`() = runTest { // Arrange - val expectedResult: CustomListResult.Created = mockk() - val customListName = "list" - val createdId = "1" - val viewModel = createViewModelWithLocationCode("") + val customListName = CustomListName.fromString("list") + val createdId = CustomListId("1") + val expectedResult = + Created( + id = createdId, + name = customListName, + locationNames = emptyList(), + undo = CustomListAction.Delete(createdId) + ) + val viewModel = createViewModelWithLocationCode(GeoLocationId.Country("AB")) coEvery { mockCustomListActionUseCase.performAction(any<CustomListAction.Create>()) - } returns Result.success(expectedResult) - every { expectedResult.locationName } returns null - every { expectedResult.id } returns createdId + } returns expectedResult.right() // Act, Assert viewModel.uiSideEffect.test { - viewModel.createCustomList(customListName) + viewModel.createCustomList(customListName.value) val sideEffect = awaitItem() assertIs<CreateCustomListDialogSideEffect.NavigateToCustomListLocationsScreen>( sideEffect @@ -70,12 +79,12 @@ class CreateCustomListDialogViewModelTest { @Test fun `when failing to creating a list should update ui state with error`() = runTest { // Arrange - val expectedError = CustomListsError.CustomListExists + val expectedError = CreateWithLocationsError.Create(CustomListAlreadyExists) val customListName = "list" - val viewModel = createViewModelWithLocationCode("") + val viewModel = createViewModelWithLocationCode(GeoLocationId.Country("AB")) coEvery { mockCustomListActionUseCase.performAction(any<CustomListAction.Create>()) - } returns Result.failure(CustomListsException(expectedError)) + } returns expectedError.left() // Act, Assert viewModel.uiState.test { @@ -89,12 +98,12 @@ class CreateCustomListDialogViewModelTest { fun `given error state when calling clear error then should update to state without error`() = runTest { // Arrange - val expectedError = CustomListsError.CustomListExists + val expectedError = CreateWithLocationsError.Create(CustomListAlreadyExists) val customListName = "list" - val viewModel = createViewModelWithLocationCode("") + val viewModel = createViewModelWithLocationCode(GeoLocationId.Country("AB")) coEvery { mockCustomListActionUseCase.performAction(any<CustomListAction.Create>()) - } returns Result.failure(CustomListsException(expectedError)) + } returns expectedError.left() // Act, Assert viewModel.uiState.test { @@ -106,7 +115,7 @@ class CreateCustomListDialogViewModelTest { } } - private fun createViewModelWithLocationCode(locationCode: String) = + private fun createViewModelWithLocationCode(locationCode: GeoLocationId) = CreateCustomListDialogViewModel( locationCode = locationCode, customListActionUseCase = mockCustomListActionUseCase diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt index d21789d36f..321e2d53b5 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.viewmodel import app.cash.turbine.test +import arrow.core.right import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -8,15 +9,21 @@ import kotlin.test.assertIs import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.ProviderId +import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.relaylist.descendants -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListRelayItemsUseCase import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -24,23 +31,31 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) class CustomListLocationsViewModelTest { - private val mockRelayListUseCase: RelayListUseCase = mockk() + private val mockRelayListRepository: RelayListRepository = mockk() private val mockCustomListUseCase: CustomListActionUseCase = mockk() + private val mockCustomListRelayItemsUseCase: CustomListRelayItemsUseCase = mockk() - private val relayListFlow = MutableStateFlow<List<RelayItem.Country>>(emptyList()) - private val customListFlow = MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) + private val relayListFlow = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) + private val selectedLocationsFlow = MutableStateFlow<List<RelayItem.Location>>(emptyList()) @BeforeEach fun setup() { - every { mockRelayListUseCase.fullRelayList() } returns relayListFlow - every { mockRelayListUseCase.customLists() } returns customListFlow + every { mockRelayListRepository.relayList } returns relayListFlow + every { mockCustomListRelayItemsUseCase.getRelayItemLocationsForCustomList(any()) } returns + selectedLocationsFlow } @Test - fun `given new list false state should return new list false`() = runTest { + fun `given new list false state uiState newList should be false`() = runTest { // Arrange val newList = false - val viewModel = createViewModel("id", newList) + val customList = + CustomList( + id = CustomListId("id"), + name = CustomListName.fromString("name"), + locations = emptyList() + ) + val viewModel = createViewModel(customListId = customList.id, newList = newList) // Act, Assert viewModel.uiState.test { assertEquals(newList, awaitItem().newList) } @@ -51,14 +66,7 @@ class CustomListLocationsViewModelTest { runTest { // Arrange val expectedList = DUMMY_COUNTRIES - val customListId = "id" - val customListName = "name" - val customList: RelayItem.CustomList = mockk { - every { id } returns customListId - every { name } returns customListName - every { locations } returns emptyList() - } - customListFlow.value = listOf(customList) + val customListId = CustomListId("id") val expectedState = CustomListLocationsUiState.Content.Data( newList = true, @@ -75,14 +83,7 @@ class CustomListLocationsViewModelTest { fun `when selecting parent should select children`() = runTest { // Arrange val expectedList = DUMMY_COUNTRIES - val customListId = "id" - val customListName = "name" - val customList: RelayItem.CustomList = mockk { - every { id } returns customListId - every { name } returns customListName - every { locations } returns emptyList() - } - customListFlow.value = listOf(customList) + val customListId = CustomListId("id") val expectedSelection = (DUMMY_COUNTRIES + DUMMY_COUNTRIES.flatMap { it.descendants() }).toSet() val viewModel = createViewModel(customListId, true) @@ -108,17 +109,11 @@ class CustomListLocationsViewModelTest { val expectedList = DUMMY_COUNTRIES val initialSelection = (DUMMY_COUNTRIES + DUMMY_COUNTRIES.flatMap { it.descendants() }).toSet() - val customListId = "id" - val customListName = "name" - val customList: RelayItem.CustomList = mockk { - every { id } returns customListId - every { name } returns customListName - every { locations } returns initialSelection.toList() - } - customListFlow.value = listOf(customList) + val customListId = CustomListId("id") val expectedSelection = emptySet<RelayItem>() - val viewModel = createViewModel(customListId, true) relayListFlow.value = expectedList + selectedLocationsFlow.value = initialSelection.toList() + val viewModel = createViewModel(customListId, true) // Act, Assert viewModel.uiState.test { @@ -140,17 +135,11 @@ class CustomListLocationsViewModelTest { val expectedList = DUMMY_COUNTRIES val initialSelection = (DUMMY_COUNTRIES + DUMMY_COUNTRIES.flatMap { it.descendants() }).toSet() - val customListId = "id" - val customListName = "name" - val customList: RelayItem.CustomList = mockk { - every { id } returns customListId - every { name } returns customListName - every { locations } returns initialSelection.toList() - } - customListFlow.value = listOf(customList) + val customListId = CustomListId("id") val expectedSelection = emptySet<RelayItem>() - val viewModel = createViewModel(customListId, true) relayListFlow.value = expectedList + selectedLocationsFlow.value = initialSelection.toList() + val viewModel = createViewModel(customListId, true) // Act, Assert viewModel.uiState.test { @@ -170,14 +159,7 @@ class CustomListLocationsViewModelTest { fun `when selecting child should not select parent`() = runTest { // Arrange val expectedList = DUMMY_COUNTRIES - val customListId = "id" - val customListName = "name" - val customList: RelayItem.CustomList = mockk { - every { id } returns customListId - every { name } returns customListName - every { locations } returns emptyList() - } - customListFlow.value = listOf(customList) + val customListId = CustomListId("id") val expectedSelection = DUMMY_COUNTRIES[0].cities[0].relays.toSet() val viewModel = createViewModel(customListId, true) relayListFlow.value = expectedList @@ -200,19 +182,12 @@ class CustomListLocationsViewModelTest { fun `given new list true when saving successfully should emit close screen side effect`() = runTest { // Arrange - val customListId = "1" - val customListName = "name" + val customListId = CustomListId("1") val newList = true - val expectedResult: CustomListResult.LocationsChanged = mockk() - val customList: RelayItem.CustomList = mockk { - every { id } returns customListId - every { name } returns customListName - every { locations } returns DUMMY_COUNTRIES - } - customListFlow.value = listOf(customList) + val expectedResult: LocationsChanged = mockk() coEvery { mockCustomListUseCase.performAction(any<CustomListAction.UpdateLocations>()) - } returns Result.success(expectedResult) + } returns expectedResult.right() val viewModel = createViewModel(customListId, newList) // Act, Assert @@ -227,19 +202,12 @@ class CustomListLocationsViewModelTest { fun `given new list false when saving successfully should emit return with result side effect`() = runTest { // Arrange - val customListId = "1" - val customListName = "name" + val customListId = CustomListId("1") val newList = false - val expectedResult: CustomListResult.LocationsChanged = mockk() - val customList: RelayItem.CustomList = mockk { - every { id } returns customListId - every { name } returns customListName - every { locations } returns DUMMY_COUNTRIES - } - customListFlow.value = listOf(customList) + val expectedResult: LocationsChanged = mockk() coEvery { mockCustomListUseCase.performAction(any<CustomListAction.UpdateLocations>()) - } returns Result.success(expectedResult) + } returns expectedResult.right() val viewModel = createViewModel(customListId, newList) // Act, Assert @@ -251,42 +219,49 @@ class CustomListLocationsViewModelTest { } } - private fun createViewModel(customListId: String, newList: Boolean) = - CustomListLocationsViewModel( + private fun createViewModel( + customListId: CustomListId, + newList: Boolean + ): CustomListLocationsViewModel { + return CustomListLocationsViewModel( customListId = customListId, newList = newList, - relayListUseCase = mockRelayListUseCase, + relayListRepository = mockRelayListRepository, + customListRelayItemsUseCase = mockCustomListRelayItemsUseCase, customListActionUseCase = mockCustomListUseCase ) + } companion object { private val DUMMY_COUNTRIES = listOf( - RelayItem.Country( + RelayItem.Location.Country( name = "Sweden", - code = "SE", + id = GeoLocationId.Country("SE"), expanded = false, cities = listOf( - RelayItem.City( + RelayItem.Location.City( name = "Gothenburg", - code = "GBG", expanded = false, - location = GeographicLocationConstraint.City("SE", "GBG"), + id = GeoLocationId.City(GeoLocationId.Country("SE"), "GBG"), relays = listOf( - RelayItem.Relay( - name = "gbg-1", - locationName = "GBG gbg-1", - active = true, - location = - GeographicLocationConstraint.Hostname( - "SE", - "GBG", + RelayItem.Location.Relay( + id = + GeoLocationId.Hostname( + GeoLocationId.City( + GeoLocationId.Country("SE"), + "GBG" + ), "gbg-1" ), - providerName = "Provider", - ownership = Ownership.MullvadOwned + active = true, + provider = + Provider( + ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) ) ) ) diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModelTest.kt index 612ae38a3a..ed615fe0af 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModelTest.kt @@ -4,13 +4,13 @@ import app.cash.turbine.test import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction import net.mullvad.mullvadvpn.compose.state.CustomListsUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.relaylist.RelayItem -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -18,15 +18,15 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) class CustomListsViewModelTest { - private val mockRelayListUseCase: RelayListUseCase = mockk(relaxed = true) + private val mockCustomListsRepository: CustomListsRepository = mockk(relaxed = true) private val mockCustomListsActionUseCase: CustomListActionUseCase = mockk(relaxed = true) @Test fun `given custom list from relay list use case should be in state`() = runTest { // Arrange - val customLists: List<RelayItem.CustomList> = mockk() + val customLists: List<CustomList> = mockk() val expectedState = CustomListsUiState.Content(customLists) - every { mockRelayListUseCase.customLists() } returns flowOf(customLists) + every { mockCustomListsRepository.customLists } returns MutableStateFlow(customLists) val viewModel = createViewModel() // Act, Assert @@ -48,7 +48,7 @@ class CustomListsViewModelTest { private fun createViewModel() = CustomListsViewModel( - relayListUseCase = mockRelayListUseCase, + customListsRepository = mockCustomListsRepository, customListActionUseCase = mockCustomListsActionUseCase ) } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt index 9f7f3f1f0b..6356719c42 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt @@ -1,13 +1,15 @@ package net.mullvad.mullvadvpn.viewmodel import app.cash.turbine.test +import arrow.core.right import io.mockk.coEvery import io.mockk.mockk import kotlin.test.assertIs import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.Deleted import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -20,11 +22,11 @@ class DeleteCustomListConfirmationViewModelTest { @Test fun `when successfully deleting a list should emit return with result side effect`() = runTest { // Arrange - val expectedResult: CustomListResult.Deleted = mockk() + val expectedResult: Deleted = mockk() val viewModel = createViewModel() coEvery { mockCustomListActionUseCase.performAction(any<CustomListAction.Delete>()) - } returns Result.success(expectedResult) + } returns expectedResult.right() // Act, Assert viewModel.uiSideEffect.test { @@ -37,7 +39,7 @@ class DeleteCustomListConfirmationViewModelTest { private fun createViewModel() = DeleteCustomListConfirmationViewModel( - customListId = "1", + customListId = CustomListId("1"), customListActionUseCase = mockCustomListActionUseCase ) } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt index 11244e9df4..b63f59b302 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt @@ -3,27 +3,22 @@ package net.mullvad.mullvadvpn.viewmodel import app.cash.turbine.test import io.mockk.MockKAnnotations import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll -import io.mockk.verify -import io.mockk.verifyOrder -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.talpid.util.EventNotifier -import net.mullvad.talpid.util.callbackFlowFromSubscription +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -35,23 +30,21 @@ class DeviceRevokedViewModelTest { @MockK private lateinit var mockedAccountRepository: AccountRepository - @MockK private lateinit var mockedServiceConnectionManager: ServiceConnectionManager - - private val serviceConnectionState = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) + @MockK private lateinit var mockConnectionProxy: ConnectionProxy private lateinit var viewModel: DeviceRevokedViewModel + private val tunnelStateFlow = MutableSharedFlow<TunnelState>() + @BeforeEach fun setup() { MockKAnnotations.init(this) - mockkStatic(EVENT_NOTIFIER_EXTENSION_CLASS) - every { mockedServiceConnectionManager.connectionState } returns serviceConnectionState + every { mockConnectionProxy.tunnelState } returns tunnelStateFlow viewModel = DeviceRevokedViewModel( - mockedServiceConnectionManager, - mockedAccountRepository, - UnconfinedTestDispatcher() + accountRepository = mockedAccountRepository, + connectionProxy = mockConnectionProxy, + dispatcher = UnconfinedTestDispatcher() ) } @@ -61,44 +54,15 @@ class DeviceRevokedViewModelTest { } @Test - fun `when service connection is Disconnected then uiState should be UNKNOWN`() = runTest { - // Arrange, Act, Assert - viewModel.uiState.test { - serviceConnectionState.value = ServiceConnectionState.Disconnected - assertEquals(DeviceRevokedUiState.UNKNOWN, awaitItem()) - } - } - - @Test - fun `when service connection is ConnectedNotReady then uiState should be UNKNOWN`() = runTest { - // Arrange, Act, Assert - viewModel.uiState.test { - serviceConnectionState.value = ServiceConnectionState.ConnectedNotReady(mockk()) - assertEquals(DeviceRevokedUiState.UNKNOWN, awaitItem()) - } - } - - @Test - fun `when service connection is ConnectedReady uiState should be SECURED`() = runTest { + fun `when tunnel state is secured uiState should be SECURED`() = runTest { // Arrange - val mockedContainer = - mockk<ServiceConnectionContainer>().apply { - val eventNotifierMock = - mockk<EventNotifier<TunnelState>>().apply { - every { callbackFlowFromSubscription(any()) } returns - MutableStateFlow(TunnelState.Connected(mockk(), mockk())) - } - val mockedConnectionProxy = - mockk<ConnectionProxy>().apply { - every { onUiStateChange } returns eventNotifierMock - } - every { connectionProxy } returns mockedConnectionProxy - } + val tunnelState: TunnelState = mockk() + every { tunnelState.isSecured() } returns true // Act, Assert viewModel.uiState.test { assertEquals(DeviceRevokedUiState.UNKNOWN, awaitItem()) - serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockedContainer) + tunnelStateFlow.emit(tunnelState) assertEquals(DeviceRevokedUiState.SECURED, awaitItem()) } } @@ -106,44 +70,29 @@ class DeviceRevokedViewModelTest { @Test fun `onGoToLoginClicked should invoke logout on AccountRepository`() { // Arrange - val mockedContainer = - mockk<ServiceConnectionContainer>().also { - every { it.connectionProxy.state } returns TunnelState.Disconnected() - every { it.connectionProxy.disconnect() } just Runs - every { mockedAccountRepository.logout() } just Runs - } - serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockedContainer) + coEvery { mockConnectionProxy.disconnect() } returns true + coEvery { mockedAccountRepository.logout() } just Runs // Act viewModel.onGoToLoginClicked() // Assert - verify { mockedAccountRepository.logout() } + coVerify { mockedAccountRepository.logout() } } @Test fun `onGoToLoginClicked should invoke disconnect before logout when connected`() { // Arrange - val mockedContainer = - mockk<ServiceConnectionContainer>().also { - every { it.connectionProxy.state } returns TunnelState.Connected(mockk(), mockk()) - every { it.connectionProxy.disconnect() } just Runs - every { mockedAccountRepository.logout() } just Runs - } - serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockedContainer) + coEvery { mockConnectionProxy.disconnect() } returns true + coEvery { mockedAccountRepository.logout() } just Runs // Act viewModel.onGoToLoginClicked() // Assert - verifyOrder { - mockedContainer.connectionProxy.disconnect() + coVerifyOrder { + mockConnectionProxy.disconnect() mockedAccountRepository.logout() } } - - companion object { - private const val EVENT_NOTIFIER_EXTENSION_CLASS = - "net.mullvad.talpid.util.EventNotifierExtensionsKt" - } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt index e9592d0336..29afc8de0d 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt @@ -1,16 +1,20 @@ package net.mullvad.mullvadvpn.viewmodel import app.cash.turbine.test +import arrow.core.left +import arrow.core.right import io.mockk.coEvery import io.mockk.mockk import kotlin.test.assertIs import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.Renamed import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.CustomListsError +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.NameAlreadyExists import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase -import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException +import net.mullvad.mullvadvpn.usecase.customlists.RenameError import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -23,13 +27,13 @@ class EditCustomListNameDialogViewModelTest { @Test fun `when successfully renamed list should emit return with result side effect`() = runTest { // Arrange - val expectedResult: CustomListResult.Renamed = mockk() - val customListId = "id" + val expectedResult: Renamed = mockk() + val customListId = CustomListId("id") val customListName = "list" val viewModel = createViewModel(customListId, customListName) coEvery { mockCustomListActionUseCase.performAction(any<CustomListAction.Rename>()) - } returns Result.success(expectedResult) + } returns expectedResult.right() // Act, Assert viewModel.uiSideEffect.test { @@ -41,15 +45,15 @@ class EditCustomListNameDialogViewModelTest { } @Test - fun `when failing to creating a list should update ui state with error`() = runTest { + fun `when failing to rename a list should update ui state with error`() = runTest { // Arrange - val expectedError = CustomListsError.CustomListExists - val customListId = "id2" + val customListId = CustomListId("id2") val customListName = "list2" + val expectedError = RenameError(NameAlreadyExists(customListName)) val viewModel = createViewModel(customListId, customListName) coEvery { mockCustomListActionUseCase.performAction(any<CustomListAction.Rename>()) - } returns Result.failure(CustomListsException(expectedError)) + } returns expectedError.left() // Act, Assert viewModel.uiState.test { @@ -63,13 +67,13 @@ class EditCustomListNameDialogViewModelTest { fun `given error state when calling clear error then should update to state without error`() = runTest { // Arrange - val expectedError = CustomListsError.CustomListExists - val customListId = "id" + val customListId = CustomListId("id") val customListName = "list" + val expectedError = RenameError(NameAlreadyExists(customListName)) val viewModel = createViewModel(customListId, customListName) coEvery { mockCustomListActionUseCase.performAction(any<CustomListAction.Rename>()) - } returns Result.failure(CustomListsException(expectedError)) + } returns expectedError.left() // Act, Assert viewModel.uiState.test { @@ -81,10 +85,10 @@ class EditCustomListNameDialogViewModelTest { } } - private fun createViewModel(customListId: String, initialName: String) = + private fun createViewModel(customListId: CustomListId, initialName: String) = EditCustomListNameDialogViewModel( customListId = customListId, - initialName = initialName, + initialName = CustomListName.fromString(initialName), customListActionUseCase = mockCustomListActionUseCase ) } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModelTest.kt index cbc5ff1c50..c3f233846a 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModelTest.kt @@ -4,33 +4,33 @@ import app.cash.turbine.test import io.mockk.every import io.mockk.mockk import kotlin.test.assertIs -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.state.EditCustomListState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.CustomListName -import net.mullvad.mullvadvpn.relaylist.RelayItem -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.repository.CustomListsRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) class EditCustomListViewModelTest { - private val mockRelayListUseCase: RelayListUseCase = mockk(relaxed = true) + private val mockCustomListsRepository: CustomListsRepository = mockk(relaxed = true) @Test fun `given a custom list id that does not exists should return not found ui state`() = runTest { // Arrange - val customListId = "2" + val customListId = CustomListId("2") val customList = - RelayItem.CustomList( - id = "1", - customListName = CustomListName.fromString("test"), - expanded = false, + CustomList( + id = CustomListId("1"), + name = CustomListName.fromString("test"), locations = emptyList() ) - every { mockRelayListUseCase.customLists() } returns flowOf(listOf(customList)) + every { mockCustomListsRepository.customLists } returns MutableStateFlow(listOf(customList)) val viewModel = createViewModel(customListId) // Act, Assert @@ -43,15 +43,14 @@ class EditCustomListViewModelTest { @Test fun `given a custom list id that exists should return content ui state`() = runTest { // Arrange - val customListId = "1" + val customListId = CustomListId("1") val customList = - RelayItem.CustomList( + CustomList( id = customListId, - customListName = CustomListName.fromString("test"), - expanded = false, + name = CustomListName.fromString("test"), locations = emptyList() ) - every { mockRelayListUseCase.customLists() } returns flowOf(listOf(customList)) + every { mockCustomListsRepository.customLists } returns MutableStateFlow(listOf(customList)) val viewModel = createViewModel(customListId) // Act, Assert @@ -64,9 +63,9 @@ class EditCustomListViewModelTest { } } - private fun createViewModel(customListId: String) = + private fun createViewModel(customListId: CustomListId) = EditCustomListViewModel( customListId = customListId, - relayListUseCase = mockRelayListUseCase + customListsRepository = mockCustomListsRepository ) } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModelTest.kt index fda88bff79..5333a481be 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/FilterViewModelTest.kt @@ -2,6 +2,8 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import arrow.core.right +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk @@ -15,11 +17,13 @@ import net.mullvad.mullvadvpn.compose.state.toConstraintProviders import net.mullvad.mullvadvpn.compose.state.toOwnershipConstraint 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.Ownership -import net.mullvad.mullvadvpn.model.Providers -import net.mullvad.mullvadvpn.relaylist.Provider -import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.ProviderId +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -27,41 +31,52 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) class FilterViewModelTest { - private val mockRelayListFilterUseCase: RelayListFilterUseCase = mockk(relaxed = true) + private val mockAvailableProvidersUseCase: AvailableProvidersUseCase = mockk(relaxed = true) + private val mockRelayListFilterRepository: RelayListFilterRepository = mockk() private lateinit var viewModel: FilterViewModel private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Only(Ownership.MullvadOwned)) private val dummyListOfAllProviders = listOf( - Provider("31173", true), - Provider("100TB", false), - Provider("Blix", true), - Provider("Creanova", true), - Provider("DataPacket", false), - Provider("HostRoyale", false), - Provider("hostuniversal", false), - Provider("iRegister", false), - Provider("M247", false), - Provider("Makonix", false), - Provider("PrivateLayer", false), - Provider("ptisp", false), - Provider("Qnax", false), - Provider("Quadranet", false), - Provider("techfutures", false), - Provider("Tzulo", false), - Provider("xtom", false) + Provider(ProviderId("31173"), Ownership.MullvadOwned), + Provider(ProviderId("100TB"), Ownership.Rented), + Provider(ProviderId("Blix"), Ownership.MullvadOwned), + Provider(ProviderId("Creanova"), Ownership.MullvadOwned), + Provider(ProviderId("DataPacket"), Ownership.Rented), + Provider(ProviderId("HostRoyale"), Ownership.Rented), + Provider(ProviderId("hostuniversal"), Ownership.Rented), + Provider(ProviderId("iRegister"), Ownership.Rented), + Provider(ProviderId("M247"), Ownership.Rented), + Provider(ProviderId("Makonix"), Ownership.Rented), + Provider(ProviderId("PrivateLayer"), Ownership.Rented), + Provider(ProviderId("ptisp"), Ownership.Rented), + Provider(ProviderId("Qnax"), Ownership.Rented), + Provider(ProviderId("Quadranet"), Ownership.Rented), + Provider(ProviderId("techfutures"), Ownership.Rented), + Provider(ProviderId("Tzulo"), Ownership.Rented), + Provider(ProviderId("xtom"), Ownership.Rented) ) private val mockSelectedProviders: List<Provider> = - listOf(Provider("31173", true), Provider("Blix", true), Provider("Creanova", true)) + listOf( + Provider(ProviderId("31173"), Ownership.MullvadOwned), + Provider(ProviderId("Blix"), Ownership.MullvadOwned), + Provider(ProviderId("Creanova"), Ownership.MullvadOwned) + ) @BeforeEach fun setup() { - every { mockRelayListFilterUseCase.selectedOwnership() } returns selectedOwnership - every { mockRelayListFilterUseCase.availableProviders() } returns + every { mockRelayListFilterRepository.selectedOwnership } returns selectedOwnership + every { mockAvailableProvidersUseCase.availableProviders() } returns flowOf(dummyListOfAllProviders) - every { mockRelayListFilterUseCase.selectedProviders() } returns - flowOf(Constraint.Only(Providers(mockSelectedProviders.map { it.name }.toHashSet()))) - viewModel = FilterViewModel(mockRelayListFilterUseCase) + every { mockRelayListFilterRepository.selectedProviders } returns + MutableStateFlow( + Constraint.Only(Providers(mockSelectedProviders.map { it.providerId }.toSet())) + ) + viewModel = + FilterViewModel( + availableProvidersUseCase = mockAvailableProvidersUseCase, + relayListFilterRepository = mockRelayListFilterRepository + ) } @AfterEach @@ -87,7 +102,7 @@ class FilterViewModelTest { fun `setSelectionProvider should emit uiState where selectedProviders include the selected provider`() = runTest { // Arrange - val mockSelectedProvidersList = Provider("ptisp", false) + val mockSelectedProvidersList = Provider(ProviderId("ptisp"), Ownership.Rented) // Assert viewModel.uiState.test { assertLists(awaitItem().selectedProviders, mockSelectedProviders) @@ -120,11 +135,19 @@ class FilterViewModelTest { val mockOwnership = Ownership.MullvadOwned.toOwnershipConstraint() val mockSelectedProviders = mockSelectedProviders.toConstraintProviders(dummyListOfAllProviders) + coEvery { + mockRelayListFilterRepository.updateSelectedOwnershipAndProviderFilter( + mockOwnership, + mockSelectedProviders + ) + } returns Unit.right() + // Act viewModel.onApplyButtonClicked() + // Assert coVerify { - mockRelayListFilterUseCase.updateOwnershipAndProviderFilter( + mockRelayListFilterRepository.updateSelectedOwnershipAndProviderFilter( mockOwnership, mockSelectedProviders ) diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt index 3271fe57eb..d6eee6d941 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt @@ -3,11 +3,15 @@ package net.mullvad.mullvadvpn.viewmodel import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import app.cash.turbine.turbineScope +import arrow.core.left +import arrow.core.right import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.verify +import io.mockk.mockk +import kotlin.test.assertIs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -19,14 +23,10 @@ import net.mullvad.mullvadvpn.compose.state.LoginState.Loading import net.mullvad.mullvadvpn.compose.state.LoginState.Success import net.mullvad.mullvadvpn.compose.state.LoginUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.AccountCreationResult -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.AccountHistory -import net.mullvad.mullvadvpn.model.AccountToken -import net.mullvad.mullvadvpn.model.DeviceListEvent -import net.mullvad.mullvadvpn.model.LoginResult -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.LoginAccountError +import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.usecase.ConnectivityUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import org.joda.time.DateTime @@ -40,27 +40,23 @@ class LoginViewModelTest { @MockK private lateinit var connectivityUseCase: ConnectivityUseCase @MockK private lateinit var mockedAccountRepository: AccountRepository - @MockK private lateinit var mockedDeviceRepository: DeviceRepository @MockK private lateinit var mockedNewDeviceNotificationUseCase: NewDeviceNotificationUseCase private lateinit var loginViewModel: LoginViewModel - private val accountHistoryTestEvents = MutableStateFlow<AccountHistory>(AccountHistory.Missing) @BeforeEach fun setup() { - Dispatchers.setMain(UnconfinedTestDispatcher()) MockKAnnotations.init(this, relaxUnitFun = true) every { connectivityUseCase.isInternetAvailable() } returns true - every { mockedAccountRepository.accountHistory } returns accountHistoryTestEvents every { mockedNewDeviceNotificationUseCase.newDeviceCreated() } returns Unit + coEvery { mockedAccountRepository.fetchAccountHistory() } returns null loginViewModel = LoginViewModel( - mockedAccountRepository, - mockedDeviceRepository, - mockedNewDeviceNotificationUseCase, - connectivityUseCase, + accountRepository = mockedAccountRepository, + newDeviceNotificationUseCase = mockedNewDeviceNotificationUseCase, + connectivityUseCase = connectivityUseCase, UnconfinedTestDispatcher() ) } @@ -97,8 +93,7 @@ class LoginViewModelTest { // Arrange val uiStates = loginViewModel.uiState.testIn(backgroundScope) val sideEffects = loginViewModel.uiSideEffect.testIn(backgroundScope) - coEvery { mockedAccountRepository.createAccount() } returns - AccountCreationResult.Success(DUMMY_ACCOUNT_TOKEN) + coEvery { mockedAccountRepository.createAccount() } returns DUMMY_ACCOUNT_TOKEN.right() // Act, Assert uiStates.skipDefaultItem() @@ -114,13 +109,13 @@ class LoginViewModelTest { // Arrange val uiStates = loginViewModel.uiState.testIn(backgroundScope) val sideEffects = loginViewModel.uiSideEffect.testIn(backgroundScope) - coEvery { mockedAccountRepository.login(any()) } returns LoginResult.Ok - coEvery { mockedAccountRepository.accountExpiryState } returns - MutableStateFlow(AccountExpiry.Available(DateTime.now().plusDays(3))) + coEvery { mockedAccountRepository.login(any()) } returns Unit.right() + coEvery { mockedAccountRepository.accountData } returns + MutableStateFlow(AccountData(mockk(relaxed = true), DateTime.now().plusDays(3))) // Act, Assert uiStates.skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + loginViewModel.login(DUMMY_ACCOUNT_TOKEN.value) assertEquals(Loading.LoggingIn, uiStates.awaitItem().loginState) assertEquals(Success, uiStates.awaitItem().loginState) assertEquals(LoginUiSideEffect.NavigateToConnect, sideEffects.awaitItem()) @@ -131,11 +126,12 @@ class LoginViewModelTest { fun `given invalid account when logging in then show invalid credentials`() = runTest { loginViewModel.uiState.test { // Arrange - coEvery { mockedAccountRepository.login(any()) } returns LoginResult.InvalidAccount + coEvery { mockedAccountRepository.login(any()) } returns + LoginAccountError.InvalidAccount.left() // Act, Assert skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + loginViewModel.login(DUMMY_ACCOUNT_TOKEN.value) assertEquals(Loading.LoggingIn, awaitItem().loginState) assertEquals(Idle(loginError = LoginError.InvalidCredentials), awaitItem().loginState) } @@ -148,23 +144,15 @@ class LoginViewModelTest { // Arrange val uiStates = loginViewModel.uiState.testIn(backgroundScope) val sideEffects = loginViewModel.uiSideEffect.testIn(backgroundScope) - coEvery { - mockedDeviceRepository.refreshAndAwaitDeviceListWithTimeout( - any(), - any(), - any(), - any() - ) - } returns DeviceListEvent.Available(DUMMY_ACCOUNT_TOKEN, listOf()) coEvery { mockedAccountRepository.login(any()) } returns - LoginResult.MaxDevicesReached + LoginAccountError.MaxDevicesReached(DUMMY_ACCOUNT_TOKEN).left() // Act, Assert uiStates.skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + loginViewModel.login(DUMMY_ACCOUNT_TOKEN.value) assertEquals(Loading.LoggingIn, uiStates.awaitItem().loginState) assertEquals( - LoginUiSideEffect.TooManyDevices(AccountToken(DUMMY_ACCOUNT_TOKEN)), + LoginUiSideEffect.TooManyDevices(DUMMY_ACCOUNT_TOKEN), sideEffects.awaitItem() ) } @@ -174,11 +162,12 @@ class LoginViewModelTest { fun `given RpcError when logging in then show unknown error with message`() = runTest { loginViewModel.uiState.test { // Arrange - coEvery { mockedAccountRepository.login(any()) } returns LoginResult.RpcError + coEvery { mockedAccountRepository.login(any()) } returns + LoginAccountError.RpcError.left() // Act, Assert skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + loginViewModel.login(DUMMY_ACCOUNT_TOKEN.value) assertEquals(Loading.LoggingIn, awaitItem().loginState) assertEquals( Idle(LoginError.Unknown(EXPECTED_RPC_ERROR_MESSAGE)), @@ -188,31 +177,32 @@ class LoginViewModelTest { } @Test - fun `given OtherError when logging in then show unknown error with message`() = runTest { + fun `given unknown error when logging in then show unknown error with message`() = runTest { loginViewModel.uiState.test { // Arrange - coEvery { mockedAccountRepository.login(any()) } returns LoginResult.OtherError + coEvery { mockedAccountRepository.login(any()) } returns + LoginAccountError.Unknown(mockk()).left() // Act, Assert skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + loginViewModel.login(DUMMY_ACCOUNT_TOKEN.value) assertEquals(Loading.LoggingIn, awaitItem().loginState) - assertEquals( - Idle(LoginError.Unknown(EXPECTED_OTHER_ERROR_MESSAGE)), - awaitItem().loginState - ) + val loginState = awaitItem().loginState + assertIs<Idle>(loginState) + assertIs<LoginError.Unknown>(loginState.loginError) } } @Test fun `on new accountHistory emission uiState should include lastUsedAccount matching accountHistory`() = runTest { + // Arrange + coEvery { mockedAccountRepository.fetchAccountHistory() } returns DUMMY_ACCOUNT_TOKEN + + // Act, Assert loginViewModel.uiState.test { - // Act, Assert - skipDefaultItem() - accountHistoryTestEvents.emit(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN)) assertEquals( - LoginUiState.INITIAL.copy(lastUsedAccount = AccountToken(DUMMY_ACCOUNT_TOKEN)), + LoginUiState.INITIAL.copy(lastUsedAccount = DUMMY_ACCOUNT_TOKEN), awaitItem() ) } @@ -222,7 +212,7 @@ class LoginViewModelTest { fun `clearAccountHistory should invoke clearAccountHistory on AccountRepository`() = runTest { // Act, Assert loginViewModel.clearAccountHistory() - verify { mockedAccountRepository.clearAccountHistory() } + coVerify { mockedAccountRepository.clearAccountHistory() } } private suspend fun <T> ReceiveTurbine<T>.skipDefaultItem() where T : Any? { @@ -230,8 +220,7 @@ class LoginViewModelTest { } companion object { - private const val DUMMY_ACCOUNT_TOKEN = "DUMMY" + private val DUMMY_ACCOUNT_TOKEN = AccountToken("DUMMY") private const val EXPECTED_RPC_ERROR_MESSAGE = "RpcError" - private const val EXPECTED_OTHER_ERROR_MESSAGE = "OtherError" } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt index e489c01d41..bd26effe82 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt @@ -8,34 +8,28 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll -import io.mockk.verify import kotlin.test.assertEquals import kotlin.test.assertIs import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.talpid.util.EventNotifier import org.joda.time.DateTime import org.joda.time.ReadableInstant import org.junit.jupiter.api.AfterEach @@ -47,23 +41,21 @@ import org.junit.jupiter.api.extension.ExtendWith class OutOfTimeViewModelTest { private val serviceConnectionStateFlow = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) - private val accountExpiryStateFlow = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) - private val deviceStateFlow = MutableStateFlow<DeviceState>(DeviceState.Initial) + MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Unbound) + private val accountExpiryStateFlow = MutableStateFlow<AccountData?>(null) + private val accountStateFlow = MutableStateFlow<DeviceState?>(null) private val paymentAvailabilityFlow = MutableStateFlow<PaymentAvailability?>(null) private val purchaseResultFlow = MutableStateFlow<PurchaseResult?>(null) private val outOfTimeFlow = MutableStateFlow(true) - // Service connections - private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() + // Connection Proxy private val mockConnectionProxy: ConnectionProxy = mockk() // Event notifiers - private val eventNotifierTunnelRealState = - EventNotifier<TunnelState>(TunnelState.Disconnected()) + private val tunnelState = MutableStateFlow<TunnelState>(TunnelState.Disconnected()) private val mockAccountRepository: AccountRepository = mockk(relaxed = true) - private val mockDeviceRepository: DeviceRepository = mockk() + private val mockDeviceRepository: DeviceRepository = mockk(relaxed = true) private val mockServiceConnectionManager: ServiceConnectionManager = mockk() private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true) private val mockOutOfTimeUseCase: OutOfTimeUseCase = mockk(relaxed = true) @@ -72,18 +64,15 @@ class OutOfTimeViewModelTest { @BeforeEach fun setup() { - mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow - every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy + every { mockConnectionProxy.tunnelState } returns tunnelState - every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState + every { mockAccountRepository.accountData } returns accountExpiryStateFlow - every { mockAccountRepository.accountExpiryState } returns accountExpiryStateFlow - - every { mockDeviceRepository.deviceState } returns deviceStateFlow + every { mockDeviceRepository.deviceState } returns accountStateFlow coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResultFlow @@ -94,10 +83,10 @@ class OutOfTimeViewModelTest { viewModel = OutOfTimeViewModel( accountRepository = mockAccountRepository, - serviceConnectionManager = mockServiceConnectionManager, deviceRepository = mockDeviceRepository, paymentUseCase = mockPaymentUseCase, outOfTimeUseCase = mockOutOfTimeUseCase, + connectionProxy = mockConnectionProxy, pollAccountExpiry = false, isPlayBuild = false ) @@ -112,10 +101,8 @@ class OutOfTimeViewModelTest { @Test fun `when clicking on site payment then open website account view`() = runTest { // Arrange - val mockToken = "4444 5555 6666 7777" - val mockAuthTokenCache: AuthTokenCache = mockk(relaxed = true) - every { mockServiceConnectionManager.authTokenCache() } returns mockAuthTokenCache - coEvery { mockAuthTokenCache.fetchAuthToken() } returns mockToken + val mockToken = WebsiteAuthToken.fromString("154c4cc94810fddac78398662b7fa0c7") + coEvery { mockAccountRepository.getWebsiteAuthToken() } returns mockToken // Act, Assert viewModel.uiSideEffect.test { @@ -133,10 +120,9 @@ class OutOfTimeViewModelTest { // Act, Assert viewModel.uiState.test { - assertEquals(OutOfTimeUiState(deviceName = ""), awaitItem()) - eventNotifierTunnelRealState.notify(tunnelRealStateTestItem) - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + // Default item + awaitItem() + tunnelState.emit(tunnelRealStateTestItem) val result = awaitItem() assertEquals(tunnelRealStateTestItem, result.tunnelState) } @@ -160,14 +146,13 @@ class OutOfTimeViewModelTest { @Test fun `onDisconnectClick should invoke disconnect on ConnectionProxy`() = runTest { // Arrange - val mockProxy: ConnectionProxy = mockk(relaxed = true) - every { mockServiceConnectionManager.connectionProxy() } returns mockProxy + coEvery { mockConnectionProxy.disconnect() } returns true // Act viewModel.onDisconnectClick() // Assert - verify { mockProxy.disconnect() } + coVerify { mockConnectionProxy.disconnect() } } @Test @@ -176,8 +161,6 @@ class OutOfTimeViewModelTest { // Arrange val productsUnavailable = PaymentAvailability.ProductsUnavailable paymentAvailabilityFlow.value = productsUnavailable - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert viewModel.uiState.test { @@ -192,8 +175,6 @@ class OutOfTimeViewModelTest { // Arrange val paymentAvailabilityError = PaymentAvailability.Error.Other(mockk()) paymentAvailabilityFlow.value = paymentAvailabilityError - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert viewModel.uiState.test { @@ -208,8 +189,6 @@ class OutOfTimeViewModelTest { // Arrange val paymentAvailabilityError = PaymentAvailability.Error.BillingUnavailable paymentAvailabilityFlow.value = paymentAvailabilityError - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert viewModel.uiState.test { @@ -226,8 +205,6 @@ class OutOfTimeViewModelTest { val expectedProductList = listOf(mockProduct) val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct)) paymentAvailabilityFlow.value = productsAvailable - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert viewModel.uiState.test { @@ -238,14 +215,12 @@ class OutOfTimeViewModelTest { } @Test - fun `onClosePurchaseResultDialog with success should invoke fetchAccountExpiry on AccountRepository`() { - // Arrange - + fun `onClosePurchaseResultDialog with success should invoke getAccountData on AccountRepository`() { // Act viewModel.onClosePurchaseResultDialog(success = true) // Assert - verify { mockAccountRepository.fetchAccountExpiry() } + coVerify { mockAccountRepository.getAccountData() } } @Test @@ -282,8 +257,6 @@ class OutOfTimeViewModelTest { } companion object { - private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = - "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt" private const val PURCHASE_RESULT_EXTENSIONS_CLASS = "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt index 9be365e7ae..17394c39db 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ResetServerIpOverridesConfirmationViewModelTest.kt @@ -2,17 +2,17 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import arrow.core.right import io.mockk.coEvery -import io.mockk.every +import io.mockk.coVerify import io.mockk.mockk import io.mockk.unmockkAll -import io.mockk.verify import kotlin.test.assertEquals import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.RelayOverride +import net.mullvad.mullvadvpn.lib.model.RelayOverride import net.mullvad.mullvadvpn.repository.RelayOverridesRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -44,7 +44,7 @@ class ResetServerIpOverridesConfirmationViewModelTest { @Test fun `successful clear of override should result in side effect`() = runTest { - every { mockRelayOverridesRepository.clearAllOverrides() } returns Unit + coEvery { mockRelayOverridesRepository.clearAllOverrides() } returns Unit.right() viewModel.uiSideEffect.test { viewModel.clearAllOverrides() assertEquals( @@ -56,8 +56,8 @@ class ResetServerIpOverridesConfirmationViewModelTest { @Test fun `clear overrides should invoke repository`() = runTest { - every { mockRelayOverridesRepository.clearAllOverrides() } returns Unit + coEvery { mockRelayOverridesRepository.clearAllOverrides() } returns Unit.right() viewModel.clearAllOverrides() - verify { mockRelayOverridesRepository.clearAllOverrides() } + coVerify { mockRelayOverridesRepository.clearAllOverrides() } } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt index 5d0ab5f604..80f62dba4a 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt @@ -2,42 +2,40 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import arrow.core.right import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every -import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.runs import io.mockk.unmockkAll -import io.mockk.verify import kotlin.test.assertEquals import kotlin.test.assertIs import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.communication.CustomListAction -import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.communication.LocationsChanged import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState 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.GeographicLocationConstraint -import net.mullvad.mullvadvpn.model.LocationConstraint -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.model.Providers -import net.mullvad.mullvadvpn.relaylist.Provider -import net.mullvad.mullvadvpn.relaylist.RelayItem -import net.mullvad.mullvadvpn.relaylist.RelayList +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CustomListId +import net.mullvad.mullvadvpn.lib.model.CustomListName +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.Provider +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId import net.mullvad.mullvadvpn.relaylist.descendants import net.mullvad.mullvadvpn.relaylist.filterOnSearchTerm -import net.mullvad.mullvadvpn.relaylist.toLocationConstraint -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy -import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase -import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.repository.RelayListFilterRepository +import net.mullvad.mullvadvpn.repository.RelayListRepository +import net.mullvad.mullvadvpn.usecase.AvailableProvidersUseCase +import net.mullvad.mullvadvpn.usecase.FilteredRelayListUseCase import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListsRelayItemUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -46,36 +44,44 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) class SelectLocationViewModelTest { - private val mockRelayListFilterUseCase: RelayListFilterUseCase = mockk(relaxed = true) - private val mockServiceConnectionManager: ServiceConnectionManager = mockk() - private lateinit var viewModel: SelectLocationViewModel - private val relayListWithSelectionFlow = - MutableStateFlow(RelayList(emptyList(), emptyList(), emptyList(), null)) - private val mockRelayListUseCase: RelayListUseCase = mockk() + private val mockRelayListFilterRepository: RelayListFilterRepository = mockk() + private val mockAvailableProvidersUseCase: AvailableProvidersUseCase = mockk(relaxed = true) private val mockCustomListActionUseCase: CustomListActionUseCase = mockk(relaxed = true) - private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any()) - private val selectedProvider = MutableStateFlow<Constraint<Providers>>(Constraint.Any()) - private val allProvider = MutableStateFlow<List<Provider>>(emptyList()) + private val mockCustomListsRelayItemUseCase: CustomListsRelayItemUseCase = mockk() + private val mockFilteredRelayListUseCase: FilteredRelayListUseCase = mockk() + private val mockRelayListRepository: RelayListRepository = mockk() + + private lateinit var viewModel: SelectLocationViewModel + + private val allProviders = MutableStateFlow<List<Provider>>(emptyList()) + private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any) + private val selectedProviders = MutableStateFlow<Constraint<Providers>>(Constraint.Any) + private val selectedRelayItemFlow = MutableStateFlow<Constraint<RelayItemId>>(Constraint.Any) + private val filteredRelayList = MutableStateFlow<List<RelayItem.Location.Country>>(emptyList()) + private val customRelayListItems = MutableStateFlow<List<RelayItem.CustomList>>(emptyList()) @BeforeEach fun setup() { - every { mockRelayListFilterUseCase.selectedOwnership() } returns selectedOwnership - every { mockRelayListFilterUseCase.selectedProviders() } returns selectedProvider - every { mockRelayListFilterUseCase.availableProviders() } returns allProvider - every { mockRelayListUseCase.relayListWithSelection() } returns relayListWithSelectionFlow - every { mockRelayListUseCase.fetchRelayList() } just runs + every { mockRelayListFilterRepository.selectedOwnership } returns selectedOwnership + every { mockRelayListFilterRepository.selectedProviders } returns selectedProviders + every { mockAvailableProvidersUseCase.availableProviders() } returns allProviders + every { mockRelayListRepository.selectedLocation } returns selectedRelayItemFlow + every { mockFilteredRelayListUseCase.filteredRelayList() } returns filteredRelayList + every { mockCustomListsRelayItemUseCase.relayItemCustomLists() } returns + customRelayListItems - mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) mockkStatic(RELAY_LIST_EXTENSIONS) mockkStatic(RELAY_ITEM_EXTENSIONS) mockkStatic(CUSTOM_LIST_EXTENSIONS) viewModel = SelectLocationViewModel( - mockServiceConnectionManager, - mockRelayListUseCase, - mockRelayListFilterUseCase, - mockCustomListActionUseCase + relayListFilterRepository = mockRelayListFilterRepository, + availableProvidersUseCase = mockAvailableProvidersUseCase, + customListsRelayItemUseCase = mockCustomListsRelayItemUseCase, + customListActionUseCase = mockCustomListActionUseCase, + filteredRelayListUseCase = mockFilteredRelayListUseCase, + relayListRepository = mockRelayListRepository ) } @@ -93,12 +99,11 @@ class SelectLocationViewModelTest { @Test fun `given relayListWithSelection emits update uiState should contain new update`() = runTest { // Arrange - val mockCountries = listOf<RelayItem.Country>(mockk(), mockk()) - val mockCustomList = listOf<RelayItem.CustomList>(mockk(relaxed = true)) - val selectedItem: RelayItem = mockk() + val mockCountries = listOf<RelayItem.Location.Country>(mockk(), mockk()) + val selectedItem: RelayItemId = mockk() every { mockCountries.filterOnSearchTerm(any(), selectedItem) } returns mockCountries - relayListWithSelectionFlow.value = - RelayList(mockCustomList, mockCountries, mockCountries, selectedItem) + filteredRelayList.value = mockCountries + selectedRelayItemFlow.value = Constraint.Only(selectedItem) // Act, Assert viewModel.uiState.test { @@ -113,12 +118,11 @@ class SelectLocationViewModelTest { fun `given relayListWithSelection emits update with no selections selectedItem should be null`() = runTest { // Arrange - val mockCustomList = listOf<RelayItem.CustomList>(mockk(relaxed = true)) - val mockCountries = listOf<RelayItem.Country>(mockk(), mockk()) - val selectedItem: RelayItem? = null + val mockCountries = listOf<RelayItem.Location.Country>(mockk(), mockk()) + val selectedItem: RelayItemId? = null every { mockCountries.filterOnSearchTerm(any(), selectedItem) } returns mockCountries - relayListWithSelectionFlow.value = - RelayList(mockCustomList, mockCountries, mockCountries, selectedItem) + filteredRelayList.value = mockCountries + selectedRelayItemFlow.value = Constraint.Any // Act, Assert viewModel.uiState.test { @@ -132,25 +136,18 @@ class SelectLocationViewModelTest { @Test fun `on selectRelay call uiSideEffect should emit CloseScreen and connect`() = runTest { // Arrange - val mockRelayItem: RelayItem.Country = mockk() - val mockLocation: GeographicLocationConstraint.Country = mockk(relaxed = true) - val mockLocationConstraint: LocationConstraint = mockk() - val connectionProxyMock: ConnectionProxy = mockk(relaxUnitFun = true) - every { mockRelayItem.location } returns mockLocation - every { mockServiceConnectionManager.connectionProxy() } returns connectionProxyMock - every { mockRelayListUseCase.updateSelectedRelayLocation(mockLocationConstraint) } returns - Unit - every { mockRelayItem.toLocationConstraint() } returns mockLocationConstraint + val mockRelayItem: RelayItem.Location.Country = mockk() + val relayItemId: GeoLocationId.Country = mockk(relaxed = true) + every { mockRelayItem.id } returns relayItemId + coEvery { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } returns + Unit.right() // Act, Assert viewModel.uiSideEffect.test { viewModel.selectRelay(mockRelayItem) // Await an empty item assertEquals(SelectLocationSideEffect.CloseScreen, awaitItem()) - verify { - connectionProxyMock.connect() - mockRelayListUseCase.updateSelectedRelayLocation(mockLocationConstraint) - } + coVerify { mockRelayListRepository.updateSelectedRelayLocation(relayItemId) } } } @@ -158,15 +155,15 @@ class SelectLocationViewModelTest { fun `on onSearchTermInput call uiState should emit with filtered countries`() = runTest { // Arrange val mockCustomList = listOf<RelayItem.CustomList>(mockk(relaxed = true)) - val mockCountries = listOf<RelayItem.Country>(mockk(), mockk()) - val selectedItem: RelayItem? = null - val mockRelayList: List<RelayItem.Country> = mockk(relaxed = true) + val mockCountries = listOf<RelayItem.Location.Country>(mockk(), mockk()) + val selectedItem: RelayItemId? = null + val mockRelayList: List<RelayItem.Location.Country> = mockk(relaxed = true) val mockSearchString = "SEARCH" every { mockRelayList.filterOnSearchTerm(mockSearchString, selectedItem) } returns mockCountries every { mockCustomList.filterOnSearchTerm(mockSearchString) } returns mockCustomList - relayListWithSelectionFlow.value = - RelayList(mockCustomList, mockRelayList, mockRelayList, selectedItem) + filteredRelayList.value = mockRelayList + selectedRelayItemFlow.value = Constraint.Any // Act, Assert viewModel.uiState.test { @@ -188,15 +185,13 @@ class SelectLocationViewModelTest { fun `when onSearchTermInput returns empty result uiState should return empty list`() = runTest { // Arrange val mockCustomList = listOf<RelayItem.CustomList>(mockk(relaxed = true)) - val mockCountries = emptyList<RelayItem.Country>() - val selectedItem: RelayItem? = null - val mockRelayList: List<RelayItem.Country> = mockk(relaxed = true) + val mockCountries = emptyList<RelayItem.Location.Country>() + val selectedItem: RelayItemId? = null + val mockRelayList: List<RelayItem.Location.Country> = mockk(relaxed = true) val mockSearchString = "SEARCH" every { mockRelayList.filterOnSearchTerm(mockSearchString, selectedItem) } returns mockCountries every { mockCustomList.filterOnSearchTerm(mockSearchString) } returns mockCustomList - relayListWithSelectionFlow.value = - RelayList(mockCustomList, mockRelayList, mockRelayList, selectedItem) // Act, Assert viewModel.uiState.test { @@ -217,36 +212,30 @@ class SelectLocationViewModelTest { fun `removeOwnerFilter should invoke use case with Constraint Any Ownership`() = runTest { // Arrange val mockSelectedProviders: Constraint<Providers> = mockk() - every { mockRelayListFilterUseCase.selectedProviders() } returns + every { mockRelayListFilterRepository.selectedProviders } returns MutableStateFlow(mockSelectedProviders) + coEvery { mockRelayListFilterRepository.updateSelectedOwnership(Constraint.Any) } returns + Unit.right() // Act viewModel.removeOwnerFilter() // Assert - verify { - mockRelayListFilterUseCase.updateOwnershipAndProviderFilter( - any<Constraint.Any<Ownership>>(), - mockSelectedProviders - ) - } + coVerify { mockRelayListFilterRepository.updateSelectedOwnership(Constraint.Any) } } @Test fun `removeProviderFilter should invoke use case with Constraint Any Provider`() = runTest { // Arrange val mockSelectedOwnership: Constraint<Ownership> = mockk() - every { mockRelayListFilterUseCase.selectedOwnership() } returns + every { mockRelayListFilterRepository.selectedOwnership } returns MutableStateFlow(mockSelectedOwnership) + coEvery { mockRelayListFilterRepository.updateSelectedProviders(Constraint.Any) } returns + Unit.right() // Act viewModel.removeProviderFilter() // Assert - verify { - mockRelayListFilterUseCase.updateOwnershipAndProviderFilter( - mockSelectedOwnership, - any<Constraint.Any<Providers>>() - ) - } + coVerify { mockRelayListFilterRepository.updateSelectedProviders(Constraint.Any) } } @Test @@ -264,18 +253,21 @@ class SelectLocationViewModelTest { @Test fun `after adding a location to a list should emit location added side effect`() = runTest { // Arrange - val expectedResult: CustomListResult.LocationsChanged = mockk() - val location: RelayItem = mockk { - every { code } returns "code" + val expectedResult: LocationsChanged = mockk() + val location: RelayItem.Location.Country = mockk { + every { id } returns GeoLocationId.Country("se") every { descendants() } returns emptyList() } - val customList: RelayItem.CustomList = mockk { - every { id } returns "1" - every { locations } returns emptyList() - } + val customList = + RelayItem.CustomList( + id = CustomListId("1"), + customListName = CustomListName.fromString("custom"), + locations = emptyList(), + expanded = false + ) coEvery { mockCustomListActionUseCase.performAction(any<CustomListAction.UpdateLocations>()) - } returns Result.success(expectedResult) + } returns expectedResult.right() // Act, Assert viewModel.uiSideEffect.test { @@ -287,8 +279,6 @@ class SelectLocationViewModelTest { } companion object { - private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = - "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt" private const val RELAY_LIST_EXTENSIONS = "net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt" private const val RELAY_ITEM_EXTENSIONS = diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt index 16e89ac20b..b39d4357de 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ServerIpOverridesViewModelTest.kt @@ -4,6 +4,8 @@ import android.content.ContentResolver import android.net.Uri import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import arrow.core.left +import arrow.core.right import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -17,13 +19,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.model.RelayOverride -import net.mullvad.mullvadvpn.model.SettingsPatchError +import net.mullvad.mullvadvpn.lib.model.RelayOverride +import net.mullvad.mullvadvpn.lib.model.SettingsPatchError import net.mullvad.mullvadvpn.repository.RelayOverridesRepository -import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -33,27 +31,20 @@ import org.junit.jupiter.api.extension.ExtendWith class ServerIpOverridesViewModelTest { private lateinit var viewModel: ServerIpOverridesViewModel - private val mockServiceConnectionManager: ServiceConnectionManager = mockk() private val mockRelayOverridesRepository: RelayOverridesRepository = mockk() - private val mockSettingsRepository: SettingsRepository = mockk(relaxed = true) private val mockContentResolver: ContentResolver = mockk() private val relayOverrides = MutableStateFlow<List<RelayOverride>?>(null) - private val serviceConnectionState = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.ConnectedReady(mockk())) @BeforeEach fun setup() { coEvery { mockRelayOverridesRepository.relayOverrides } returns relayOverrides - coEvery { mockServiceConnectionManager.connectionState } returns serviceConnectionState mockkStatic(READ_TEXT) viewModel = ServerIpOverridesViewModel( - serviceConnectionManager = mockServiceConnectionManager, relayOverridesRepository = mockRelayOverridesRepository, - settingsRepository = mockSettingsRepository, contentResolver = mockContentResolver ) } @@ -80,10 +71,12 @@ class ServerIpOverridesViewModelTest { @Test fun `when import is finished we should get side effect`() = runTest { + // Arrange val mockkResult: SettingsPatchError = mockk() - coEvery { mockSettingsRepository.applySettingsPatch(TEXT_INPUT) } returns - Event.ApplyJsonSettingsResult(mockkResult) + coEvery { mockRelayOverridesRepository.applySettingsPatch(TEXT_INPUT) } returns + mockkResult.left() + // Act, Assert viewModel.uiSideEffect.test { viewModel.importText(TEXT_INPUT) assertEquals(ServerIpOverridesUiSideEffect.ImportResult(mockkResult), awaitItem()) @@ -92,22 +85,30 @@ class ServerIpOverridesViewModelTest { @Test fun `ensure import text invokes repository`() = runTest { + // Arrange + coEvery { mockRelayOverridesRepository.applySettingsPatch(TEXT_INPUT) } returns Unit.right() + + // Act viewModel.importText(TEXT_INPUT) - coVerify { mockSettingsRepository.applySettingsPatch(TEXT_INPUT) } + // Assert + coVerify { mockRelayOverridesRepository.applySettingsPatch(TEXT_INPUT) } } @Test fun `ensure import file invokes repository`() = runTest { + // Arrange val uri: Uri = mockk() - val mockInputStream: InputStream = mockk() every { mockContentResolver.openInputStream(uri) } returns mockInputStream every { any<InputStreamReader>().readText() } returns TEXT_INPUT + coEvery { mockRelayOverridesRepository.applySettingsPatch(TEXT_INPUT) } returns Unit.right() + // Act viewModel.importFile(uri) - coVerify { mockSettingsRepository.applySettingsPatch(TEXT_INPUT) } + // Assert + coVerify { mockRelayOverridesRepository.applySettingsPatch(TEXT_INPUT) } } companion object { diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt index 0eace9ca43..c76e2cd278 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt @@ -4,21 +4,16 @@ import androidx.lifecycle.viewModelScope import app.cash.turbine.test import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.assertEquals import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.util.appVersionCallbackFlow +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -28,42 +23,26 @@ import org.junit.jupiter.api.extension.ExtendWith class SettingsViewModelTest { private val mockDeviceRepository: DeviceRepository = mockk() - private val mockServiceConnectionManager: ServiceConnectionManager = mockk() - private lateinit var mockAppVersionInfoCache: AppVersionInfoCache - private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() + private val mockAppVersionInfoRepository: AppVersionInfoRepository = mockk() - private val serviceConnectionState = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) private val versionInfo = MutableStateFlow( - VersionInfo( - currentVersion = null, - upgradeVersion = null, - isOutdated = false, - isSupported = false - ) + VersionInfo(currentVersion = "", isSupported = false, suggestedUpgradeVersion = null) ) private lateinit var viewModel: SettingsViewModel @BeforeEach fun setup() { - mockkStatic(CACHE_EXTENSION_CLASS) val deviceState = MutableStateFlow<DeviceState>(DeviceState.LoggedOut) - mockAppVersionInfoCache = - mockk<AppVersionInfoCache>().apply { - every { appVersionCallbackFlow() } returns versionInfo - } - every { mockServiceConnectionManager.connectionState } returns serviceConnectionState - every { mockServiceConnectionContainer.appVersionInfoCache } returns mockAppVersionInfoCache every { mockDeviceRepository.deviceState } returns deviceState - every { mockAppVersionInfoCache.onUpdate = any() } answers {} + every { mockAppVersionInfoRepository.versionInfo() } returns versionInfo viewModel = SettingsViewModel( deviceRepository = mockDeviceRepository, - serviceConnectionManager = mockServiceConnectionManager, + appVersionInfoRepository = mockAppVersionInfoRepository, isPlayBuild = false ) } @@ -87,20 +66,14 @@ class SettingsViewModelTest { val versionInfoTestItem = VersionInfo( currentVersion = "1.0", - upgradeVersion = "1.0", - isOutdated = false, - isSupported = true + isSupported = true, + suggestedUpgradeVersion = null ) - every { mockAppVersionInfoCache.version } returns "1.0" - every { mockAppVersionInfoCache.isSupported } returns true - every { mockAppVersionInfoCache.isOutdated } returns false // Act, Assert viewModel.uiState.test { awaitItem() // Wait for initial value - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) versionInfo.value = versionInfoTestItem val result = awaitItem() assertEquals(false, result.isUpdateAvailable) @@ -111,16 +84,12 @@ class SettingsViewModelTest { fun `when AppVersionInfoCache returns isSupported false uiState should return isUpdateAvailable true`() = runTest { // Arrange - every { mockAppVersionInfoCache.isSupported } returns false - every { mockAppVersionInfoCache.isOutdated } returns false - every { mockAppVersionInfoCache.version } returns "" + val versionInfoTestItem = + VersionInfo(currentVersion = "", isSupported = false, suggestedUpgradeVersion = "") + versionInfo.value = versionInfoTestItem // Act, Assert viewModel.uiState.test { - awaitItem() - - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) val result = awaitItem() assertEquals(true, result.isUpdateAvailable) } @@ -130,22 +99,14 @@ class SettingsViewModelTest { fun `when AppVersionInfoCache returns isOutdated true uiState should return isUpdateAvailable true`() = runTest { // Arrange - every { mockAppVersionInfoCache.isSupported } returns true - every { mockAppVersionInfoCache.isOutdated } returns true - every { mockAppVersionInfoCache.version } returns "" + val versionInfoTestItem = + VersionInfo(currentVersion = "", isSupported = true, suggestedUpgradeVersion = "") + versionInfo.value = versionInfoTestItem // Act, Assert viewModel.uiState.test { - awaitItem() - - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) val result = awaitItem() assertEquals(true, result.isUpdateAvailable) } } - - companion object { - private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt" - } } 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 11b253e5ea..aa1ccc82f0 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 @@ -2,15 +2,13 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import arrow.core.right +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every -import io.mockk.invoke -import io.mockk.just import io.mockk.mockk -import io.mockk.runs -import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify -import io.mockk.verifyAll import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlinx.coroutines.cancel @@ -21,10 +19,8 @@ import net.mullvad.mullvadvpn.applist.AppData import net.mullvad.mullvadvpn.applist.ApplicationsProvider import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling +import net.mullvad.mullvadvpn.lib.model.AppId +import net.mullvad.mullvadvpn.repository.SplitTunnelingRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -36,14 +32,16 @@ import org.junit.jupiter.api.extension.ExtendWith class SplitTunnelingViewModelTest { private val mockedApplicationsProvider = mockk<ApplicationsProvider>() - private val mockedSplitTunneling = mockk<SplitTunneling>() - private val mockedServiceConnectionManager = mockk<ServiceConnectionManager>() - private val mockedServiceConnectionContainer = mockk<ServiceConnectionContainer>() + private val mockedSplitTunnelingRepository = mockk<SplitTunnelingRepository>() private lateinit var testSubject: SplitTunnelingViewModel + private val excludedApps: MutableStateFlow<Set<AppId>> = MutableStateFlow(emptySet()) + private val enabled: MutableStateFlow<Boolean> = MutableStateFlow(true) + @BeforeEach fun setup() { - every { mockedSplitTunneling.enabled } returns true + every { mockedSplitTunnelingRepository.splitTunnelingEnabled } returns enabled + every { mockedSplitTunnelingRepository.excludedApps } returns excludedApps } @AfterEach @@ -66,14 +64,6 @@ class SplitTunnelingViewModelTest { @Test fun `empty app list should work`() = runTest { - every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers - { - lambda<(Set<String>) -> Unit>().invoke(emptySet()) - } - every { mockedSplitTunneling.enabledChange = captureLambda() } answers - { - lambda<(Boolean) -> Unit>().invoke(true) - } initTestSubject(emptyList()) val expectedState = SplitTunnelingUiState.ShowAppList( @@ -89,16 +79,9 @@ class SplitTunnelingViewModelTest { fun `includedApps and excludedApps should both be included in uiState`() = runTest { val appExcluded = AppData("test.excluded", 0, "testName1") val appNotExcluded = AppData("test.not.excluded", 0, "testName2") - every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers - { - lambda<(Set<String>) -> Unit>().invoke(setOf(appExcluded.packageName)) - } - every { mockedSplitTunneling.enabledChange = captureLambda() } answers - { - lambda<(Boolean) -> Unit>().invoke(true) - } initTestSubject(listOf(appExcluded, appNotExcluded)) + excludedApps.value = setOf(AppId(appExcluded.packageName)) val expectedState = SplitTunnelingUiState.ShowAppList( @@ -111,29 +94,15 @@ class SplitTunnelingViewModelTest { testSubject.uiState.test { val actualState = awaitItem() assertEquals(expectedState, actualState) - verifyAll { - mockedSplitTunneling.enabledChange = any() - mockedSplitTunneling.excludedAppsChange = any() - } } } @Test fun `include app should work`() = runTest { - var excludedAppsCallback = slot<(Set<String>) -> Unit>() val app = AppData("test", 0, "testName") - every { mockedSplitTunneling.includeApp(app.packageName) } just runs - every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers - { - excludedAppsCallback = lambda() - excludedAppsCallback.invoke(setOf(app.packageName)) - } - every { mockedSplitTunneling.enabledChange = captureLambda() } answers - { - lambda<(Boolean) -> Unit>().invoke(true) - } initTestSubject(listOf(app)) + excludedApps.value = setOf(AppId(app.packageName)) val expectedStateBeforeAction = SplitTunnelingUiState.ShowAppList( @@ -149,35 +118,22 @@ class SplitTunnelingViewModelTest { includedApps = listOf(app), showSystemApps = false ) + coEvery { mockedSplitTunnelingRepository.includeApp(AppId(app.packageName)) } returns + Unit.right() testSubject.uiState.test { assertEquals(expectedStateBeforeAction, awaitItem()) testSubject.onIncludeAppClick(app.packageName) - excludedAppsCallback.invoke(emptySet()) + excludedApps.value = emptySet() assertEquals(expectedStateAfterAction, awaitItem()) - verifyAll { - mockedSplitTunneling.enabledChange = any() - mockedSplitTunneling.excludedAppsChange = any() - mockedSplitTunneling.includeApp(app.packageName) - } + coVerify { mockedSplitTunnelingRepository.includeApp(AppId(app.packageName)) } } } @Test fun `onExcludeApp should result in new uiState with app excluded`() = runTest { - var excludedAppsCallback = slot<(Set<String>) -> Unit>() val app = AppData("test", 0, "testName") - every { mockedSplitTunneling.excludeApp(app.packageName) } just runs - every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers - { - excludedAppsCallback = lambda() - excludedAppsCallback.invoke(emptySet()) - } - every { mockedSplitTunneling.enabledChange = captureLambda() } answers - { - lambda<(Boolean) -> Unit>().invoke(true) - } initTestSubject(listOf(app)) @@ -197,32 +153,23 @@ class SplitTunnelingViewModelTest { showSystemApps = false ) + coEvery { mockedSplitTunnelingRepository.excludeApp(AppId(app.packageName)) } returns + Unit.right() + testSubject.uiState.test { assertEquals(expectedStateBeforeAction, awaitItem()) testSubject.onExcludeAppClick(app.packageName) - excludedAppsCallback.invoke(setOf(app.packageName)) + excludedApps.value = setOf(AppId(app.packageName)) assertEquals(expectedStateAfterAction, awaitItem()) - verifyAll { - mockedSplitTunneling.enabledChange = any() - mockedSplitTunneling.excludedAppsChange = any() - mockedSplitTunneling.excludeApp(app.packageName) - } + coVerify { mockedSplitTunnelingRepository.excludeApp(AppId(app.packageName)) } } } @Test fun `when split tunneling is disabled uiState should be disabled`() = runTest { - every { mockedSplitTunneling.excludedAppsChange = captureLambda() } answers - { - lambda<(Set<String>) -> Unit>().invoke(emptySet()) - } - every { mockedSplitTunneling.enabledChange = captureLambda() } answers - { - lambda<(Boolean) -> Unit>().invoke(false) - } - initTestSubject(emptyList()) + enabled.value = false val expectedState = SplitTunnelingUiState.ShowAppList(enabled = false) @@ -234,15 +181,10 @@ class SplitTunnelingViewModelTest { private fun initTestSubject(appList: List<AppData>) { every { mockedApplicationsProvider.getAppsList() } returns appList - every { mockedServiceConnectionManager.connectionState } returns - MutableStateFlow( - ServiceConnectionState.ConnectedReady(mockedServiceConnectionContainer) - ) - every { mockedServiceConnectionContainer.splitTunneling } returns mockedSplitTunneling testSubject = SplitTunnelingViewModel( mockedApplicationsProvider, - mockedServiceConnectionManager, + mockedSplitTunnelingRepository, UnconfinedTestDispatcher() ) } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt index 6934384643..ef3b34effc 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt @@ -1,7 +1,8 @@ package net.mullvad.mullvadvpn.viewmodel -import android.content.res.Resources import app.cash.turbine.test +import arrow.core.left +import arrow.core.right import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -10,18 +11,13 @@ import io.mockk.unmockkAll import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.state.VoucherDialogState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.model.VoucherSubmission -import net.mullvad.mullvadvpn.model.VoucherSubmissionError -import net.mullvad.mullvadvpn.model.VoucherSubmissionResult -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.VoucherRedeemer -import net.mullvad.mullvadvpn.ui.serviceconnection.voucherRedeemer +import net.mullvad.mullvadvpn.lib.model.RedeemVoucherError +import net.mullvad.mullvadvpn.lib.model.RedeemVoucherSuccess +import net.mullvad.mullvadvpn.lib.shared.VoucherRepository +import org.joda.time.DateTime import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -30,26 +26,15 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) class VoucherDialogViewModelTest { - private val mockServiceConnectionManager: ServiceConnectionManager = mockk() - private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() - private val mockVoucherSubmission: VoucherSubmission = mockk() - private val serviceConnectionState = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) + private val mockVoucherSubmission: RedeemVoucherSuccess = mockk() - private val mockVoucherRedeemer: VoucherRedeemer = mockk() - private val mockResources: Resources = mockk() + private val mockVoucherRepository: VoucherRepository = mockk() private lateinit var viewModel: VoucherDialogViewModel @BeforeEach fun setup() { - every { mockServiceConnectionManager.connectionState } returns serviceConnectionState - - viewModel = - VoucherDialogViewModel( - serviceConnectionManager = mockServiceConnectionManager, - resources = mockResources - ) + viewModel = VoucherDialogViewModel(voucherRepository = mockVoucherRepository) } @AfterEach @@ -62,36 +47,31 @@ class VoucherDialogViewModelTest { val voucher = DUMMY_INVALID_VOUCHER // Arrange - every { mockServiceConnectionManager.voucherRedeemer() } returns mockVoucherRedeemer - every { mockVoucherSubmission.timeAdded } returns 0 - coEvery { mockVoucherRedeemer.submit(voucher) } returns - VoucherSubmissionResult.Ok(mockVoucherSubmission) + val timeAdded = 0L + val newExpiry = DateTime() + coEvery { mockVoucherRepository.submitVoucher(voucher) } returns + RedeemVoucherSuccess(timeAdded, newExpiry).right() // Act assertIs<VoucherDialogState.Default>(viewModel.uiState.value.voucherState) viewModel.onRedeem(voucher) // Assert - coVerify(exactly = 1) { mockVoucherRedeemer.submit(voucher) } + coVerify(exactly = 1) { mockVoucherRepository.submitVoucher(voucher) } } @Test fun `given invalid voucher when redeeming then show error`() = runTest { val voucher = DUMMY_INVALID_VOUCHER - val dummyStringResource = DUMMY_STRING_RESOURCE // Arrange - every { mockServiceConnectionManager.voucherRedeemer() } returns mockVoucherRedeemer - every { mockResources.getString(any()) } returns dummyStringResource every { mockVoucherSubmission.timeAdded } returns 0 - coEvery { mockVoucherRedeemer.submit(voucher) } returns - VoucherSubmissionResult.Error(VoucherSubmissionError.OtherError) + coEvery { mockVoucherRepository.submitVoucher(voucher) } returns + RedeemVoucherError.InvalidVoucher.left() // Act, Assert viewModel.uiState.test { assertEquals(viewModel.uiState.value, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) viewModel.onRedeem(voucher) assertTrue { awaitItem().voucherState is VoucherDialogState.Verifying } assertTrue { awaitItem().voucherState is VoucherDialogState.Error } @@ -101,20 +81,15 @@ class VoucherDialogViewModelTest { @Test fun `given valid voucher when redeeming then show success`() = runTest { val voucher = DUMMY_VALID_VOUCHER - val dummyStringResource = DUMMY_STRING_RESOURCE // Arrange - every { mockServiceConnectionManager.voucherRedeemer() } returns mockVoucherRedeemer - every { mockResources.getString(any()) } returns dummyStringResource every { mockVoucherSubmission.timeAdded } returns 0 - coEvery { mockVoucherRedeemer.submit(voucher) } returns - VoucherSubmissionResult.Ok(VoucherSubmission(0, DUMMY_STRING_RESOURCE)) + coEvery { mockVoucherRepository.submitVoucher(voucher) } returns + RedeemVoucherSuccess(0, DateTime()).right() // Act, Assert viewModel.uiState.test { assertEquals(viewModel.uiState.value, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) viewModel.onRedeem(voucher) assertTrue { awaitItem().voucherState is VoucherDialogState.Verifying } assertTrue { awaitItem().voucherState is VoucherDialogState.Success } @@ -124,20 +99,15 @@ class VoucherDialogViewModelTest { @Test fun `when voucher input is changed then clear error`() = runTest { val voucher = DUMMY_INVALID_VOUCHER - val dummyStringResource = DUMMY_STRING_RESOURCE // Arrange - every { mockServiceConnectionManager.voucherRedeemer() } returns mockVoucherRedeemer - every { mockResources.getString(any()) } returns dummyStringResource every { mockVoucherSubmission.timeAdded } returns 0 - coEvery { mockVoucherRedeemer.submit(voucher) } returns - VoucherSubmissionResult.Error(VoucherSubmissionError.OtherError) + coEvery { mockVoucherRepository.submitVoucher(voucher) } returns + RedeemVoucherError.VoucherAlreadyUsed.left() // Act, Assert viewModel.uiState.test { assertEquals(viewModel.uiState.value, awaitItem()) - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) viewModel.onRedeem(voucher) assertTrue { awaitItem().voucherState is VoucherDialogState.Verifying } assertTrue { awaitItem().voucherState is VoucherDialogState.Error } @@ -149,6 +119,5 @@ class VoucherDialogViewModelTest { companion object { private const val DUMMY_VALID_VOUCHER = "dummy_valid_voucher" private const val DUMMY_INVALID_VOUCHER = "dummy_invalid_voucher" - private const val DUMMY_STRING_RESOURCE = "dummy_string_resource" } } 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 11992c40c0..29a6c764ba 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 @@ -1,12 +1,13 @@ package net.mullvad.mullvadvpn.viewmodel -import android.content.res.Resources import androidx.lifecycle.viewModelScope import app.cash.turbine.test +import arrow.core.right +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll -import io.mockk.verify import kotlin.test.assertEquals import kotlin.test.assertIs import kotlinx.coroutines.cancel @@ -14,19 +15,19 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -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.RelayConstraints -import net.mullvad.mullvadvpn.model.RelaySettings -import net.mullvad.mullvadvpn.model.Settings -import net.mullvad.mullvadvpn.model.TunnelOptions -import net.mullvad.mullvadvpn.model.WireguardConstraints -import net.mullvad.mullvadvpn.model.WireguardTunnelOptions +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.PortRange +import net.mullvad.mullvadvpn.lib.model.QuantumResistantState +import net.mullvad.mullvadvpn.lib.model.RelayConstraints +import net.mullvad.mullvadvpn.lib.model.RelaySettings +import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.TunnelOptions +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.lib.model.WireguardTunnelOptions +import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository -import net.mullvad.mullvadvpn.usecase.PortRangeUseCase -import net.mullvad.mullvadvpn.usecase.RelayListUseCase import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -37,10 +38,8 @@ import org.junit.jupiter.api.extension.ExtendWith class VpnSettingsViewModelTest { private val mockSettingsRepository: SettingsRepository = mockk() - private val mockResources: Resources = mockk() - private val mockPortRangeUseCase: PortRangeUseCase = mockk() - private val mockRelayListUseCase: RelayListUseCase = mockk() private val mockSystemVpnSettingsUseCase: SystemVpnSettingsUseCase = mockk(relaxed = true) + private val mockRelayListRepository: RelayListRepository = mockk() private val mockSettingsUpdate = MutableStateFlow<Settings?>(null) private val portRangeFlow = MutableStateFlow(emptyList<PortRange>()) @@ -50,15 +49,13 @@ class VpnSettingsViewModelTest { @BeforeEach fun setup() { every { mockSettingsRepository.settingsUpdates } returns mockSettingsUpdate - every { mockPortRangeUseCase.portRanges() } returns portRangeFlow + every { mockRelayListRepository.portRanges } returns portRangeFlow viewModel = VpnSettingsViewModel( repository = mockSettingsRepository, - resources = mockResources, - portRangeUseCase = mockPortRangeUseCase, - relayListUseCase = mockRelayListUseCase, systemVpnSettingsUseCase = mockSystemVpnSettingsUseCase, + relayListRepository = mockRelayListRepository, dispatcher = UnconfinedTestDispatcher() ) } @@ -73,11 +70,11 @@ class VpnSettingsViewModelTest { fun `onSelectQuantumResistanceSetting should invoke setWireguardQuantumResistant on SettingsRepository`() = runTest { val quantumResistantState = QuantumResistantState.On - every { + coEvery { mockSettingsRepository.setWireguardQuantumResistant(quantumResistantState) - } returns Unit + } returns Unit.right() viewModel.onSelectQuantumResistanceSetting(quantumResistantState) - verify(exactly = 1) { + coVerify(exactly = 1) { mockSettingsRepository.setWireguardQuantumResistant(quantumResistantState) } } @@ -105,7 +102,8 @@ class VpnSettingsViewModelTest { every { mockSettings.tunnelOptions } returns mockTunnelOptions every { mockTunnelOptions.wireguard } returns mockWireguardTunnelOptions every { mockWireguardTunnelOptions.quantumResistant } returns expectedResistantState - every { mockSettings.relaySettings } returns mockk<RelaySettings.Normal>(relaxed = true) + every { mockWireguardTunnelOptions.mtu } returns Mtu(0) + every { mockSettings.relaySettings } returns mockk<RelaySettings>(relaxed = true) viewModel.uiState.test { assertEquals(defaultResistantState, awaitItem().quantumResistant) @@ -120,7 +118,7 @@ class VpnSettingsViewModelTest { // Arrange val expectedPort: Constraint<Port> = Constraint.Only(Port(99)) val mockSettings: Settings = mockk(relaxed = true) - val mockRelaySettings: RelaySettings.Normal = mockk() + val mockRelaySettings: RelaySettings = mockk() val mockRelayConstraints: RelayConstraints = mockk() val mockWireguardConstraints: WireguardConstraints = mockk() @@ -128,10 +126,19 @@ class VpnSettingsViewModelTest { every { mockRelaySettings.relayConstraints } returns mockRelayConstraints every { mockRelayConstraints.wireguardConstraints } returns mockWireguardConstraints every { mockWireguardConstraints.port } returns expectedPort + every { mockSettings.tunnelOptions } returns + TunnelOptions( + wireguard = + WireguardTunnelOptions( + mtu = null, + quantumResistant = QuantumResistantState.Off + ), + dnsOptions = mockk(relaxed = true) + ) // Act, Assert viewModel.uiState.test { - assertIs<Constraint.Any<Port>>(awaitItem().selectedWireguardPort) + assertIs<Constraint.Any>(awaitItem().selectedWireguardPort) mockSettingsUpdate.value = mockSettings assertEquals(expectedPort, awaitItem().customWireguardPort) assertEquals(expectedPort, awaitItem().selectedWireguardPort) @@ -144,14 +151,15 @@ class VpnSettingsViewModelTest { // Arrange val wireguardPort: Constraint<Port> = Constraint.Only(Port(99)) val wireguardConstraints = WireguardConstraints(port = wireguardPort) - every { mockRelayListUseCase.updateSelectedWireguardConstraints(any()) } returns Unit + coEvery { mockRelayListRepository.updateSelectedWireguardConstraints(any()) } returns + Unit.right() // Act viewModel.onWireguardPortSelected(wireguardPort) // Assert - verify(exactly = 1) { - mockRelayListUseCase.updateSelectedWireguardConstraints(wireguardConstraints) + coVerify(exactly = 1) { + mockRelayListRepository.updateSelectedWireguardConstraints(wireguardConstraints) } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt index 91554193bc..3113450276 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt @@ -13,27 +13,23 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.state.PaymentState -import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult -import net.mullvad.mullvadvpn.model.AccountAndDevice -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.Device -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.talpid.util.EventNotifier import org.joda.time.DateTime import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -44,21 +40,20 @@ import org.junit.jupiter.api.extension.ExtendWith class WelcomeViewModelTest { private val serviceConnectionStateFlow = - MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) - private val deviceStateFlow = MutableStateFlow<DeviceState>(DeviceState.Initial) - private val accountExpiryStateFlow = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) + MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Unbound) + private val deviceStateFlow = MutableStateFlow<DeviceState?>(DeviceState.LoggedOut) + private val accountExpiryStateFlow = MutableStateFlow<AccountData?>(null) private val purchaseResultFlow = MutableStateFlow<PurchaseResult?>(null) private val paymentAvailabilityFlow = MutableStateFlow<PaymentAvailability?>(null) - // Service connections - private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() + // ConnectionProxy private val mockConnectionProxy: ConnectionProxy = mockk() // Event notifiers - private val eventNotifierTunnelUiState = EventNotifier<TunnelState>(TunnelState.Disconnected()) + private val tunnelState = MutableStateFlow<TunnelState>(TunnelState.Disconnected()) private val mockAccountRepository: AccountRepository = mockk(relaxed = true) - private val mockDeviceRepository: DeviceRepository = mockk() + private val mockDeviceRepository: DeviceRepository = mockk(relaxed = true) private val mockServiceConnectionManager: ServiceConnectionManager = mockk() private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true) @@ -66,18 +61,15 @@ class WelcomeViewModelTest { @BeforeEach fun setup() { - mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) every { mockDeviceRepository.deviceState } returns deviceStateFlow every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow - every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy + every { mockConnectionProxy.tunnelState } returns tunnelState - every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState - - every { mockAccountRepository.accountExpiryState } returns accountExpiryStateFlow + every { mockAccountRepository.accountData } returns accountExpiryStateFlow coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResultFlow @@ -87,8 +79,8 @@ class WelcomeViewModelTest { WelcomeViewModel( accountRepository = mockAccountRepository, deviceRepository = mockDeviceRepository, - serviceConnectionManager = mockServiceConnectionManager, paymentUseCase = mockPaymentUseCase, + connectionProxy = mockConnectionProxy, pollAccountExpiry = false, isPlayBuild = false ) @@ -103,10 +95,8 @@ class WelcomeViewModelTest { @Test fun `on onSitePaymentClick call uiSideEffect should emit OpenAccountView`() = runTest { // Arrange - val mockToken = "4444 5555 6666 7777" - val mockAuthTokenCache: AuthTokenCache = mockk(relaxed = true) - every { mockServiceConnectionManager.authTokenCache() } returns mockAuthTokenCache - coEvery { mockAuthTokenCache.fetchAuthToken() } returns mockToken + val mockToken = WebsiteAuthToken.fromString("154c4cc94810fddac78398662b7fa0c7") + coEvery { mockAccountRepository.getWebsiteAuthToken() } returns mockToken // Act, Assert viewModel.uiSideEffect.test { @@ -124,10 +114,9 @@ class WelcomeViewModelTest { // Act, Assert viewModel.uiState.test { - assertEquals(WelcomeUiState(), awaitItem()) - eventNotifierTunnelUiState.notify(tunnelUiStateTestItem) - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + // Default state + awaitItem() + tunnelState.emit(tunnelUiStateTestItem) val result = awaitItem() assertEquals(tunnelUiStateTestItem, result.tunnelState) } @@ -137,21 +126,17 @@ class WelcomeViewModelTest { fun `when DeviceRepository returns LoggedIn uiState should include new accountNumber`() = runTest { // Arrange - val expectedAccountNumber = "4444555566667777" + val expectedAccountNumber = AccountToken("4444555566667777") val device: Device = mockk() every { device.displayName() } returns "" // Act, Assert viewModel.uiState.test { - assertEquals(WelcomeUiState(), awaitItem()) + // Default state + awaitItem() paymentAvailabilityFlow.value = null deviceStateFlow.value = - DeviceState.LoggedIn( - accountAndDevice = - AccountAndDevice(account_token = expectedAccountNumber, device = device) - ) - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + DeviceState.LoggedIn(accountToken = expectedAccountNumber, device = device) assertEquals(expectedAccountNumber, awaitItem().accountNumber) } } @@ -159,7 +144,7 @@ class WelcomeViewModelTest { @Test fun `when user has added time then uiSideEffect should emit OpenConnectScreen`() = runTest { // Arrange - accountExpiryStateFlow.emit(AccountExpiry.Available(DateTime().plusDays(1))) + accountExpiryStateFlow.emit(AccountData(mockk(relaxed = true), DateTime().plusDays(1))) // Act, Assert viewModel.uiSideEffect.test { @@ -179,8 +164,6 @@ class WelcomeViewModelTest { // Default item awaitItem() paymentAvailabilityFlow.tryEmit(productsUnavailable) - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) val result = awaitItem().billingPaymentState assertIs<PaymentState.NoPayment>(result) } @@ -192,8 +175,6 @@ class WelcomeViewModelTest { // Arrange val paymentOtherError = PaymentAvailability.Error.Other(mockk()) paymentAvailabilityFlow.tryEmit(paymentOtherError) - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert viewModel.uiState.test { @@ -207,8 +188,6 @@ class WelcomeViewModelTest { runTest { // Arrange val paymentBillingError = PaymentAvailability.Error.BillingUnavailable paymentAvailabilityFlow.value = paymentBillingError - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert viewModel.uiState.test { @@ -225,8 +204,6 @@ class WelcomeViewModelTest { val expectedProductList = listOf(mockProduct) val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct)) paymentAvailabilityFlow.value = productsAvailable - serviceConnectionStateFlow.value = - ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) // Act, Assert viewModel.uiState.test { @@ -237,8 +214,6 @@ class WelcomeViewModelTest { } companion object { - private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = - "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt" private const val PURCHASE_RESULT_EXTENSIONS_CLASS = "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" } |
