diff --git a/.github/workflows/android-app.yml b/.github/workflows/android-app.yml index 576dbfdddf29..562ae34e8e6f 100644 --- a/.github/workflows/android-app.yml +++ b/.github/workflows/android-app.yml @@ -222,6 +222,8 @@ jobs: :app:testOssProdDebugUnitTest :service:testOssProdDebugUnitTest :lib:billing:testDebugUnitTest + :lib:daemon-grpc:testDebugUnitTest + :lib:shared:testDebugUnitTest - gradle-task: :test:arch:test --rerun-tasks - gradle-task: detekt - gradle-task: lint diff --git a/Cargo.lock b/Cargo.lock index 07fb64dcd90a..1df9eeb250f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2366,7 +2366,6 @@ dependencies = [ "clap", "intersection-derive", "ipnetwork", - "jnix", "log", "once_cell", "regex", diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 750b98aa6740..798667ff3f32 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -174,22 +174,23 @@ android { "META-INF/LGPL2.1", // Fixes packaging error caused by: jetified-junit-* "META-INF/LICENSE.md", - "META-INF/LICENSE-notice.md" + "META-INF/LICENSE-notice.md", + "META-INF/io.netty.versions.properties", + "META-INF/INDEX.LIST" ) } } applicationVariants.configureEach { val alwaysShowChangelog = - gradleLocalProperties(rootProject.projectDir, providers).getProperty("ALWAYS_SHOW_CHANGELOG") - ?: "false" + gradleLocalProperties(rootProject.projectDir, providers) + .getProperty("ALWAYS_SHOW_CHANGELOG") ?: "false" buildConfigField("boolean", "ALWAYS_SHOW_CHANGELOG", alwaysShowChangelog) val enableInAppVersionNotifications = gradleLocalProperties(rootProject.projectDir, providers) - .getProperty("ENABLE_IN_APP_VERSION_NOTIFICATIONS") - ?: "true" + .getProperty("ENABLE_IN_APP_VERSION_NOTIFICATIONS") ?: "true" buildConfigField( "boolean", @@ -305,18 +306,19 @@ afterEvaluate { play { serviceAccountCredentials.set(file("play-api-key.json")) } dependencies { - implementation(project(Dependencies.Mullvad.vpnService)) - implementation(project(Dependencies.Mullvad.tileService)) - implementation(project(Dependencies.Mullvad.commonLib)) + implementation(project(Dependencies.Mullvad.daemonGrpc)) implementation(project(Dependencies.Mullvad.endpointLib)) - implementation(project(Dependencies.Mullvad.ipcLib)) + implementation(project(Dependencies.Mullvad.intentLib)) + implementation(project(Dependencies.Mullvad.mapLib)) implementation(project(Dependencies.Mullvad.modelLib)) + implementation(project(Dependencies.Mullvad.paymentLib)) implementation(project(Dependencies.Mullvad.resourceLib)) + implementation(project(Dependencies.Mullvad.sharedLib)) implementation(project(Dependencies.Mullvad.talpidLib)) + implementation(project(Dependencies.Mullvad.tileService)) implementation(project(Dependencies.Mullvad.themeLib)) - implementation(project(Dependencies.Mullvad.paymentLib)) - implementation(project(Dependencies.Mullvad.mapLib)) + implementation(project(Dependencies.Mullvad.vpnService)) // Play implementation playImplementation(project(Dependencies.Mullvad.billingLib)) @@ -326,6 +328,7 @@ dependencies { implementation(Dependencies.AndroidX.lifecycleRuntimeKtx) implementation(Dependencies.AndroidX.lifecycleViewmodelKtx) implementation(Dependencies.AndroidX.lifecycleRuntimeCompose) + implementation(Dependencies.Arrow.core) implementation(Dependencies.Compose.constrainLayout) implementation(Dependencies.Compose.foundation) implementation(Dependencies.Compose.material3) diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml index ae420539c803..0ab48b375dca 100644 --- a/android/app/lint-baseline.xml +++ b/android/app/lint-baseline.xml @@ -1,16 +1,5 @@ - - - - - + diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 7d80dc6e39ff..b72bed1d0536 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -18,3 +18,16 @@ -dontwarn org.joda.time.** -keep class org.joda.time.** { *; } -keep interface org.joda.time.** { *; } + +# grpc +-keep class io.grpc.okhttp.OkHttpChannelBuilder { *; } +-keep class mullvad_daemon.management_interface.** { *; } +-keep class com.google.protobuf.Timestamp { *; } +-keepnames class com.google.protobuf.** { *; } +-dontwarn com.google.j2objc.annotations.ReflectionSupport +-dontwarn com.google.j2objc.annotations.RetainedWith +-dontwarn com.squareup.okhttp.CipherSuite +-dontwarn com.squareup.okhttp.ConnectionSpec +-dontwarn com.squareup.okhttp.TlsVersion + + 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 2adfa22220bd..0218e06afd80 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() 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 baeb5902d7a9..915db8243887 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 f9c7ec2143b2..bcb3908faed3 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 = 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 e79c5a2fe7de..ee347c246a0a 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 cbd6ae09d712..3128bbc508f1 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 0641998f9bd1..5fe812cd4427 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 851866818b98..98c87114fbaa 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 59515505506b..4f4db0a529b6 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 da9ed6099711..bdcb7969978a 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 f44441b53621..5e5730977766 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 c57c5c3f6219..b3cfd7972f70 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 f54944356f91..7dc378261d2f 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 b3ac57d95c5f..d8159dafd042 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 28651c385202..4fcee479d612 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 471e39c38f28..ca7a01a0a950 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 d8711b4b6113..d60a7b100b22 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 0337d1200d06..f6f60cdd1fab 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,14 +19,14 @@ android:required="false" /> - @@ -51,6 +51,9 @@ + + + @@ -89,10 +91,9 @@ --> + android:label="@string/toggle_vpn" + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> 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 4b34886c34b0..04ccb1cddc14 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 3815a3bb4696..8ca896cd73c9 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 65a3399631e7..529a310919b7 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 1029cfada019..000000000000 --- 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 cd5a08edbfb4..cdb4825150a7 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 3d52aca80c33..9c429757fb84 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 d2dcf1e86386..6dfd8f3eb1ce 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 d949f2a70827..0ea18d0b48f9 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 a2d7cc74c140..d1903b75d5c8 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 +) { 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 +) { 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 9ddee73e22d0..206c90ab7b9b 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) = Create(name, locations) + data class Delete(val id: CustomListId) : CustomListAction { + fun not(name: CustomListName, locations: List) = Create(name, locations) } @Parcelize - data class Create(val name: CustomListName, val locations: List = emptyList()) : - CustomListAction, Parcelable { - fun not(customListId: String) = Delete(customListId) + data class Create(val name: CustomListName, val locations: List) : + CustomListAction { + fun not(customListId: CustomListId) = Delete(customListId) } @Parcelize data class UpdateLocations( - val customListId: String, - val locations: List = emptyList() + val id: CustomListId, + val locations: List = emptyList() ) : CustomListAction { - fun not(locations: List): UpdateLocations = - UpdateLocations(customListId = customListId, locations = locations) + fun not(locations: List): 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 14cba09b44d0..000000000000 --- 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 000000000000..d83cd4c76de8 --- /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, + 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 000000000000..45eb76cc85a3 --- /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 a081b9f07915..9774cc27fbeb 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 b3a0ece5773e..bb4339a1f799 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 ed41d25f40ff..a913368de579 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 585855cb1d13..4e03ebf4ae2f 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 18a88fdf7910..79fdec7b9dfc 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,58 +11,12 @@ 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, 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 94dc40a17525..dbc510b00920 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 99501d1f4d87..b8ea96fc72a3 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 98f2007bc039..90e82e1fbf33 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, - locationCode: String = "" + backNavigator: ResultBackNavigator, + 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 236dedec6a36..e9718d7c24ee 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, - customListId: String, - name: String + navigator: ResultBackNavigator, + 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 7ac1469f0943..5b76023a7e4d 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, + resultNavigator: ResultBackNavigator, 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 9f46ee1d5aef..c01ceab7f8c1 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, - customListId: String, - initialName: String + backNavigator: ResultBackNavigator, + 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 4644a1aa952a..c9276c5c0937 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() +fun MtuDialog(mtuInitial: Mtu?, navigator: ResultBackNavigator) { + val viewModel = koinViewModel(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 88eb68284949..7034e67a91b6 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 a0270989cf58..b8592c1acb09 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, device: Device) { +fun RemoveDeviceConfirmationDialog(navigator: ResultBackNavigator, 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 c90c22ead46e..46111ebf8cce 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 44901ce65649..6640984a0f03 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, ) : Parcelable @@ -57,7 +58,7 @@ data class WireguardCustomPortNavArgs( @Composable fun WireguardCustomPortDialog( navArg: WireguardCustomPortNavArgs, - backNavigator: ResultBackNavigator, + backNavigator: ResultBackNavigator, ) { WireguardCustomPortDialog( initialPort = navArg.customPort, @@ -69,12 +70,14 @@ fun WireguardCustomPortDialog( @Composable fun WireguardCustomPortDialog( - initialPort: Int?, + initialPort: Port?, allowedPortRanges: List, - 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 a3329b1248b2..7de2e97fbb09 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 03e943400623..5af5e4305d70 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() 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 8a418c17aac2..000000000000 --- 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 e85939c51cb2..e8a3706b6687 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 000000000000..405c2b1a4dfc --- /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> { + 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 000000000000..817843145270 --- /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 000000000000..efc0da1fb5e1 --- /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 { + override val values: Sequence = 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 000000000000..c0cae0128f5b --- /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> { + 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 000000000000..afaf81ac5567 --- /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, + 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 000000000000..26ea64418595 --- /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> { + 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 af6f8e992f55..4d19095a6e59 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 fc13e053b8f1..94b5ef3b5b85 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 +) { 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 3bd924a18990..fc5fc62c3d9b 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, + backNavigator: ResultBackNavigator, discardChangesResultRecipient: ResultRecipient, - 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 20a92132f17d..b039f838a2bd 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 + editCustomListResultRecipient: ResultRecipient ) { val viewModel = koinViewModel() 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, - openCustomList: (RelayItem.CustomList) -> Unit + customLists: List, + 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 e6402fc8bd79..e781c12de917 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 +) { + 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 + confirmRemoveResultRecipient: + ResultRecipient ) { - val viewModel = koinViewModel() + val viewModel = + koinViewModel( + 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 9186e639c56e..0deadd545c88 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, - customListId: String, - confirmDeleteListResultRecipient: - ResultRecipient + backNavigator: ResultBackNavigator, + customListId: CustomListId, + confirmDeleteListResultRecipient: ResultRecipient ) { val viewModel = koinViewModel(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 bcd42d7c0ce8..f58b28eacaa8 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 508fcf67f34a..e2ee4cc240f0 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() + val permissionVm = koinViewModel() 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 c5c99c62f543..d557558a6022 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 387170e8e0a3..7ae7a464fcb1 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 4476b8064a14..7d861ea717bd 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, + backNavigator: ResultBackNavigator, + createCustomListDialogResultRecipient: ResultRecipient, editCustomListNameDialogResultRecipient: - ResultRecipient, - deleteCustomListDialogResultRecipient: - ResultRecipient, + ResultRecipient, + deleteCustomListDialogResultRecipient: ResultRecipient, updateCustomListResultRecipient: - ResultRecipient + ResultRecipient ) { val vm = koinViewModel() 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, - 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, - selectedItem: RelayItem?, + countries: List, + 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, - 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 , R : CustomListResult> ResultRecipient +private fun , R : CustomListSuccess> ResultRecipient .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, - 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 33b8419b9cdf..7f9542f22a8b 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 c355eb640596..a9b7873a2f5e 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 3e5a973d938e..05eb72c14291 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 5b4c9071032f..7eee7c13984d 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, - customWgPortResult: ResultRecipient + dnsDialogResult: ResultRecipient, + customWgPortResult: ResultRecipient, + mtuDialogResult: ResultRecipient, ) { val vm = koinViewModel() 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 8dcbea035086..29bc0c33065e 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 4a1c41e56266..910bdaa17f4f 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?, 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 43052702bdd2..255e0bf5615a 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 7c9c5aedec6d..f207d8535900 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 = emptyList(), + val availableLocations: List = emptyList(), val selectedLocations: Set = 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 f055bf95d2bd..63e3167881ed 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 = emptyList()) : - CustomListsUiState + data class Content(val customLists: List = 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 000000000000..000fc13f4aa0 --- /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 e539dbafc6bb..c5c2d5fab096 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, - 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, + ) : 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 000000000000..9e6bcdecf8c5 --- /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 9b564bb4072c..fa583e6fb99d 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) : - EditCustomListState + data class Content( + val id: CustomListId, + val name: CustomListName, + val locations: List + ) : 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 ad301877c4d0..52ef7445b087 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.toNullableOwnership(): Ownership? = when (this) { - is Constraint.Any -> null + Constraint.Any -> null is Constraint.Only -> this.value } fun Ownership?.toOwnershipConstraint(): Constraint = when (this) { - null -> Constraint.Any() + null -> Constraint.Any else -> Constraint.Only(this) } fun Constraint.toSelectedProviders(allProviders: List): List = 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.toConstraintProviders(allProviders: List): Constraint = 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 82f69e538008..0babd243daa9 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 d72e01519494..6e195d40d827 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 664f03ce40e8..0ef8dfb9c1c9 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 747e21d91c98..79f434aad1a0 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, val customLists: List, - val countries: List, - val selectedItem: RelayItem? + val countries: List, + 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 7eac74a40aea..000000000000 --- 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 c143dda0e837..925dc33aa9a4 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 75abbc7cefa2..dd9802db2c44 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 = Constraint.Any(), + selectedWireguardPort: Constraint = Constraint.Any, customWireguardPort: Constraint.Only? = null, availablePortRanges: List = 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 e43cf6bb9887..02e8217172a2 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 6c5e80d6ede4..ce8f9989bbc0 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 3581d1d0b41d..000000000000 --- 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, - 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 000000000000..13817db4bc6d --- /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() { + 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 3e5b7e16187e..1dcbc302efbe 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 d58107c713f2..8efe66085f46 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 000000000000..755e0767213a --- /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 000000000000..d116d929b401 --- /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 fe02cf5b7a1f..b6dba8f74ed9 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 { 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 041011736675..d15f83da0c72 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 ad668ed9e88c..2a7eeddb696b 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 +fun CustomList.toRelayItemCustomList( + relayCountries: List ): 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.toRelayItemLists( - relayCountries: List -): List = this.map { it.toRelayItemCustomList(relayCountries) } - fun List.filterOnSearchTerm(searchTerm: String) = if (searchTerm.length >= MIN_SEARCH_LENGTH) { this.filter { it.name.contains(searchTerm, ignoreCase = true) } @@ -28,7 +22,9 @@ fun List.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.getById(id: CustomListId) = this.find { it.id == id } -fun List.getById(id: String) = this.find { it.id == id } +fun List.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 c10397670050..000000000000 --- 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 af4a0084d293..000000000000 --- 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 { - 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 - ) : 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 - ) : 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 3f138dee29dc..a3758b25fef9 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 { 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 { +fun RelayItem.Location.children(): List { 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 { +fun RelayItem.Location.descendants(): List { val children = children() return children + children.flatMap { it.descendants() } } -private fun RelayItem.hasOwnership(ownershipConstraint: Constraint): Boolean = +fun List.withDescendants(): List = + this + flatMap { it.descendants() } + +private fun RelayItem.Location.hasOwnership(ownershipConstraint: Constraint): 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): Boolean = +private fun RelayItem.Location.hasProvider(providersConstraint: Constraint): 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, - providers: Constraint -): 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, providers: Constraint @@ -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, providers: Constraint -): 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, providers: Constraint -): 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, providers: Constraint -): 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 e469aec11816..000000000000 --- 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, - val allCountries: List, - val filteredCountries: List, - 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 78b3732734ad..069f0e1a088a 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 { - val relayCountries = - this.countries - .map { country -> - val cities = mutableListOf() - val relayCountry = RelayItem.Country(country.name, country.code, false, cities) - - for (city in country.cities) { - val relays = mutableListOf() - 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.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.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.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.findItemForGeographicLocationConstraint( * expanded If a relay is matched, its parents are added and expanded and itself is also added. */ @Suppress("NestedBlockDepth") -fun List.filterOnSearchTerm( +fun List.filterOnSearchTerm( searchTerm: String, - selectedItem: RelayItem? -): List { + selectedItem: RelayItemId? +): List { return if (searchTerm.length >= MIN_SEARCH_LENGTH) { - val filteredCountries = mutableMapOf() + val filteredCountries = mutableMapOf() this.forEach { relayCountry -> - val cities = mutableListOf() + val cities = mutableListOf() // 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() + val relays = mutableListOf() // 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.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.filterOnSearchTerm( } } -private fun List.filterValidRelays(): List = filter { - it.isWireguardRelay -} - /** Expand the parent(s), if any, for the current selected item */ -private fun List.expandItemForSelection( - selectedItem: RelayItem? -): List { - 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.expandItemForSelection( + selectedItem: RelayItemId? +): List { + 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.getRelayItemsByCodes(codes: List): List = - this.filter { codes.contains(it.code) } + - this.flatMap { it.descendants() }.filter { codes.contains(it.code) } +fun List.getRelayItemsByCodes( + codes: List +): List = + 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/repository/AccountRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt deleted file mode 100644 index 369f3e8fee17..000000000000 --- 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 = - messageHandler - .events() - .map { it.result } - .shareIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed()) - - val accountExpiryState: StateFlow = - messageHandler - .events() - .map { it.expiry } - .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, AccountExpiry.Missing) - - val accountHistory: StateFlow = - messageHandler - .events() - .map { it.history } - .onStart { fetchAccountHistory() } - .stateIn(CoroutineScope(dispatcher), SharingStarted.Lazily, AccountHistory.Missing) - - private val loginEvents: SharedFlow = - messageHandler - .events() - .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 0832f434a56a..fd67a6c17a8f 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?> = + managementService.settings + .mapNotNull { it.customLists } + .stateIn(CoroutineScope(dispatcher), SharingStarted.Eagerly, null) - return if (result) { - messageHandler.events().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().first().result - } else { - UpdateCustomListResult.Error(CustomListsError.OtherError) - } + suspend fun updateCustomListName( + id: CustomListId, + name: CustomListName + ): Either = either { + val customList = getCustomListById(id).bind() + updateCustomList(customList.copy(name = name)) + .mapLeft(UpdateCustomListNameError::from) + .bind() } - suspend fun updateCustomListLocationsFromCodes( - id: String, - locationCodes: List - ): 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 - ): 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 + ): Either = 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 = + 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 4fa211c8740b..000000000000 --- 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.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 = - 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 0751d0b1f784..decff575f882 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 000000000000..9251cac65c96 --- /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> = + managementService.settings + .map { settings -> settings.relaySettings.relayConstraints.ownership } + .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), Constraint.Any) + + val selectedProviders: StateFlow> = + managementService.settings + .map { settings -> settings.relaySettings.relayConstraints.providers } + .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), Constraint.Any) + + suspend fun updateSelectedOwnershipAndProviderFilter( + ownership: Constraint, + providers: Constraint + ) = managementService.setOwnershipAndProviders(ownership, providers) + + suspend fun updateSelectedOwnership(value: Constraint) = + managementService.setOwnership(value) + + suspend fun updateSelectedProviders(value: Constraint) = + 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 000000000000..7d9846c31bb9 --- /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> = + managementService.relayCountries.stateIn( + CoroutineScope(dispatcher), + SharingStarted.WhileSubscribed(), + emptyList() + ) + + val wireguardEndpointData: StateFlow = + managementService.wireguardEndpointData.stateIn( + CoroutineScope(dispatcher), + SharingStarted.WhileSubscribed(), + defaultWireguardEndpointData() + ) + + val selectedLocation: StateFlow> = + managementService.settings + .map { it.relaySettings.relayConstraints.location } + .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), Constraint.Any) + + val portRanges: Flow> = + 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 835cab4710a0..ddc6a6f529c7 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?> = - 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 7d61feaf0cf2..e2469f626f67 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 = - 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, 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) -> List) { - updateDnsSettings { dnsOptions -> - val newDnsList = ArrayList(update(dnsOptions.customOptions.addresses.map { it })) - dnsOptions.copy( - state = if (newDnsList.isEmpty()) DnsState.Default else DnsState.Custom, - customOptions = - CustomDnsOptions( - addresses = newDnsList, - ) - ) - } - } + 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().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 000000000000..383d52b6ce71 --- /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 2bfe5d5d9dea..56494420503e 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() - .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 ca5fe50aed9c..c0ab7dd0ed00 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 9210e5809b2b..000000000000 --- 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(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 000000000000..74b67348b323 --- /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 = + 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 2c7ea5385cc1..000000000000 --- 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>() - - init { - eventDispatcher.registerHandler(Event.AuthToken::class) { event -> - synchronized(this@AuthTokenCache) { fetchQueue.poll()?.complete(event.token ?: "") } - } - } - - suspend fun fetchAuthToken(): String { - val authToken = CompletableDeferred() - - 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 bbc267b2fac5..000000000000 --- 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.Disconnected()) - val onUiStateChange = EventNotifier(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 bfad798e08eb..000000000000 --- 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 000000000000..28819f7aa0f7 --- /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 841c9e0c59d2..000000000000 --- 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 = - messageHandler - .events() - .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, - providers: Constraint - ) { - 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 8aabe6c9f56c..000000000000 --- 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() - - 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 a9094ed011ee..000000000000 --- 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 4e1d773f1e42..315243a77ced 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.Disconnected) + MutableStateFlow(ServiceConnectionState.Unbound) val connectionState = _connectionState.asStateFlow() - // TODO: Remove after refactoring fragments to support flow. - @Deprecated(message = "Use connectionState") - val serviceNotifier = EventNotifier(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 events(klass: KClass): Flow { - 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 31ac1befdc40..000000000000 --- 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 ca868e5cfacd..77478446732f 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 e2ccc2e47069..000000000000 --- 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(null) - val settingsNotifier = EventNotifier(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 666d77218401..000000000000 --- 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()) { _, _, 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) -> 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 fbf082ba3c9a..000000000000 --- 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) { - private val activeSubmissions = - mutableMapOf>() - - 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() - - 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 143a01d719b3..000000000000 --- 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) { - 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 a4961bafe7d0..65822788cb31 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> = - 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 000000000000..f79c0421f692 --- /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> = + 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 000000000000..265c1272278e --- /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.filterOnOwnershipAndProvider( + ownership: Constraint, + providers: Constraint + ) = 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 000000000000..67bc12cc9286 --- /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 = + connectionProxy.tunnelState + .filterIsInstance() + .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 628cc555ec66..06d26a76e8fb 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 88ec42f986e7..a86124c8a9bf 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 = - messageHandler - .events() - .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 = - 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 2b104cda3985..000000000000 --- 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> = - 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 f480e6a23a66..000000000000 --- 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, - providers: Constraint - ) { - relayListListener.updateSelectedOwnershipAndProviderFilter(ownership, providers) - } - - fun selectedOwnership(): Flow> = - settingsRepository.settingsUpdates.map { settings -> - settings?.relaySettings?.relayConstraints()?.ownership ?: Constraint.Any() - } - - fun selectedProviders(): Flow> = - settingsRepository.settingsUpdates.map { settings -> - settings?.relaySettings?.relayConstraints()?.providers ?: Constraint.Any() - } - - fun availableProviders(): Flow> = - 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 b4197fe7b7a5..000000000000 --- 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 = - 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 = relayListWithSelection().map { it.selectedItem } - - fun fullRelayList(): Flow> = - relayListWithSelection().map { it.allCountries } - - fun customLists(): Flow> = - relayListWithSelection().map { it.customLists } - - fun fetchRelayList() { - relayListListener.fetchRelayList() - } - - private fun findSelectedRelayItem( - relaySettings: RelaySettings?, - relayCountries: List, - customLists: List - ): 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 000000000000..a37e33492dec --- /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, + customLists: List + ): 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, + 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 dec794c86c8e..ce0878c5176e 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> = - 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 = - 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 28496c46398e..b7dc50a24173 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 16c86c0d59e3..180381f7718a 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 { + suspend fun performAction( + action: CustomListAction + ): Either { return when (action) { is CustomListAction.Create -> { performAction(action) @@ -33,86 +41,101 @@ class CustomListActionUseCase( } } - suspend fun performAction(action: CustomListAction.Rename): Result = - 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 = + customListsRepository + .updateCustomListName(action.id, action.newName) + .map { Renamed(undo = action.not()) } + .mapLeft(::RenameError) + + suspend fun performAction( + action: CustomListAction.Create + ): Either = 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 = - 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 { - 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 = 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 { - 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 = 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 = - 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 000000000000..d28bfe1d5509 --- /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> = + 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 07c37f733332..000000000000 --- 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 000000000000..015aa8ab4f5a --- /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 ae79c1364f5e..000000000000 --- 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 13b8f84599f9..000000000000 --- 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 DeviceState.addDebounceForStates(delay: Long, vararg states: KClass): 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 b3a8727df95c..fbe44a5feabe 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 Flow.flatMapReadyConnectionOrDefault( - default: Flow, - transform: (value: ServiceConnectionState.ConnectedReady) -> Flow -): Flow { - return flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - transform.invoke(state) - } else { - default - } - } -} - -fun callbackFlowFromNotifier(notifier: EventNotifier) = callbackFlow { - val handler: (T) -> Unit = { value -> trySend(value) } - notifier.subscribe(this, handler) - awaitClose { notifier.unsubscribe(this) } -} inline fun combine( flow: Flow, @@ -110,9 +84,6 @@ inline fun combine( } } -suspend inline fun Deferred.awaitWithTimeoutOrNull(timeout: Long) = - withTimeoutOrNull(timeout) { await() } - fun Deferred.getOrDefault(default: T) = try { getCompleted() @@ -150,7 +121,3 @@ suspend inline fun Flow.retryWithExponentialBackOff( } class ExceptionWrapper(val item: Any) : Throwable() - -suspend fun Flow.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 b978caad5333..d908f44158f6 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 a1a1d54b3671..000000000000 --- 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 similarity index 53% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortConstraintExtensions.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PortExtensions.kt index 0f0708707eb8..ac93b60d001d 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.hasValue(value: Int) = when (this) { @@ -16,8 +17,13 @@ fun Constraint.isCustom() = is Constraint.Only -> !WIREGUARD_PRESET_PORTS.contains(this.value.value) } -fun Constraint.toValueOrNull() = +fun Constraint.toPortOrNull() = when (this) { is Constraint.Any -> null - is Constraint.Only -> this.value.value + is Constraint.Only -> this.value } + +fun Port.inAnyOf(portRanges: List): Boolean = + portRanges.any { portRange -> this in portRange } + +fun List.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 7b7fa8b104ac..000000000000 --- 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.isPortInValidRanges(port: Int) = - this.any { portRange -> portRange.from <= port && portRange.to >= port } - -fun List.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 41e83465a185..f0cf46978b8c 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 d39104e67a98..d8c310b029d2 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 { 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 e3c0b226dd78..0a497c22f66e 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() @@ -35,13 +31,13 @@ class AccountViewModel( val uiState: StateFlow = 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 6b17592b8ef3..7b15e74a0e04 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 bebb0d6e42c0..c98ce4fa59a8 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() @@ -62,124 +54,114 @@ class ConnectViewModel( val uiSideEffect = merge(_uiSideEffect.receiveAsFlow(), outOfTimeEffect(), revokedDeviceEffect()) - private val _shared: SharedFlow = - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - flowOf(state.container) - } else { - emptyFlow() - } - } - .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) - + @OptIn(FlowPreview::class) val uiState: StateFlow = - _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 = - callbackFlowFromNotifier(this.onUiStateChange) - - private fun ConnectionProxy.tunnelRealStateFlow(): Flow = - callbackFlowFromNotifier(this.onStateChange) - - private fun ConnectionProxy.lastKnownDisconnectedLocation(): Flow = - tunnelRealStateFlow() - .filterIsInstance() - .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 f58916cd66c6..043f9895981f 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(1, BufferOverflow.DROP_OLDEST) val uiSideEffect = _uiSideEffect.receiveAsFlow() - private val _error = MutableStateFlow(null) + private val _error = MutableStateFlow(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 cdbcebbb8369..581c11c39716 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(replay = 1, extraBufferCapacity = 1) val uiSideEffect: SharedFlow = _uiSideEffect - private val _initialLocations = MutableStateFlow>(emptySet()) - private val _selectedLocations = MutableStateFlow?>(null) + private val _initialLocations = MutableStateFlow>(emptySet()) + private val _selectedLocations = MutableStateFlow?>(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 = + private fun availableLocations(): List = (uiState.value as? CustomListLocationsUiState.Content.Data)?.availableLocations ?: emptyList() - private fun Set.deselectParents(relayItem: RelayItem): Set { + private fun Set.deselectParents( + relayItem: RelayItem.Location + ): Set { 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.calculateLocationsToSave(): List { + private fun Set.calculateLocationsToSave(): List { // 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.selectChildren(): Set = - (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 79a2ba61c65b..3689ad7fc8b4 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 e3c7f45664ec..79c2a133c20d 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(Channel.BUFFERED) val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val _error = MutableStateFlow(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 7b6c092ded4e..d2c87806063f 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>(emptyList()) + private val loadingDevices = MutableStateFlow>(emptySet()) + private val deviceList = MutableStateFlow>(emptyList()) + private val loading = MutableStateFlow(true) + private val error = MutableStateFlow(null) private val _uiSideEffect = Channel() val uiSideEffect = _uiSideEffect.receiveAsFlow() - private var cachedDeviceList: List? = 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 = + 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 4cb02c748ff9..8d526ba9b2d2 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 4703e1cbf99b..cc377b0bab39 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 9a8d3d2f6236..7c45bed0d7de 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(1, BufferOverflow.DROP_OLDEST) val uiSideEffect = _uiSideEffect.receiveAsFlow() - private val _error = MutableStateFlow(null) + private val _error = MutableStateFlow(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 81232e63d596..adfacceb4e9e 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 0d39ffa6250f..6e139f4d7f44 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() 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 = 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 9af9d700ce8c..e56802117705 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() val uiSideEffect = _uiSideEffect.receiveAsFlow() + private val _mutableAccountHistory: MutableStateFlow = 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 = - _uiState.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), LoginUiState.INITIAL) - fun clearAccountHistory() = accountRepository.clearAccountHistory() + val uiState: StateFlow = + _uiState + .onStart { + viewModelScope.launch { + _mutableAccountHistory.update { accountRepository.fetchAccountHistory() } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), LoginUiState.INITIAL) + + 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() - .map { it.expiryDateTime.isBeforeNow } - .first() - } - delay(1000) - val isOutOfTime = isOutOfTimeDeferred.getOrDefault(false) - if (isOutOfTime) { - _uiSideEffect.send(LoginUiSideEffect.NavigateToOutOfTime) - } else { - _uiSideEffect.send(LoginUiSideEffect.NavigateToConnect) - } - } - 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())) + result + .await() + .fold( + { it.toUiState() }, + { + onSuccessfulLogin() + Success } - } - 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 4b6e8ed7679d..9d1a17207c7b 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 = + combine(_mtuInput, _isValidMtu, ::createState) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + createState(_mtuInput.value, _isValidMtu.value) + ) + private val _uiSideEffect = Channel() 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 eff31be0eecb..f8863f243375 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 = 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 3c70717e47b6..66e9a719eb67 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 = - 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 4afa12219af1..f7bbd73907c6 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 27edb954578b..2ab757bd78bc 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() 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, selectedOwnership: Ownership? ): List = - 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.filterOnOwnershipAndProvider( + ownership: Constraint, + providers: Constraint + ): List = 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 5a77727b1888..069eda8dc8f3 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 b836894cb739..5150af274786 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 = - 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 83442059daad..bd34161e2c92 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 833117c046a6..b43e046e570b 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?>(null) private val showSystemApps = MutableStateFlow(false) - private val _shared: SharedFlow = - 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 = + 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() } - } - - 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 bc16662f0099..89dde0decb87 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 = emptySet(), + val excludedApps: Set = emptySet(), val allApps: List? = 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 8022332650ba..3d67b42bd1c7 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.Default) private val voucherInput = MutableStateFlow("") - private val _shared: SharedFlow = - 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 000000000000..cd9a52efa19d --- /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 = + 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 ba487c5a4057..864d402fb30e 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?>(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.asInetAddressList(): List { @@ -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 91866d5cc2ac..f8e4f0b79916 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 0f6b23a30670..208c9d871b12 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 = - 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 9767d3930a2f..c9cfb0e75c25 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 eb66c2d4f907..000000000000 --- 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 9c2ac615c31e..4b8a524e5cf5 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 settingsFlow: MutableStateFlow = MutableStateFlow(null) - private val relayListFlow: MutableStateFlow = MutableStateFlow(mockk()) + private val mockManagementService: ManagementService = mockk() + private lateinit var customListsRepository: CustomListsRepository + + private val settingsFlow: MutableStateFlow = 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 null when id does not matches custom list in settings`() { + 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 `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() } 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() } 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()) } 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() } 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() } 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() } 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 000000000000..c8027240a264 --- /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(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 = 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 = + 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 8fd21c5533bc..000000000000 --- 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>() - 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>(), 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>(), 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 81b518199c38..000000000000 --- 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>(), 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>(), 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 39bfae63d884..11d574b663e0 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.Missing) + private val accountExpiry = MutableStateFlow(null) private lateinit var accountExpiryNotificationUseCase: AccountExpiryNotificationUseCase @BeforeEach @@ -30,7 +30,7 @@ class AccountExpiryNotificationUseCaseTest { MockKAnnotations.init(this) val accountRepository = mockk() - 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 4dfb95768bd2..bb19d42d13af 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()) + @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 = 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) - val exception = result.exceptionOrNull() - assertIs(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) - val exception = result.exceptionOrNull() - assertIs(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 = arrayListOf(mockLocation) + val mockLocation: GeoLocationId.Country = mockk() + val mockLocations: List = 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 = - 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 = + 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 691bb9913135..b55da83f5199 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( + MutableStateFlow( 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 326e1834457f..088c9a435c85 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 - private lateinit var expiry: MutableStateFlow + private lateinit var events: Channel + private lateinit var expiry: MutableStateFlow 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() } 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 82126099d83c..a2e8db36fdbe 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.Disconnected) private lateinit var tunnelStateNotificationUseCase: TunnelStateNotificationUseCase - private val eventNotifierTunnelUiState = EventNotifier(TunnelState.Disconnected()) + private val tunnelState = MutableStateFlow(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 fbc677b4614b..1630ed757fb4 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.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().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 61758c2d1d3d..362fc457f530 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 = MutableStateFlow(DeviceState.Initial) + private val deviceState: MutableStateFlow = MutableStateFlow(null) private val paymentAvailability = MutableStateFlow(null) private val purchaseResult = MutableStateFlow(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 46126f5ad8a9..7888f02a4d18 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 7e207a15a450..2de7724c6946 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.Disconnected) - private val versionInfo = - MutableStateFlow( - VersionInfo( - currentVersion = null, - upgradeVersion = null, - isOutdated = false, - isSupported = true - ) - ) - private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing) - private val deviceState = MutableStateFlow(DeviceState.Initial) + MutableStateFlow(ServiceConnectionState.Unbound) + private val accountExpiryState = MutableStateFlow(null) + private val device = MutableStateFlow(null) private val notifications = MutableStateFlow>(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.Disconnected()) - private val eventNotifierTunnelRealState = - EventNotifier(TunnelState.Disconnected()) - // Flows - private val selectedRelayItemFlow = MutableStateFlow(null) + private val tunnelState = MutableStateFlow(TunnelState.Disconnected()) + private val selectedRelayItemFlow = MutableStateFlow(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().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 7b14db3ffb41..83675794f569 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()) - } 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()) - } 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( 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()) - } 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()) - } 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 d21789d36f35..321e2d53b596 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>(emptyList()) - private val customListFlow = MutableStateFlow>(emptyList()) + private val relayListFlow = MutableStateFlow>(emptyList()) + private val selectedLocationsFlow = MutableStateFlow>(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() - 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() - 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()) - } 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()) - } 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 612ae38a3afd..ed615fe0affc 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 = mockk() + val customLists: List = 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 9f7f3f1f0bda..6356719c42f8 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()) - } 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 11244e9df4ec..b63f59b302be 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.Disconnected) + @MockK private lateinit var mockConnectionProxy: ConnectionProxy private lateinit var viewModel: DeviceRevokedViewModel + private val tunnelStateFlow = MutableSharedFlow() + @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().apply { - val eventNotifierMock = - mockk>().apply { - every { callbackFlowFromSubscription(any()) } returns - MutableStateFlow(TunnelState.Connected(mockk(), mockk())) - } - val mockedConnectionProxy = - mockk().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().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().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 e9592d0336c8..29afc8de0d77 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()) - } 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()) - } 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()) - } 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 cbc5ff1c50dc..c3f233846a9a 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 fda88bff797d..5333a481be54 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.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 = - 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 3271fe57eb7b..d6eee6d94169 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.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(loginState) + assertIs(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 ReceiveTurbine.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 e489c01d4116..bd26effe821a 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.Disconnected) - private val accountExpiryStateFlow = MutableStateFlow(AccountExpiry.Missing) - private val deviceStateFlow = MutableStateFlow(DeviceState.Initial) + MutableStateFlow(ServiceConnectionState.Unbound) + private val accountExpiryStateFlow = MutableStateFlow(null) + private val accountStateFlow = MutableStateFlow(null) private val paymentAvailabilityFlow = MutableStateFlow(null) private val purchaseResultFlow = MutableStateFlow(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.Disconnected()) + private val tunnelState = MutableStateFlow(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 9be365e7aeb9..17394c39db2f 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 5d0ab5f604e5..80f62dba4a55 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.Any()) - private val selectedProvider = MutableStateFlow>(Constraint.Any()) - private val allProvider = MutableStateFlow>(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>(emptyList()) + private val selectedOwnership = MutableStateFlow>(Constraint.Any) + private val selectedProviders = MutableStateFlow>(Constraint.Any) + private val selectedRelayItemFlow = MutableStateFlow>(Constraint.Any) + private val filteredRelayList = MutableStateFlow>(emptyList()) + private val customRelayListItems = MutableStateFlow>(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(mockk(), mockk()) - val mockCustomList = listOf(mockk(relaxed = true)) - val selectedItem: RelayItem = mockk() + val mockCountries = listOf(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(mockk(relaxed = true)) - val mockCountries = listOf(mockk(), mockk()) - val selectedItem: RelayItem? = null + val mockCountries = listOf(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(mockk(relaxed = true)) - val mockCountries = listOf(mockk(), mockk()) - val selectedItem: RelayItem? = null - val mockRelayList: List = mockk(relaxed = true) + val mockCountries = listOf(mockk(), mockk()) + val selectedItem: RelayItemId? = null + val mockRelayList: List = 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(mockk(relaxed = true)) - val mockCountries = emptyList() - val selectedItem: RelayItem? = null - val mockRelayList: List = mockk(relaxed = true) + val mockCountries = emptyList() + val selectedItem: RelayItemId? = null + val mockRelayList: List = 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 = 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>(), - mockSelectedProviders - ) - } + coVerify { mockRelayListFilterRepository.updateSelectedOwnership(Constraint.Any) } } @Test fun `removeProviderFilter should invoke use case with Constraint Any Provider`() = runTest { // Arrange val mockSelectedOwnership: Constraint = 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>() - ) - } + 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()) - } 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 16e89ac20b7d..b39d4357de4e 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?>(null) - private val serviceConnectionState = - MutableStateFlow(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().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 0eace9ca434b..c76e2cd2787f 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.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.LoggedOut) - mockAppVersionInfoCache = - mockk().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 11b253e5eaa3..aa1ccc82f088 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() - private val mockedSplitTunneling = mockk() - private val mockedServiceConnectionManager = mockk() - private val mockedServiceConnectionContainer = mockk() + private val mockedSplitTunnelingRepository = mockk() private lateinit var testSubject: SplitTunnelingViewModel + private val excludedApps: MutableStateFlow> = MutableStateFlow(emptySet()) + private val enabled: MutableStateFlow = 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) -> 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) -> 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) -> 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) -> 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) -> 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) { 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 6934384643c3..ef3b34effc0a 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.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(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 11992c40c0e8..29a6c764ba4c 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(null) private val portRangeFlow = MutableStateFlow(emptyList()) @@ -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(relaxed = true) + every { mockWireguardTunnelOptions.mtu } returns Mtu(0) + every { mockSettings.relaySettings } returns mockk(relaxed = true) viewModel.uiState.test { assertEquals(defaultResistantState, awaitItem().quantumResistant) @@ -120,7 +118,7 @@ class VpnSettingsViewModelTest { // Arrange val expectedPort: Constraint = 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>(awaitItem().selectedWireguardPort) + assertIs(awaitItem().selectedWireguardPort) mockSettingsUpdate.value = mockSettings assertEquals(expectedPort, awaitItem().customWireguardPort) assertEquals(expectedPort, awaitItem().selectedWireguardPort) @@ -144,14 +151,15 @@ class VpnSettingsViewModelTest { // Arrange val wireguardPort: Constraint = 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 91554193bcf5..3113450276d2 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.Disconnected) - private val deviceStateFlow = MutableStateFlow(DeviceState.Initial) - private val accountExpiryStateFlow = MutableStateFlow(AccountExpiry.Missing) + MutableStateFlow(ServiceConnectionState.Unbound) + private val deviceStateFlow = MutableStateFlow(DeviceState.LoggedOut) + private val accountExpiryStateFlow = MutableStateFlow(null) private val purchaseResultFlow = MutableStateFlow(null) private val paymentAvailabilityFlow = MutableStateFlow(null) - // Service connections - private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() + // ConnectionProxy private val mockConnectionProxy: ConnectionProxy = mockk() // Event notifiers - private val eventNotifierTunnelUiState = EventNotifier(TunnelState.Disconnected()) + private val tunnelState = MutableStateFlow(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(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" } diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index 82fe6d70d7b0..b2417befba98 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -28,6 +28,8 @@ object Dependencies { "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.AndroidX.lifecycle}" const val lifecycleRuntimeCompose = "androidx.lifecycle:lifecycle-runtime-compose:${Versions.AndroidX.lifecycle}" + const val lifecycleService = + "androidx.lifecycle:lifecycle-service:${Versions.AndroidX.lifecycle}" const val espressoCore = "androidx.test.espresso:espresso-core:${Versions.AndroidX.espresso}" const val testCore = "androidx.test:core:${Versions.AndroidX.test}" @@ -40,6 +42,12 @@ object Dependencies { "androidx.test:orchestrator:${Versions.AndroidX.testOrchestrator}" } + object Arrow { + const val core = "io.arrow-kt:arrow-core:${Versions.Arrow.base}" + const val optics = "io.arrow-kt:arrow-optics:${Versions.Arrow.base}" + const val opticsKsp = "io.arrow-kt:arrow-optics-ksp-plugin:${Versions.Arrow.base}" + } + object Compose { const val constrainLayout = "androidx.constraintlayout:constraintlayout-compose:${Versions.Compose.constrainLayout}" @@ -62,6 +70,15 @@ object Dependencies { const val uiUtil = "androidx.compose.ui:ui-util:${Versions.Compose.base}" } + object Grpc { + const val grpcOkHttp = "io.grpc:grpc-okhttp:${Versions.Grpc.grpcVersion}" + const val grpcAndroid = "io.grpc:grpc-android:${Versions.Grpc.grpcVersion}" + const val grpcKotlinStub = "io.grpc:grpc-kotlin-stub:${Versions.Grpc.grpcKotlinVersion}" + const val protobufLite = "io.grpc:grpc-protobuf-lite:${Versions.Grpc.grpcVersion}" + const val protobufKotlinLite = + "com.google.protobuf:protobuf-kotlin-lite:${Versions.Grpc.protobufVersion}" + } + object Koin { const val core = "io.insert-koin:koin-core:${Versions.Koin.base}" const val android = "io.insert-koin:koin-android:${Versions.Koin.base}" @@ -76,6 +93,8 @@ object Dependencies { } object KotlinX { + const val coroutinesCore = + "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.kotlinx}" const val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.kotlinx}" const val coroutinesTest = @@ -88,12 +107,12 @@ object Dependencies { } object Mullvad { + const val daemonGrpc = ":lib:daemon-grpc" const val vpnService = ":service" const val tileService = ":tile" const val commonLib = ":lib:common" const val endpointLib = ":lib:endpoint" - const val ipcLib = ":lib:ipc" const val modelLib = ":lib:model" const val resourceLib = ":lib:resource" const val talpidLib = ":lib:talpid" @@ -102,6 +121,8 @@ object Dependencies { const val billingLib = ":lib:billing" const val paymentLib = ":lib:payment" const val mapLib = ":lib:map" + const val sharedLib = ":lib:shared" + const val intentLib = ":lib:intent-provider" } object Plugin { @@ -130,5 +151,6 @@ object Dependencies { const val playPublisher = "com.github.triplet.gradle:play-publisher:${Versions.Plugin.playPublisher}" const val playPublisherId = "com.github.triplet.play" + const val protobufId = "com.google.protobuf" } } diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index 606ed2e57547..37f0f2c029cd 100644 --- a/android/buildSrc/src/main/kotlin/Versions.kt +++ b/android/buildSrc/src/main/kotlin/Versions.kt @@ -34,6 +34,10 @@ object Versions { const val uiautomator = "2.3.0" } + object Arrow { + const val base = "1.2.3" + } + object Compose { const val destinations = "1.10.2" const val base = "1.6.3" @@ -42,6 +46,12 @@ object Versions { const val material3 = "1.2.1" } + object Grpc { + const val grpcVersion = "1.63.0" + const val grpcKotlinVersion = "1.4.1" + const val protobufVersion = "3.25.3" + } + object Plugin { // The androidAapt plugin version must be in sync with the android plugin version. // Required for Gradle metadata verification to work properly, see: @@ -49,6 +59,7 @@ object Versions { const val android = "8.3.0" const val androidAapt = "$android-10880808" const val playPublisher = "3.9.0" + const val protobuf = "0.9.4" const val dependencyCheck = "9.0.9" const val detekt = "1.23.5" const val gradleVersions = "0.51.0" diff --git a/android/config/baseline.xml b/android/config/baseline.xml index cccc81b70311..e79b02aa94c7 100644 --- a/android/config/baseline.xml +++ b/android/config/baseline.xml @@ -49,8 +49,8 @@ ConstructorParameterNaming:ExcludedProcessKt.kt$ExcludedProcessKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.ExcludedProcess.Builder ConstructorParameterNaming:ExcludedProcessListKt.kt$ExcludedProcessListKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.ExcludedProcessList.Builder ConstructorParameterNaming:GeoIpLocationKt.kt$GeoIpLocationKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.GeoIpLocation.Builder - ConstructorParameterNaming:GeographicLocationConstraintKt.kt$GeographicLocationConstraintKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.GeographicLocationConstraint.Builder - ConstructorParameterNaming:LocationConstraintKt.kt$LocationConstraintKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.LocationConstraint.Builder + ConstructorParameterNaming:GeoLocationIdKt.kt$GeoLocationIdKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.GeoLocationId.Builder + ConstructorParameterNaming:RelayItemIdKt.kt$RelayItemIdKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.RelayItemId.Builder ConstructorParameterNaming:LocationKt.kt$LocationKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.Location.Builder ConstructorParameterNaming:NewAccessMethodSettingKt.kt$NewAccessMethodSettingKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.NewAccessMethodSetting.Builder ConstructorParameterNaming:NormalRelaySettingsKt.kt$NormalRelaySettingsKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.NormalRelaySettings.Builder @@ -193,10 +193,10 @@ FunctionNaming:ExcludedProcessListKt.kt$ExcludedProcessListKt.Dsl.Companion$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _create( builder: mullvad_daemon.management_interface.ManagementInterface.ExcludedProcessList.Builder, ): Dsl FunctionNaming:GeoIpLocationKt.kt$GeoIpLocationKt.Dsl$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _build(): mullvad_daemon.management_interface.ManagementInterface.GeoIpLocation FunctionNaming:GeoIpLocationKt.kt$GeoIpLocationKt.Dsl.Companion$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _create( builder: mullvad_daemon.management_interface.ManagementInterface.GeoIpLocation.Builder, ): Dsl - FunctionNaming:GeographicLocationConstraintKt.kt$GeographicLocationConstraintKt.Dsl$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _build(): mullvad_daemon.management_interface.ManagementInterface.GeographicLocationConstraint - FunctionNaming:GeographicLocationConstraintKt.kt$GeographicLocationConstraintKt.Dsl.Companion$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _create( builder: mullvad_daemon.management_interface.ManagementInterface.GeographicLocationConstraint.Builder, ): Dsl - FunctionNaming:LocationConstraintKt.kt$LocationConstraintKt.Dsl$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _build(): mullvad_daemon.management_interface.ManagementInterface.LocationConstraint - FunctionNaming:LocationConstraintKt.kt$LocationConstraintKt.Dsl.Companion$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _create( builder: mullvad_daemon.management_interface.ManagementInterface.LocationConstraint.Builder, ): Dsl + FunctionNaming:GeoLocationIdKt.kt$GeoLocationIdKt.Dsl$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _build(): mullvad_daemon.management_interface.ManagementInterface.GeoLocationId + FunctionNaming:GeoLocationIdKt.kt$GeoLocationIdKt.Dsl.Companion$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _create( builder: mullvad_daemon.management_interface.ManagementInterface.GeoLocationId.Builder, ): Dsl + FunctionNaming:RelayItemIdKt.kt$RelayItemIdKt.Dsl$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _build(): mullvad_daemon.management_interface.ManagementInterface.RelayItemId + FunctionNaming:RelayItemIdKt.kt$RelayItemIdKt.Dsl.Companion$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _create( builder: mullvad_daemon.management_interface.ManagementInterface.RelayItemId.Builder, ): Dsl FunctionNaming:LocationKt.kt$LocationKt.Dsl$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _build(): mullvad_daemon.management_interface.ManagementInterface.Location FunctionNaming:LocationKt.kt$LocationKt.Dsl.Companion$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _create( builder: mullvad_daemon.management_interface.ManagementInterface.Location.Builder, ): Dsl FunctionNaming:NewAccessMethodSettingKt.kt$NewAccessMethodSettingKt.Dsl$@kotlin.jvm.JvmSynthetic @kotlin.PublishedApi internal fun _build(): mullvad_daemon.management_interface.ManagementInterface.NewAccessMethodSetting @@ -378,7 +378,7 @@ MaxLineLength:ConnectionConfigKt.kt$public inline MaxLineLength:CustomDnsOptionsKt.kt$CustomDnsOptionsKt.Dsl$@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class) MaxLineLength:CustomListKt.kt$CustomListKt.Dsl$public - MaxLineLength:CustomListKt.kt$CustomListKt.Dsl$values: kotlin.collections.Iterable<mullvad_daemon.management_interface.ManagementInterface.GeographicLocationConstraint> + MaxLineLength:CustomListKt.kt$CustomListKt.Dsl$values: kotlin.collections.Iterable<mullvad_daemon.management_interface.ManagementInterface.GeoLocationId> MaxLineLength:CustomListSettingsKt.kt$CustomListSettingsKt.Dsl$public MaxLineLength:CustomRelaySettingsKt.kt$public MaxLineLength:DaemonEventKt.kt$public @@ -396,8 +396,8 @@ MaxLineLength:ErrorStateKt.kt$public MaxLineLength:ExcludedProcessListKt.kt$ExcludedProcessListKt.Dsl$public MaxLineLength:ExcludedProcessListKt.kt$ExcludedProcessListKt.Dsl$values: kotlin.collections.Iterable<mullvad_daemon.management_interface.ManagementInterface.ExcludedProcess> - MaxLineLength:GeographicLocationConstraintKt.kt$GeographicLocationConstraintKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.GeographicLocationConstraint.Builder - MaxLineLength:LocationConstraintKt.kt$public + MaxLineLength:GeoLocationIdKt.kt$GeoLocationIdKt.Dsl$private val _builder: mullvad_daemon.management_interface.ManagementInterface.GeoLocationId.Builder + MaxLineLength:RelayItemIdKt.kt$public MaxLineLength:ManagementInterfaceGrpcKt.kt$ManagementServiceGrpcKt$public MaxLineLength:ManagementInterfaceGrpcKt.kt$ManagementServiceGrpcKt.ManagementServiceCoroutineImplBase$"Method mullvad_daemon.management_interface.ManagementService.AddSplitTunnelProcess is unimplemented" MaxLineLength:ManagementInterfaceGrpcKt.kt$ManagementServiceGrpcKt.ManagementServiceCoroutineImplBase$"Method mullvad_daemon.management_interface.ManagementService.ClearSplitTunnelProcesses is unimplemented" @@ -540,9 +540,9 @@ PackageNaming:ExcludedProcessKt.kt$package mullvad_daemon.management_interface PackageNaming:ExcludedProcessListKt.kt$package mullvad_daemon.management_interface PackageNaming:GeoIpLocationKt.kt$package mullvad_daemon.management_interface - PackageNaming:GeographicLocationConstraintKt.kt$package mullvad_daemon.management_interface + PackageNaming:GeoLocationIdKt.kt$package mullvad_daemon.management_interface PackageNaming:InetNetwork.kt$package net.mullvad.talpid.tun_provider - PackageNaming:LocationConstraintKt.kt$package mullvad_daemon.management_interface + PackageNaming:RelayItemIdKt.kt$package mullvad_daemon.management_interface PackageNaming:LocationKt.kt$package mullvad_daemon.management_interface PackageNaming:ManagementInterfaceGrpcKt.kt$package mullvad_daemon.management_interface PackageNaming:ManagementInterfaceKt.kt$package mullvad_daemon.management_interface diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 13a615352c3b..11faf12b982e 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -111,6 +111,11 @@ + + + + + @@ -139,6 +144,9 @@ + + + @@ -173,6 +181,9 @@ + + + @@ -1114,11 +1125,6 @@ - - - - - @@ -1297,14 +1303,6 @@ - - - - - - - - @@ -1395,6 +1393,14 @@ + + + + + + + + @@ -1628,11 +1634,6 @@ - - - - - @@ -2454,6 +2455,11 @@ + + + + + @@ -2479,6 +2485,11 @@ + + + + + @@ -2489,11 +2500,27 @@ + + + + + + + + + + + + + + + + @@ -2542,6 +2569,11 @@ + + + + + @@ -2552,6 +2584,24 @@ + + + + + + + + + + + + + + + + + + @@ -2824,16 +2874,16 @@ - - - - - + + + + + @@ -2850,6 +2900,14 @@ + + + + + + + + @@ -2971,6 +3029,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3229,26 +3407,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3259,11 +3470,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3552,6 +3791,11 @@ + + + + + @@ -4105,11 +4349,6 @@ - - - - - @@ -4208,6 +4447,11 @@ + + + + + @@ -4258,6 +4502,11 @@ + + + + + diff --git a/android/lib/billing/build.gradle.kts b/android/lib/billing/build.gradle.kts index 26cc345556cb..6bb4e5e7a620 100644 --- a/android/lib/billing/build.gradle.kts +++ b/android/lib/billing/build.gradle.kts @@ -49,12 +49,15 @@ dependencies { //Model implementation(project(Dependencies.Mullvad.modelLib)) - //IPC - implementation(project(Dependencies.Mullvad.ipcLib)) - //Payment library implementation(project(Dependencies.Mullvad.paymentLib)) + //Either + implementation(Dependencies.Arrow.core) + + // Management service + implementation(project(Dependencies.Mullvad.daemonGrpc)) + // Test dependencies testRuntimeOnly(Dependencies.junitEngine) diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt index 76df623ada75..8b3ad66171f9 100644 --- a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt @@ -15,15 +15,14 @@ import net.mullvad.mullvadvpn.lib.billing.extension.toPaymentStatus import net.mullvad.mullvadvpn.lib.billing.extension.toPurchaseResult import net.mullvad.mullvadvpn.lib.billing.model.BillingException import net.mullvad.mullvadvpn.lib.billing.model.PurchaseEvent +import net.mullvad.mullvadvpn.lib.model.PlayPurchase +import net.mullvad.mullvadvpn.lib.model.PlayPurchasePaymentToken import net.mullvad.mullvadvpn.lib.payment.PaymentRepository import net.mullvad.mullvadvpn.lib.payment.ProductIds import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult -import net.mullvad.mullvadvpn.model.PlayPurchase -import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult -import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult class BillingPaymentRepository( private val billingRepository: BillingRepository, @@ -74,19 +73,20 @@ class BillingPaymentRepository( // Get transaction id emit(PurchaseResult.FetchingObfuscationId) - val obfuscatedId: String = - when (val result = initialisePurchase()) { - is PlayPurchaseInitResult.Ok -> result.obfuscatedId - else -> { - emit(PurchaseResult.Error.TransactionIdError(productId, null)) - return@flow - } - } + val obfuscatedId: PlayPurchasePaymentToken = + initialisePurchase() + .fold( + { + emit(PurchaseResult.Error.TransactionIdError(productId, null)) + return@flow + }, + { it } + ) val result = billingRepository.startPurchaseFlow( productDetails = productDetails, - obfuscatedId = obfuscatedId, + obfuscatedId = obfuscatedId.value, activityProvider = activityProvider ) @@ -115,11 +115,13 @@ class BillingPaymentRepository( emit(PurchaseResult.Completed.Pending) } else { emit(PurchaseResult.VerificationStarted) - if (verifyPurchase(event.purchases.first()) == PlayPurchaseVerifyResult.Ok) { - emit(PurchaseResult.Completed.Success) - } else { - emit(PurchaseResult.Error.VerificationError(null)) - } + emit( + verifyPurchase(event.purchases.first()) + .fold( + { PurchaseResult.Error.VerificationError(null) }, + { PurchaseResult.Completed.Success } + ) + ) } } PurchaseEvent.UserCanceled -> emit(event.toPurchaseResult()) @@ -135,13 +137,12 @@ class BillingPaymentRepository( val purchases = purchasesResult.nonPendingPurchases() if (purchases.isNotEmpty()) { emit(VerificationResult.VerificationStarted) - val verificationResult = verifyPurchase(purchases.first()) emit( - when (verificationResult) { - is PlayPurchaseVerifyResult.Error -> - VerificationResult.Error.VerificationError(null) - PlayPurchaseVerifyResult.Ok -> VerificationResult.Success - } + verifyPurchase(purchases.first()) + .fold( + { VerificationResult.Error.VerificationError(null) }, + { VerificationResult.Success } + ) ) } else { emit(VerificationResult.NothingToVerify) @@ -152,16 +153,13 @@ class BillingPaymentRepository( } } - private suspend fun initialisePurchase(): PlayPurchaseInitResult { - return playPurchaseRepository.initializePlayPurchase() - } + private suspend fun initialisePurchase() = playPurchaseRepository.initializePlayPurchase() - private suspend fun verifyPurchase(purchase: Purchase): PlayPurchaseVerifyResult { - return playPurchaseRepository.verifyPlayPurchase( + private suspend fun verifyPurchase(purchase: Purchase) = + playPurchaseRepository.verifyPlayPurchase( PlayPurchase( productId = purchase.products.first(), - purchaseToken = purchase.purchaseToken, + purchaseToken = PlayPurchasePaymentToken(purchase.purchaseToken), ) ) - } } diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/PlayPurchaseRepository.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/PlayPurchaseRepository.kt index ac71372f76f8..8e89cb8f958e 100644 --- a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/PlayPurchaseRepository.kt +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/PlayPurchaseRepository.kt @@ -1,33 +1,11 @@ package net.mullvad.mullvadvpn.lib.billing -import kotlinx.coroutines.flow.first -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.PlayPurchase -import net.mullvad.mullvadvpn.model.PlayPurchaseInitError -import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult -import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyError -import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.PlayPurchase -class PlayPurchaseRepository(private val messageHandler: MessageHandler) { - suspend fun initializePlayPurchase(): PlayPurchaseInitResult { - val result = messageHandler.trySendRequest(Request.InitPlayPurchase) +class PlayPurchaseRepository(private val managementService: ManagementService) { + suspend fun initializePlayPurchase() = managementService.initializePlayPurchase() - return if (result) { - messageHandler.events().first().result - } else { - PlayPurchaseInitResult.Error(PlayPurchaseInitError.OtherError) - } - } - - suspend fun verifyPlayPurchase(purchase: PlayPurchase): PlayPurchaseVerifyResult { - val result = messageHandler.trySendRequest(Request.VerifyPlayPurchase(purchase)) - return if (result) { - messageHandler.events().first().result - } else { - PlayPurchaseVerifyResult.Error(PlayPurchaseVerifyError.OtherError) - } - } + suspend fun verifyPlayPurchase(purchase: PlayPurchase) = + managementService.verifyPlayPurchase(purchase) } diff --git a/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt b/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt index c4d1b049051c..ad716cd30c4c 100644 --- a/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt +++ b/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt @@ -1,6 +1,8 @@ package net.mullvad.mullvadvpn.lib.billing import app.cash.turbine.test +import arrow.core.left +import arrow.core.right import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.BillingResult import com.android.billingclient.api.ProductDetails @@ -17,14 +19,13 @@ import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.billing.extension.toPaymentProduct import net.mullvad.mullvadvpn.lib.billing.model.PurchaseEvent import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.PlayPurchaseInitError +import net.mullvad.mullvadvpn.lib.model.PlayPurchasePaymentToken +import net.mullvad.mullvadvpn.lib.model.PlayPurchaseVerifyError 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.PlayPurchaseInitError -import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult -import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyError -import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -170,7 +171,7 @@ class BillingPaymentRepositoryTest { coEvery { mockBillingRepository.queryProducts(listOf(mockProductId.value)) } returns mockProductDetailsResult coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns - PlayPurchaseInitResult.Error(PlayPurchaseInitError.OtherError) + PlayPurchaseInitError.OtherError.left() // Act, Assert paymentRepository.purchaseProduct(mockProductId, mockk()).test { @@ -206,7 +207,7 @@ class BillingPaymentRepositoryTest { ) } returns mockBillingResult coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns - PlayPurchaseInitResult.Ok("MOCK") + PlayPurchasePaymentToken("MOCK").right() // Act, Assert paymentRepository.purchaseProduct(mockProductId, mockk()).test { @@ -241,7 +242,7 @@ class BillingPaymentRepositoryTest { ) } returns mockBillingResult coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns - PlayPurchaseInitResult.Ok(mockObfuscatedId) + PlayPurchasePaymentToken(mockObfuscatedId).right() // Act, Assert paymentRepository.purchaseProduct(mockProductId, mockk()).test { @@ -283,9 +284,9 @@ class BillingPaymentRepositoryTest { ) } returns mockBillingResult coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns - PlayPurchaseInitResult.Ok("MOCK-ID") + PlayPurchasePaymentToken("MOCK-ID").right() coEvery { mockPlayPurchaseRepository.verifyPlayPurchase(any()) } returns - PlayPurchaseVerifyResult.Error(PlayPurchaseVerifyError.OtherError) + PlayPurchaseVerifyError.OtherError.left() // Act, Assert paymentRepository.purchaseProduct(mockProductId, mockk()).test { @@ -326,9 +327,8 @@ class BillingPaymentRepositoryTest { ) } returns mockBillingResult coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns - PlayPurchaseInitResult.Ok("MOCK") - coEvery { mockPlayPurchaseRepository.verifyPlayPurchase(any()) } returns - PlayPurchaseVerifyResult.Ok + PlayPurchasePaymentToken("MOCK").right() + coEvery { mockPlayPurchaseRepository.verifyPlayPurchase(any()) } returns Unit.right() // Act, Assert paymentRepository.purchaseProduct(mockProductId, mockk()).test { @@ -368,7 +368,7 @@ class BillingPaymentRepositoryTest { ) } returns mockBillingResult coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns - PlayPurchaseInitResult.Ok("MOCK") + PlayPurchasePaymentToken("MOCK").right() // Act, Assert paymentRepository.purchaseProduct(mockProductId, mockk()).test { diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/ClassesAndActions.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/ClassNames.kt similarity index 54% rename from android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/ClassesAndActions.kt rename to android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/ClassNames.kt index 09210ffa033e..1636bbd46f6c 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/ClassesAndActions.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/ClassNames.kt @@ -2,13 +2,8 @@ package net.mullvad.mullvadvpn.lib.common.constant // Do not use in cases where the application id is expected since the application id will differ // between different builds. -private const val MULLVAD_PACKAGE_NAME = "net.mullvad.mullvadvpn" +internal const val MULLVAD_PACKAGE_NAME = "net.mullvad.mullvadvpn" // Classes const val MAIN_ACTIVITY_CLASS = "$MULLVAD_PACKAGE_NAME.ui.MainActivity" const val VPN_SERVICE_CLASS = "$MULLVAD_PACKAGE_NAME.service.MullvadVpnService" - -// Actions -const val KEY_CONNECT_ACTION = "$MULLVAD_PACKAGE_NAME.connect_action" -const val KEY_DISCONNECT_ACTION = "$MULLVAD_PACKAGE_NAME.disconnect_action" -const val KEY_QUIT_ACTION = "$MULLVAD_PACKAGE_NAME.quit_action" diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/IntentActions.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/IntentActions.kt new file mode 100644 index 000000000000..ea420f2d0a98 --- /dev/null +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/IntentActions.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.lib.common.constant + +// Actions +const val KEY_CONNECT_ACTION = "$MULLVAD_PACKAGE_NAME.connect_action" +const val KEY_DISCONNECT_ACTION = "$MULLVAD_PACKAGE_NAME.disconnect_action" +const val KEY_REQUEST_VPN_PERMISSION = "$MULLVAD_PACKAGE_NAME.request_vpn_permission" diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/LogTag.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/LogTag.kt new file mode 100644 index 000000000000..d2ae3f18718c --- /dev/null +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/constant/LogTag.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.common.constant + +const val TAG = "mullvad" diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonFlowUtils.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonFlowUtils.kt index 42f066396767..bf94c8077836 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonFlowUtils.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonFlowUtils.kt @@ -1,47 +1,9 @@ package net.mullvad.mullvadvpn.lib.common.util -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.util.Log -import kotlin.coroutines.EmptyCoroutineContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import net.mullvad.mullvadvpn.model.ServiceResult +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull -fun SendChannel.safeOffer(element: T): Boolean { - return runCatching { trySend(element).isSuccess }.getOrDefault(false) -} - -fun Context.bindServiceFlow(intent: Intent, flags: Int = 0): Flow = callbackFlow { - val connectionCallback = - object : ServiceConnection { - override fun onServiceConnected(className: ComponentName, binder: IBinder) { - safeOffer(ServiceResult(binder)) - } - - override fun onServiceDisconnected(className: ComponentName) { - safeOffer(ServiceResult.NOT_CONNECTED) - bindService(intent, this, flags) - } - } - - bindService(intent, connectionCallback, flags) - - awaitClose { - safeOffer(ServiceResult.NOT_CONNECTED) - - Dispatchers.Default.dispatch(EmptyCoroutineContext) { - try { - unbindService(connectionCallback) - } catch (e: IllegalArgumentException) { - Log.e("mullvad", "Cannot unbind as no binding exists.") - } - } - } +suspend fun Flow.firstOrNullWithTimeout(timeMillis: Long): T? { + return withTimeoutOrNull(timeMillis) { firstOrNull() } } diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt index 8ef70dad927b..d714dae3279e 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt @@ -4,15 +4,20 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.provider.Settings -import net.mullvad.mullvadvpn.lib.common.R import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.getInstalledPackagesList +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken private const val ALWAYS_ON_VPN_APP = "always_on_vpn_app" -fun Context.openAccountPageInBrowser(authToken: String) { - startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.account_url) + "?token=$authToken")) - ) +fun createAccountUri(accountUri: String, websiteAuthToken: WebsiteAuthToken?): Uri { + val urlString = buildString { + append(accountUri) + if (websiteAuthToken != null) { + append("?token=") + append(websiteAuthToken.value) + } + } + return Uri.parse(urlString) } fun Context.getAlwaysOnVpnAppName(): String? { diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/DispatchingFlow.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/DispatchingFlow.kt deleted file mode 100644 index 7fc37a752c1b..000000000000 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/DispatchingFlow.kt +++ /dev/null @@ -1,45 +0,0 @@ -package net.mullvad.mullvadvpn.lib.common.util - -import java.util.concurrent.ConcurrentHashMap -import kotlin.reflect.KClass -import kotlinx.coroutines.InternalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.consumeAsFlow - -class DispatchingFlow(private val upstream: Flow) : Flow { - private val subscribers = ConcurrentHashMap, SendChannel>() - - fun subscribe(variant: KClass, capacity: Int = Channel.CONFLATED): Flow { - val channel = Channel(capacity) - - // This is safe because `collect` will only send to this channel if the instance class is V - @Suppress("UNCHECKED_CAST") - subscribers[variant] = channel as SendChannel - - return channel.consumeAsFlow() - } - - fun unsubscribe(variant: KClass) = subscribers.remove(variant) - - @InternalCoroutinesApi - override suspend fun collect(collector: FlowCollector) { - upstream.collect { event -> - try { - subscribers[event::class]?.send(event) - } catch (closedException: ClosedSendChannelException) { - subscribers.remove(event::class) - } - - collector.emit(event) - } - - subscribers.clear() - } -} - -fun Flow.dispatchTo(configureSubscribers: DispatchingFlow.() -> Unit) = - DispatchingFlow(this).also(configureSubscribers) diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorStateExtension.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorStateExtension.kt index f906ee8f6dd5..2c9554a84224 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorStateExtension.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ErrorStateExtension.kt @@ -2,9 +2,9 @@ package net.mullvad.mullvadvpn.lib.common.util import android.content.Context import net.mullvad.mullvadvpn.lib.common.R -import net.mullvad.talpid.tunnel.ErrorState -import net.mullvad.talpid.tunnel.ErrorStateCause -import net.mullvad.talpid.tunnel.ParameterGenerationError +import net.mullvad.mullvadvpn.lib.model.ErrorState +import net.mullvad.mullvadvpn.lib.model.ErrorStateCause +import net.mullvad.mullvadvpn.lib.model.ParameterGenerationError import net.mullvad.talpid.util.addressString fun ErrorState.getErrorNotificationResources(context: Context): ErrorNotificationMessage { @@ -48,8 +48,8 @@ fun ErrorStateCause.errorMessageId(): Int { is ErrorStateCause.InvalidDnsServers -> R.string.invalid_dns_servers is ErrorStateCause.AuthFailed -> R.string.auth_failed is ErrorStateCause.Ipv6Unavailable -> R.string.ipv6_unavailable - is ErrorStateCause.SetFirewallPolicyError -> R.string.set_firewall_policy_error - is ErrorStateCause.SetDnsError -> R.string.set_dns_error + is ErrorStateCause.FirewallPolicyError -> R.string.set_firewall_policy_error + is ErrorStateCause.DnsError -> R.string.set_dns_error is ErrorStateCause.StartTunnelError -> R.string.start_tunnel_error is ErrorStateCause.IsOffline -> R.string.is_offline is ErrorStateCause.TunnelParameterError -> { diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/Intermittent.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/Intermittent.kt deleted file mode 100644 index 448d96778f37..000000000000 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/Intermittent.kt +++ /dev/null @@ -1,87 +0,0 @@ -package net.mullvad.mullvadvpn.lib.common.util - -import kotlin.properties.Delegates.observable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.sync.withPermit -import net.mullvad.talpid.util.EventNotifier - -// Wrapper to allow awaiting for intermittent values. -// -// Wraps a property that is changed from time to time and that can become unavailable (null). This -// behaves in a way similar to `CompletableDeferred`, but the value can be set and reset multiple -// times. -// -// Calling `await` will either provide the value if it's available, or suspend until it becomes -// available and then return it. -// -// Calling `update` will set the internal value after it guarantees that no other coroutine is -// currently reading the value (through a permit from the semaphore). After the value is set, it -// provides a permit to the semaphore so that suspended coroutines can use the new value. -// -// Extra initialization can be done on the intermittent value when it becomes available and before -// it is provided to the awaiting coroutines, through the use of listener callbacks. These are -// called after the value is updated but before it is made available to the coroutines. -class Intermittent { - private val notifier = EventNotifier(null) - private val semaphore = Semaphore(1, 1) - private val writeLock = Mutex() - - private var updateJob: Job? = null - private var value by notifier.notifiable() - - // When the internal value is updated, listeners can be notified before the awaiting coroutines - // resume execution. This allows performing any extra initialization before the value is made - // available for usage. - fun registerListener(id: Any, listener: (T?) -> Unit) = notifier.subscribe(id, listener) - - fun unregisterListener(id: Any) = notifier.unsubscribe(id) - - suspend fun await(): T { - return semaphore.withPermit { value!! } - } - - suspend fun update(newValue: T?) { - writeLock.withLock { - if (newValue != value) { - if (value != null) { - semaphore.acquire() - } - - // This will trigger the listeners to run before the awaiting coroutines resume - value = newValue - - if (newValue != null) { - semaphore.release() - } - } - } - } - - // Helper method that spawns a coroutine to update the value. - fun spawnUpdate(newValue: T?) { - synchronized(this@Intermittent) { - val previousUpdate = updateJob - - updateJob = - GlobalScope.launch(Dispatchers.Default) { - previousUpdate?.join() - update(newValue) - } - } - } - - // Helper method that provides a simple way to change the wrapped value. - // The method returns a property delegate that will spawn a coroutine to update the wrapped - // value every time the property is written to. - fun source() = observable(null) { _, _, newValue -> spawnUpdate(newValue) } - - fun onDestroy() { - notifier.unsubscribeAll() - } -} diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/JobTracker.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/JobTracker.kt deleted file mode 100644 index edb76ed4aea6..000000000000 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/JobTracker.kt +++ /dev/null @@ -1,91 +0,0 @@ -package net.mullvad.mullvadvpn.lib.common.util - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.launch - -class JobTracker { - private val jobs = HashMap() - private val reaperJobs = HashMap() - private val namedJobs = HashMap() - - private var jobIdCounter = 0L - - fun newJob(job: Job): Long { - synchronized(jobs) { - val jobId = jobIdCounter - - jobIdCounter += 1 - - jobs.put(jobId, job) - - reaperJobs.put( - jobId, - GlobalScope.launch(Dispatchers.Default) { - job.join() - - synchronized(jobs) { jobs.remove(jobId) } - } - ) - - return jobId - } - } - - fun newJob(name: String, job: Job): Long { - synchronized(namedJobs) { - cancelJob(name) - - val newJobId = newJob(job) - - namedJobs.put(name, newJobId) - - return newJobId - } - } - - fun newBackgroundJob(name: String, jobBody: suspend () -> Unit): Long { - return newJob(name, GlobalScope.launch(Dispatchers.Default) { jobBody() }) - } - - fun newUiJob(name: String, jobBody: suspend () -> Unit): Long { - return newJob(name, GlobalScope.launch(Dispatchers.Main) { jobBody() }) - } - - suspend fun runOnBackground(jobBody: suspend () -> T): T { - val job = GlobalScope.async(Dispatchers.Default) { jobBody() } - - newJob(job) - - return job.await() - } - - fun cancelJob(name: String) { - synchronized(namedJobs) { namedJobs.remove(name)?.let { oldJobId -> cancelJob(oldJobId) } } - } - - fun cancelJob(jobId: Long) { - synchronized(jobs) { - jobs.remove(jobId)?.cancel() - reaperJobs.remove(jobId)?.cancel() - } - } - - fun cancelAllJobs() { - synchronized(jobs) { - for (job in jobs.values) { - job.cancel() - } - - for (job in reaperJobs.values) { - job.cancel() - } - - jobs.clear() - reaperJobs.clear() - namedJobs.clear() - } - } -} diff --git a/android/lib/daemon-grpc/build.gradle.kts b/android/lib/daemon-grpc/build.gradle.kts new file mode 100644 index 000000000000..ed33aa4d09db --- /dev/null +++ b/android/lib/daemon-grpc/build.gradle.kts @@ -0,0 +1,85 @@ +import com.google.protobuf.gradle.proto + +plugins { + id(Dependencies.Plugin.androidLibraryId) + id(Dependencies.Plugin.kotlinAndroidId) + id(Dependencies.Plugin.kotlinParcelizeId) + id(Dependencies.Plugin.protobufId) version Versions.Plugin.protobuf + id(Dependencies.Plugin.junit5) version Versions.Plugin.junit5 +} + +android { + namespace = "net.mullvad.mullvadvpn.lib.daemon.grpc" + compileSdk = Versions.Android.compileSdkVersion + + defaultConfig { minSdk = Versions.Android.minSdkVersion } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { jvmTarget = Versions.jvmTarget } + + lint { + lintConfig = file("${rootProject.projectDir}/config/lint.xml") + abortOnError = true + warningsAsErrors = true + } + + sourceSets { + getByName("main") { + proto { srcDir("${rootProject.projectDir}/../mullvad-management-interface/proto") } + } + } +} + +protobuf { + protoc { artifact = "com.google.protobuf:protoc:${Versions.Grpc.protobufVersion}" } + plugins { + create("java") { artifact = "io.grpc:protoc-gen-grpc-java:${Versions.Grpc.grpcVersion}" } + create("grpc") { artifact = "io.grpc:protoc-gen-grpc-java:${Versions.Grpc.grpcVersion}" } + create("grpckt") { + artifact = "io.grpc:protoc-gen-grpc-kotlin:${Versions.Grpc.grpcKotlinVersion}:jdk8@jar" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + create("java") { option("lite") } + create("grpc") { option("lite") } + create("grpckt") { option("lite") } + } + it.builtins { create("kotlin") { option("lite") } } + } + } +} + +dependencies { + implementation(project(Dependencies.Mullvad.commonLib)) + implementation(project(Dependencies.Mullvad.modelLib)) + implementation(project(Dependencies.Mullvad.talpidLib)) + + implementation(Dependencies.jodaTime) + implementation(Dependencies.Kotlin.stdlib) + implementation(Dependencies.KotlinX.coroutinesCore) + implementation(Dependencies.KotlinX.coroutinesAndroid) + + implementation(Dependencies.Grpc.grpcOkHttp) + implementation(Dependencies.Grpc.grpcAndroid) + implementation(Dependencies.Grpc.grpcKotlinStub) + implementation(Dependencies.Grpc.protobufLite) + implementation(Dependencies.Grpc.protobufKotlinLite) + + implementation(Dependencies.Arrow.core) + implementation(Dependencies.Arrow.optics) + + testImplementation(project(Dependencies.Mullvad.commonTestLib)) + testImplementation(Dependencies.Kotlin.test) + testImplementation(Dependencies.KotlinX.coroutinesTest) + testImplementation(Dependencies.MockK.core) + testImplementation(Dependencies.turbine) + testImplementation(Dependencies.junitApi) + testRuntimeOnly(Dependencies.junitEngine) + testImplementation(Dependencies.junitParams) +} diff --git a/android/lib/ipc/src/main/AndroidManifest.xml b/android/lib/daemon-grpc/src/main/AndroidManifest.xml similarity index 100% rename from android/lib/ipc/src/main/AndroidManifest.xml rename to android/lib/daemon-grpc/src/main/AndroidManifest.xml diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt new file mode 100644 index 000000000000..80b79b707a5c --- /dev/null +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt @@ -0,0 +1,565 @@ +package net.mullvad.mullvadvpn.lib.daemon.grpc + +import android.net.LocalSocketAddress +import android.util.Log +import arrow.core.Either +import arrow.optics.copy +import arrow.optics.dsl.index +import arrow.optics.typeclasses.Index +import com.google.protobuf.BoolValue +import com.google.protobuf.Empty +import com.google.protobuf.StringValue +import com.google.protobuf.UInt32Value +import io.grpc.ConnectivityState +import io.grpc.Status +import io.grpc.StatusException +import io.grpc.android.UdsChannelBuilder +import java.net.InetAddress +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mullvad_daemon.management_interface.ManagementInterface +import mullvad_daemon.management_interface.ManagementServiceGrpcKt +import net.mullvad.mullvadvpn.lib.common.constant.TAG +import net.mullvad.mullvadvpn.lib.daemon.grpc.mapper.fromDomain +import net.mullvad.mullvadvpn.lib.daemon.grpc.mapper.toDomain +import net.mullvad.mullvadvpn.lib.daemon.grpc.util.LogInterceptor +import net.mullvad.mullvadvpn.lib.daemon.grpc.util.connectivityFlow +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.AddSplitTunnelingAppError +import net.mullvad.mullvadvpn.lib.model.AppId +import net.mullvad.mullvadvpn.lib.model.AppVersionInfo as ModelAppVersionInfo +import net.mullvad.mullvadvpn.lib.model.ClearAllOverridesError +import net.mullvad.mullvadvpn.lib.model.ConnectError +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CreateAccountError +import net.mullvad.mullvadvpn.lib.model.CreateCustomListError +import net.mullvad.mullvadvpn.lib.model.CustomList as ModelCustomList +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.DeleteCustomListError +import net.mullvad.mullvadvpn.lib.model.DeleteDeviceError +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.model.DeviceState as ModelDeviceState +import net.mullvad.mullvadvpn.lib.model.DnsOptions as ModelDnsOptions +import net.mullvad.mullvadvpn.lib.model.DnsOptions +import net.mullvad.mullvadvpn.lib.model.DnsState as ModelDnsState +import net.mullvad.mullvadvpn.lib.model.GetAccountDataError +import net.mullvad.mullvadvpn.lib.model.GetAccountHistoryError +import net.mullvad.mullvadvpn.lib.model.GetDeviceListError +import net.mullvad.mullvadvpn.lib.model.GetDeviceStateError +import net.mullvad.mullvadvpn.lib.model.LoginAccountError +import net.mullvad.mullvadvpn.lib.model.ObfuscationSettings as ModelObfuscationSettings +import net.mullvad.mullvadvpn.lib.model.Ownership as ModelOwnership +import net.mullvad.mullvadvpn.lib.model.PlayPurchase +import net.mullvad.mullvadvpn.lib.model.PlayPurchaseInitError +import net.mullvad.mullvadvpn.lib.model.PlayPurchasePaymentToken +import net.mullvad.mullvadvpn.lib.model.PlayPurchaseVerifyError +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.lib.model.QuantumResistantState as ModelQuantumResistantState +import net.mullvad.mullvadvpn.lib.model.RedeemVoucherError +import net.mullvad.mullvadvpn.lib.model.RedeemVoucherSuccess +import net.mullvad.mullvadvpn.lib.model.RelayConstraints +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId as ModelRelayItemId +import net.mullvad.mullvadvpn.lib.model.RelayList as ModelRelayList +import net.mullvad.mullvadvpn.lib.model.RelayList +import net.mullvad.mullvadvpn.lib.model.RelaySettings +import net.mullvad.mullvadvpn.lib.model.RemoveSplitTunnelingAppError +import net.mullvad.mullvadvpn.lib.model.SetAllowLanError +import net.mullvad.mullvadvpn.lib.model.SetAutoConnectError +import net.mullvad.mullvadvpn.lib.model.SetDnsOptionsError +import net.mullvad.mullvadvpn.lib.model.SetObfuscationOptionsError +import net.mullvad.mullvadvpn.lib.model.SetRelayLocationError +import net.mullvad.mullvadvpn.lib.model.SetWireguardConstraintsError +import net.mullvad.mullvadvpn.lib.model.SetWireguardMtuError +import net.mullvad.mullvadvpn.lib.model.SetWireguardQuantumResistantError +import net.mullvad.mullvadvpn.lib.model.Settings as ModelSettings +import net.mullvad.mullvadvpn.lib.model.SettingsPatchError +import net.mullvad.mullvadvpn.lib.model.TunnelState as ModelTunnelState +import net.mullvad.mullvadvpn.lib.model.UnknownCustomListError +import net.mullvad.mullvadvpn.lib.model.UpdateCustomListError +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints as ModelWireguardConstraints +import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData as ModelWireguardEndpointData +import net.mullvad.mullvadvpn.lib.model.addresses +import net.mullvad.mullvadvpn.lib.model.customOptions +import net.mullvad.mullvadvpn.lib.model.location +import net.mullvad.mullvadvpn.lib.model.ownership +import net.mullvad.mullvadvpn.lib.model.providers +import net.mullvad.mullvadvpn.lib.model.relayConstraints +import net.mullvad.mullvadvpn.lib.model.state +import net.mullvad.mullvadvpn.lib.model.wireguardConstraints + +@Suppress("TooManyFunctions") +class ManagementService( + rpcSocketPath: String, + private val extensiveLogging: Boolean, + private val scope: CoroutineScope, +) { + private var job: Job? = null + + private val channel = + UdsChannelBuilder.forPath(rpcSocketPath, LocalSocketAddress.Namespace.FILESYSTEM).build() + + val connectionState: StateFlow = + channel + .connectivityFlow() + .map(ConnectivityState::toDomain) + .stateIn(scope, SharingStarted.Eagerly, channel.getState(false).toDomain()) + + private val grpc = + ManagementServiceGrpcKt.ManagementServiceCoroutineStub(channel) + .withExecutor(Dispatchers.IO.asExecutor()) + .let { + if (extensiveLogging) { + it.withInterceptors(LogInterceptor()) + } else it + } + .withWaitForReady() + + private val _mutableDeviceState = MutableStateFlow(null) + val deviceState: Flow = _mutableDeviceState.filterNotNull() + + private val _mutableTunnelState = MutableStateFlow(null) + val tunnelState: Flow = _mutableTunnelState.filterNotNull() + + private val _mutableSettings = MutableStateFlow(null) + val settings: Flow = _mutableSettings.filterNotNull() + + private val _mutableVersionInfo = MutableStateFlow(null) + val versionInfo: Flow = _mutableVersionInfo.filterNotNull() + + private val _mutableRelayList = MutableStateFlow(null) + val relayList: Flow = _mutableRelayList.filterNotNull() + + val relayCountries: Flow> = + relayList.mapNotNull { it.countries } + + val wireguardEndpointData: Flow = + relayList.mapNotNull { it.wireguardEndpointData } + + fun start() { + // Just to ensure that connection is set up since the connection won't be setup without a + // call to the daemon + if (job != null) { + error("ManagementService already started") + } + + job = scope.launch { subscribeEvents() } + } + + fun stop() { + job?.cancel(message = "ManagementService stopped") + ?: error("ManagementService already stopped") + job = null + } + + private suspend fun subscribeEvents() = + withContext(Dispatchers.IO) { + launch { + grpc.eventsListen(Empty.getDefaultInstance()).collect { event -> + if (extensiveLogging) { + Log.d(TAG, "Event: $event") + } + @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") + when (event.eventCase) { + ManagementInterface.DaemonEvent.EventCase.TUNNEL_STATE -> + _mutableTunnelState.update { event.tunnelState.toDomain() } + ManagementInterface.DaemonEvent.EventCase.SETTINGS -> + _mutableSettings.update { event.settings.toDomain() } + ManagementInterface.DaemonEvent.EventCase.RELAY_LIST -> + _mutableRelayList.update { event.relayList.toDomain() } + ManagementInterface.DaemonEvent.EventCase.VERSION_INFO -> + _mutableVersionInfo.update { event.versionInfo.toDomain() } + ManagementInterface.DaemonEvent.EventCase.DEVICE -> + _mutableDeviceState.update { event.device.newState.toDomain() } + ManagementInterface.DaemonEvent.EventCase.REMOVE_DEVICE -> {} + ManagementInterface.DaemonEvent.EventCase.EVENT_NOT_SET -> {} + ManagementInterface.DaemonEvent.EventCase.NEW_ACCESS_METHOD -> {} + } + } + } + getInitialServiceState() + } + + suspend fun getDevice(): Either = + Either.catch { grpc.getDevice(Empty.getDefaultInstance()) } + .map { it.toDomain() } + .mapLeft { GetDeviceStateError.Unknown(it) } + + suspend fun getDeviceList(token: AccountToken): Either> = + Either.catch { grpc.listDevices(StringValue.of(token.value)) } + .map { it.devicesList.map(ManagementInterface.Device::toDomain) } + .mapLeft { GetDeviceListError.Unknown(it) } + + suspend fun removeDevice( + token: AccountToken, + deviceId: DeviceId + ): Either = + Either.catch { + grpc.removeDevice( + ManagementInterface.DeviceRemoval.newBuilder() + .setAccountToken(token.value) + .setDeviceId(deviceId.value.toString()) + .build(), + ) + } + .mapEmpty() + .mapLeft { DeleteDeviceError.Unknown(it) } + + suspend fun connect(): Either = + Either.catch { grpc.connectTunnel(Empty.getDefaultInstance()).value } + .mapLeft(ConnectError::Unknown) + + suspend fun disconnect(): Boolean = grpc.disconnectTunnel(Empty.getDefaultInstance()).value + + suspend fun reconnect(): Boolean = grpc.reconnectTunnel(Empty.getDefaultInstance()).value + + private suspend fun getTunnelState(): ModelTunnelState = + grpc.getTunnelState(Empty.getDefaultInstance()).toDomain() + + private suspend fun getSettings(): ModelSettings = + grpc.getSettings(Empty.getDefaultInstance()).toDomain() + + private suspend fun getDeviceState(): ModelDeviceState = + grpc.getDevice(Empty.getDefaultInstance()).toDomain() + + private suspend fun getRelayList(): ModelRelayList = + grpc.getRelayLocations(Empty.getDefaultInstance()).toDomain() + + private suspend fun getVersionInfo(): ModelAppVersionInfo = + grpc.getVersionInfo(Empty.getDefaultInstance()).toDomain() + + suspend fun logoutAccount() { + grpc.logoutAccount(Empty.getDefaultInstance()) + } + + suspend fun loginAccount(accountToken: AccountToken): Either = + Either.catch { grpc.loginAccount(StringValue.of(accountToken.value)) } + .mapLeftStatus { + when (it.status.code) { + Status.Code.UNAUTHENTICATED -> LoginAccountError.InvalidAccount + Status.Code.RESOURCE_EXHAUSTED -> + LoginAccountError.MaxDevicesReached(accountToken) + Status.Code.UNAVAILABLE -> LoginAccountError.RpcError + else -> LoginAccountError.Unknown(it) + } + } + .mapEmpty() + + suspend fun clearAccountHistory() { + grpc.clearAccountHistory(Empty.getDefaultInstance()) + } + + suspend fun getAccountHistory(): Either = + Either.catch { + val history = grpc.getAccountHistory(Empty.getDefaultInstance()) + if (history.hasToken()) { + AccountToken(history.token.value) + } else { + null + } + } + .mapLeft(GetAccountHistoryError::Unknown) + + private suspend fun getInitialServiceState() { + withContext(Dispatchers.IO) { + awaitAll( + async { _mutableTunnelState.update { getTunnelState() } }, + async { _mutableDeviceState.update { getDeviceState() } }, + async { _mutableSettings.update { getSettings() } }, + async { _mutableVersionInfo.update { getVersionInfo() } }, + async { _mutableRelayList.update { getRelayList() } }, + ) + } + } + + suspend fun getAccountData( + accountToken: AccountToken + ): Either = + Either.catch { grpc.getAccountData(StringValue.of(accountToken.value)).toDomain() } + .mapLeft(GetAccountDataError::Unknown) + + suspend fun createAccount(): Either = + Either.catch { + val accountTokenStringValue = grpc.createNewAccount(Empty.getDefaultInstance()) + AccountToken(accountTokenStringValue.value) + } + .mapLeft(CreateAccountError::Unknown) + + suspend fun setDnsOptions(dnsOptions: ModelDnsOptions): Either = + Either.catch { grpc.setDnsOptions(dnsOptions.fromDomain()) } + .mapLeft(SetDnsOptionsError::Unknown) + .mapEmpty() + + suspend fun setDnsState(dnsState: ModelDnsState): Either = + Either.catch { + val currentDnsOptions = getSettings().tunnelOptions.dnsOptions + val updated = DnsOptions.state.set(currentDnsOptions, dnsState) + grpc.setDnsOptions(updated.fromDomain()) + } + .mapLeft(SetDnsOptionsError::Unknown) + .mapEmpty() + + suspend fun setCustomDns(index: Int, address: InetAddress): Either = + Either.catch { + val currentDnsOptions = getSettings().tunnelOptions.dnsOptions + val updatedDnsOptions = + DnsOptions.customOptions.addresses + .index(Index.list(), index) + .set(currentDnsOptions, address) + + grpc.setDnsOptions(updatedDnsOptions.fromDomain()) + } + .mapLeft(SetDnsOptionsError::Unknown) + .mapEmpty() + + suspend fun addCustomDns(address: InetAddress): Either = + Either.catch { + val currentDnsOptions = getSettings().tunnelOptions.dnsOptions + val updatedDnsOptions = + DnsOptions.customOptions.addresses.modify(currentDnsOptions) { it + address } + grpc.setDnsOptions(updatedDnsOptions.fromDomain()) + } + .mapLeft(SetDnsOptionsError::Unknown) + .mapEmpty() + + suspend fun deleteCustomDns(address: InetAddress): Either = + Either.catch { + val currentDnsOptions = getSettings().tunnelOptions.dnsOptions + val updatedDnsOptions = + DnsOptions.customOptions.addresses.modify(currentDnsOptions) { it - address } + grpc.setDnsOptions(updatedDnsOptions.fromDomain()) + } + .mapLeft(SetDnsOptionsError::Unknown) + .mapEmpty() + + suspend fun setWireguardMtu(value: Int): Either = + Either.catch { grpc.setWireguardMtu(UInt32Value.of(value)) } + .mapLeft(SetWireguardMtuError::Unknown) + .mapEmpty() + + suspend fun resetWireguardMtu(): Either = + Either.catch { grpc.setWireguardMtu(UInt32Value.newBuilder().clearValue().build()) } + .mapLeft(SetWireguardMtuError::Unknown) + .mapEmpty() + + suspend fun setWireguardQuantumResistant( + value: ModelQuantumResistantState + ): Either = + Either.catch { grpc.setQuantumResistantTunnel(value.toDomain()) } + .mapLeft(SetWireguardQuantumResistantError::Unknown) + .mapEmpty() + + // Todo needs to be more advanced + suspend fun setRelaySettings(value: RelaySettings) { + grpc.setRelaySettings(value.fromDomain()) + } + + suspend fun setObfuscationOptions( + value: ModelObfuscationSettings + ): Either = + Either.catch { grpc.setObfuscationSettings(value.fromDomain()) } + .mapLeft(SetObfuscationOptionsError::Unknown) + .mapEmpty() + + suspend fun setAutoConnect(isEnabled: Boolean): Either = + Either.catch { grpc.setAutoConnect(BoolValue.of(isEnabled)) } + .mapLeft(SetAutoConnectError::Unknown) + .mapEmpty() + + suspend fun setAllowLan(allow: Boolean): Either = + Either.catch { grpc.setAllowLan(BoolValue.of(allow)) } + .mapLeft(SetAllowLanError::Unknown) + .mapEmpty() + + suspend fun setRelayLocation(location: ModelRelayItemId): Either = + Either.catch { + val currentRelaySettings = getSettings().relaySettings + val updatedRelaySettings = + RelaySettings.relayConstraints.location.set( + currentRelaySettings, + Constraint.Only(location), + ) + grpc.setRelaySettings(updatedRelaySettings.fromDomain()) + } + .mapLeft(SetRelayLocationError::Unknown) + .mapEmpty() + + suspend fun createCustomList( + name: CustomListName + ): Either = + Either.catch { grpc.createCustomList(StringValue.of(name.value)) } + .map { CustomListId(it.value) } + .mapLeftStatus { + when (it.status.code) { + Status.Code.ALREADY_EXISTS -> CustomListAlreadyExists + else -> UnknownCustomListError(it) + } + } + + suspend fun updateCustomList(customList: ModelCustomList): Either = + Either.catch { grpc.updateCustomList(customList.fromDomain()) } + .mapLeft(::UnknownCustomListError) + .mapEmpty() + + suspend fun deleteCustomList(id: CustomListId): Either = + Either.catch { grpc.deleteCustomList(StringValue.of(id.value)) } + .mapLeft(::UnknownCustomListError) + .mapEmpty() + + suspend fun clearAllRelayOverrides(): Either = + Either.catch { grpc.clearAllRelayOverrides(Empty.getDefaultInstance()) } + .mapLeft(ClearAllOverridesError::Unknown) + .mapEmpty() + + suspend fun applySettingsPatch(json: String): Either = + Either.catch { grpc.applyJsonSettings(StringValue.of(json)) } + .mapLeftStatus { + when (it.status.code) { + // Currently we only get invalid argument errors from daemon via gRPC + Status.Code.INVALID_ARGUMENT -> SettingsPatchError.ParsePatch + else -> SettingsPatchError.ApplyPatch + } + } + .mapEmpty() + + suspend fun setWireguardConstraints( + value: ModelWireguardConstraints + ): Either = + Either.catch { + val relaySettings = getSettings().relaySettings + val updated = + RelaySettings.relayConstraints.wireguardConstraints.set(relaySettings, value) + grpc.setRelaySettings(updated.fromDomain()) + } + .mapLeft(SetWireguardConstraintsError::Unknown) + .mapEmpty() + + suspend fun setOwnershipAndProviders( + ownershipConstraint: Constraint, + providersConstraint: Constraint + ): Either = + Either.catch { + val relaySettings = getSettings().relaySettings + val updated = + relaySettings.copy { + inside(RelaySettings.relayConstraints) { + RelayConstraints.providers set providersConstraint + RelayConstraints.ownership set ownershipConstraint + } + } + grpc.setRelaySettings(updated.fromDomain()) + } + .mapLeft(SetWireguardConstraintsError::Unknown) + .mapEmpty() + + suspend fun setOwnership( + ownership: Constraint + ): Either = + Either.catch { + val relaySettings = getSettings().relaySettings + val updated = RelaySettings.relayConstraints.ownership.set(relaySettings, ownership) + grpc.setRelaySettings(updated.fromDomain()) + } + .mapLeft(SetWireguardConstraintsError::Unknown) + .mapEmpty() + + suspend fun setProviders( + providersConstraint: Constraint + ): Either = + Either.catch { + val relaySettings = getSettings().relaySettings + val updated = + RelaySettings.relayConstraints.providers.set(relaySettings, providersConstraint) + grpc.setRelaySettings(updated.fromDomain()) + } + .mapLeft(SetWireguardConstraintsError::Unknown) + .mapEmpty() + + suspend fun submitVoucher(voucher: String): Either = + Either.catch { grpc.submitVoucher(StringValue.of(voucher)).toDomain() } + .mapLeftStatus { + when (it.status.code) { + Status.Code.INVALID_ARGUMENT, + Status.Code.NOT_FOUND -> RedeemVoucherError.InvalidVoucher + Status.Code.ALREADY_EXISTS, + Status.Code.RESOURCE_EXHAUSTED -> RedeemVoucherError.VoucherAlreadyUsed + Status.Code.UNAVAILABLE -> RedeemVoucherError.RpcError + else -> RedeemVoucherError.Unknown(it) + } + } + + suspend fun initializePlayPurchase(): Either = + Either.catch { grpc.initPlayPurchase(Empty.getDefaultInstance()).toDomain() } + .mapLeft { PlayPurchaseInitError.OtherError } + + suspend fun verifyPlayPurchase(purchase: PlayPurchase): Either = + Either.catch { grpc.verifyPlayPurchase(purchase.fromDomain()) } + .mapLeft { PlayPurchaseVerifyError.OtherError } + .mapEmpty() + + suspend fun addSplitTunnelingApp(app: AppId): Either = + Either.catch { grpc.addSplitTunnelApp(StringValue.of(app.value)) } + .mapLeft(AddSplitTunnelingAppError::Unknown) + .mapEmpty() + + suspend fun removeSplitTunnelingApp(app: AppId): Either = + Either.catch { grpc.removeSplitTunnelApp(StringValue.of(app.value)) } + .mapLeft(RemoveSplitTunnelingAppError::Unknown) + .mapEmpty() + + suspend fun setSplitTunnelingState( + enabled: Boolean + ): Either = + Either.catch { grpc.setSplitTunnelState(BoolValue.of(enabled)) } + .mapLeft(RemoveSplitTunnelingAppError::Unknown) + .mapEmpty() + + suspend fun getWebsiteAuthToken(): Either = + Either.catch { grpc.getWwwAuthToken(Empty.getDefaultInstance()) } + .map { WebsiteAuthToken.fromString(it.value) } + + private fun Either.mapEmpty() = map {} + + private inline fun Either.mapLeftStatus( + f: (StatusException) -> C + ): Either = mapLeft { + if (it is StatusException) { + f(it) + } else { + throw it + } + } +} + +sealed interface GrpcConnectivityState { + data object Connecting : GrpcConnectivityState + + data object Ready : GrpcConnectivityState + + data object Idle : GrpcConnectivityState + + data object TransientFailure : GrpcConnectivityState + + data object Shutdown : GrpcConnectivityState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparator.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/RelayNameComparator.kt similarity index 78% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparator.kt rename to android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/RelayNameComparator.kt index c062fd1466f7..a1b1d3b09237 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayNameComparator.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/RelayNameComparator.kt @@ -1,7 +1,9 @@ -package net.mullvad.mullvadvpn.relaylist +package net.mullvad.mullvadvpn.lib.daemon.grpc -internal object RelayNameComparator : Comparator { - override fun compare(o1: RelayItem.Relay, o2: RelayItem.Relay): Int { +import net.mullvad.mullvadvpn.lib.model.RelayItem + +internal object RelayNameComparator : Comparator { + override fun compare(o1: RelayItem.Location.Relay, o2: RelayItem.Location.Relay): Int { val partitions1 = o1.name.split(regex) val partitions2 = o2.name.split(regex) return if (partitions1.size > partitions2.size) partitions1 compareWith partitions2 diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt new file mode 100644 index 000000000000..df4625228feb --- /dev/null +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/FromDomain.kt @@ -0,0 +1,140 @@ +package net.mullvad.mullvadvpn.lib.daemon.grpc.mapper + +import mullvad_daemon.management_interface.ManagementInterface +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CustomDnsOptions +import net.mullvad.mullvadvpn.lib.model.CustomList +import net.mullvad.mullvadvpn.lib.model.CustomListId +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.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.ObfuscationSettings +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.PlayPurchase +import net.mullvad.mullvadvpn.lib.model.PlayPurchasePaymentToken +import net.mullvad.mullvadvpn.lib.model.Port +import net.mullvad.mullvadvpn.lib.model.Providers +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.model.RelaySettings +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints + +internal fun Constraint.fromDomain(): ManagementInterface.LocationConstraint = + ManagementInterface.LocationConstraint.newBuilder() + .apply { + when (this@fromDomain) { + is Constraint.Any -> {} + is Constraint.Only -> { + when (val relayItemId = value) { + is CustomListId -> setCustomList(relayItemId.value) + is GeoLocationId -> setLocation(relayItemId.fromDomain()) + } + } + } + } + .build() + +internal fun Constraint.fromDomain(): List = + when (this) { + is Constraint.Any -> emptyList() + is Constraint.Only -> value.providers.map { it.value } + } + +internal fun DnsOptions.fromDomain(): ManagementInterface.DnsOptions = + ManagementInterface.DnsOptions.newBuilder() + .setState(state.fromDomain()) + .setCustomOptions(customOptions.fromDomain()) + .setDefaultOptions(defaultOptions.fromDomain()) + .build() + +internal fun DnsState.fromDomain(): ManagementInterface.DnsOptions.DnsState = + when (this) { + DnsState.Default -> ManagementInterface.DnsOptions.DnsState.DEFAULT + DnsState.Custom -> ManagementInterface.DnsOptions.DnsState.CUSTOM + } + +internal fun CustomDnsOptions.fromDomain(): ManagementInterface.CustomDnsOptions = + ManagementInterface.CustomDnsOptions.newBuilder() + .addAllAddresses(addresses.map { it.hostAddress }) + .build() + +internal fun DefaultDnsOptions.fromDomain(): ManagementInterface.DefaultDnsOptions = + ManagementInterface.DefaultDnsOptions.newBuilder() + .setBlockAds(blockAds) + .setBlockGambling(blockGambling) + .setBlockMalware(blockMalware) + .setBlockTrackers(blockTrackers) + .setBlockAdultContent(blockAdultContent) + .setBlockSocialMedia(blockSocialMedia) + .build() + +internal fun ObfuscationSettings.fromDomain(): ManagementInterface.ObfuscationSettings = + ManagementInterface.ObfuscationSettings.newBuilder() + .setSelectedObfuscation(selectedObfuscation.toDomain()) + .setUdp2Tcp(udp2tcp.toDomain()) + .build() + +internal fun GeoLocationId.fromDomain(): ManagementInterface.GeographicLocationConstraint = + ManagementInterface.GeographicLocationConstraint.newBuilder() + .apply { + when (val id = this@fromDomain) { + is GeoLocationId.Country -> setCountry(id.countryCode) + is GeoLocationId.City -> setCountry(id.countryCode.countryCode).setCity(id.cityCode) + is GeoLocationId.Hostname -> + setCountry(id.country.countryCode) + .setCity(id.city.cityCode) + .setHostname(id.hostname) + } + } + .build() + +internal fun CustomList.fromDomain(): ManagementInterface.CustomList = + ManagementInterface.CustomList.newBuilder() + .setId(id.value) + .setName(name.value) + .addAllLocations(locations.map { it.fromDomain() }) + .build() + +internal fun WireguardConstraints.fromDomain(): ManagementInterface.WireguardConstraints = + when (port) { + is Constraint.Any -> ManagementInterface.WireguardConstraints.newBuilder().build() + is Constraint.Only -> + ManagementInterface.WireguardConstraints.newBuilder() + .setPort((port as Constraint.Only).value.value) + .build() + } + +internal fun Ownership.fromDomain(): ManagementInterface.Ownership = + when (this) { + Ownership.MullvadOwned -> ManagementInterface.Ownership.MULLVAD_OWNED + Ownership.Rented -> ManagementInterface.Ownership.RENTED + } + +internal fun RelaySettings.fromDomain(): ManagementInterface.RelaySettings = + ManagementInterface.RelaySettings.newBuilder() + .setNormal( + ManagementInterface.NormalRelaySettings.newBuilder() + .setTunnelType(ManagementInterface.TunnelType.WIREGUARD) + .setWireguardConstraints(relayConstraints.wireguardConstraints.fromDomain()) + .setOpenvpnConstraints(ManagementInterface.OpenvpnConstraints.getDefaultInstance()) + .setLocation(relayConstraints.location.fromDomain()) + .setOwnership(relayConstraints.ownership.fromDomain()) + .addAllProviders(relayConstraints.providers.fromDomain()) + .build() + ) + .build() + +internal fun Constraint.fromDomain(): ManagementInterface.Ownership = + when (this) { + Constraint.Any -> ManagementInterface.Ownership.ANY + is Constraint.Only -> value.fromDomain() + } + +internal fun PlayPurchasePaymentToken.fromDomain(): ManagementInterface.PlayPurchasePaymentToken = + ManagementInterface.PlayPurchasePaymentToken.newBuilder().setToken(value).build() + +internal fun PlayPurchase.fromDomain(): ManagementInterface.PlayPurchase = + ManagementInterface.PlayPurchase.newBuilder() + .setPurchaseToken(purchaseToken.fromDomain()) + .setProductId(productId) + .build() diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt new file mode 100644 index 000000000000..636e6c51768a --- /dev/null +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt @@ -0,0 +1,540 @@ +@file:Suppress("TooManyFunctions") + +package net.mullvad.mullvadvpn.lib.daemon.grpc.mapper + +import io.grpc.ConnectivityState +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.UUID +import mullvad_daemon.management_interface.ManagementInterface +import net.mullvad.mullvadvpn.lib.daemon.grpc.GrpcConnectivityState +import net.mullvad.mullvadvpn.lib.daemon.grpc.RelayNameComparator +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.model.AccountId +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.AppId +import net.mullvad.mullvadvpn.lib.model.AppVersionInfo +import net.mullvad.mullvadvpn.lib.model.Constraint +import net.mullvad.mullvadvpn.lib.model.CustomDnsOptions +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.DefaultDnsOptions +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.model.DnsOptions +import net.mullvad.mullvadvpn.lib.model.DnsState +import net.mullvad.mullvadvpn.lib.model.Endpoint +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.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.Mtu +import net.mullvad.mullvadvpn.lib.model.ObfuscationEndpoint +import net.mullvad.mullvadvpn.lib.model.ObfuscationSettings +import net.mullvad.mullvadvpn.lib.model.ObfuscationType +import net.mullvad.mullvadvpn.lib.model.Ownership +import net.mullvad.mullvadvpn.lib.model.ParameterGenerationError +import net.mullvad.mullvadvpn.lib.model.PlayPurchasePaymentToken +import net.mullvad.mullvadvpn.lib.model.Port +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.Providers +import net.mullvad.mullvadvpn.lib.model.QuantumResistantState +import net.mullvad.mullvadvpn.lib.model.RedeemVoucherSuccess +import net.mullvad.mullvadvpn.lib.model.RelayConstraints +import net.mullvad.mullvadvpn.lib.model.RelayItem +import net.mullvad.mullvadvpn.lib.model.RelayItemId +import net.mullvad.mullvadvpn.lib.model.RelayList +import net.mullvad.mullvadvpn.lib.model.RelayOverride +import net.mullvad.mullvadvpn.lib.model.RelaySettings +import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation +import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.SplitTunnelSettings +import net.mullvad.mullvadvpn.lib.model.TransportProtocol +import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint +import net.mullvad.mullvadvpn.lib.model.TunnelOptions +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.model.Udp2TcpObfuscationSettings +import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData +import net.mullvad.mullvadvpn.lib.model.WireguardTunnelOptions +import org.joda.time.Instant + +internal fun ManagementInterface.TunnelState.toDomain(): TunnelState = + when (stateCase!!) { + ManagementInterface.TunnelState.StateCase.DISCONNECTED -> + TunnelState.Disconnected( + location = + with(disconnected) { + if (hasDisconnectedLocation()) { + disconnectedLocation.toDomain() + } else null + }, + ) + ManagementInterface.TunnelState.StateCase.CONNECTING -> + TunnelState.Connecting( + endpoint = connecting.relayInfo.tunnelEndpoint.toDomain(), + location = + with(connecting.relayInfo) { + if (hasLocation()) { + location.toDomain() + } else null + } + ) + ManagementInterface.TunnelState.StateCase.CONNECTED -> + TunnelState.Connected( + endpoint = connected.relayInfo.tunnelEndpoint.toDomain(), + location = + with(connected.relayInfo) { + if (hasLocation()) { + location.toDomain() + } else { + null + } + } + ) + ManagementInterface.TunnelState.StateCase.DISCONNECTING -> + TunnelState.Disconnecting( + actionAfterDisconnect = disconnecting.afterDisconnect.toDomain(), + ) + ManagementInterface.TunnelState.StateCase.ERROR -> + TunnelState.Error(errorState = error.errorState.toDomain()) + ManagementInterface.TunnelState.StateCase.STATE_NOT_SET -> + TunnelState.Disconnected( + location = disconnected.disconnectedLocation.toDomain(), + ) + } + +internal fun ManagementInterface.GeoIpLocation.toDomain(): GeoIpLocation = + GeoIpLocation( + ipv4 = + if (hasIpv4()) { + InetAddress.getByName(ipv4) + } else { + null + }, + ipv6 = + if (hasIpv6()) { + InetAddress.getByName(ipv6) + } else { + null + }, + country = country, + city = city, + latitude = latitude, + longitude = longitude, + hostname = hostname + ) + +internal fun ManagementInterface.TunnelEndpoint.toDomain(): TunnelEndpoint = + TunnelEndpoint( + endpoint = + with(address) { + val indexOfSeparator = indexOfLast { it == ':' } + val ipPart = + address.substring(0, indexOfSeparator).filter { it !in listOf('[', ']') } + val portPart = address.substring(indexOfSeparator + 1) + + Endpoint( + address = InetSocketAddress(InetAddress.getByName(ipPart), portPart.toInt()), + protocol = protocol.toDomain() + ) + }, + quantumResistant = quantumResistant, + obfuscation = + if (hasObfuscation()) { + obfuscation.toDomain() + } else { + null + } + ) + +internal fun ManagementInterface.ObfuscationEndpoint.toDomain(): ObfuscationEndpoint = + ObfuscationEndpoint( + endpoint = + Endpoint(address = InetSocketAddress(address, port), protocol = protocol.toDomain()), + obfuscationType = obfuscationType.toDomain() + ) + +internal fun ManagementInterface.ObfuscationType.toDomain(): ObfuscationType = + when (this) { + ManagementInterface.ObfuscationType.UDP2TCP -> ObfuscationType.Udp2Tcp + ManagementInterface.ObfuscationType.UNRECOGNIZED -> + throw IllegalArgumentException("Unrecognized obfuscation type") + } + +internal fun ManagementInterface.TransportProtocol.toDomain(): TransportProtocol = + when (this) { + ManagementInterface.TransportProtocol.TCP -> TransportProtocol.Tcp + ManagementInterface.TransportProtocol.UDP -> TransportProtocol.Udp + ManagementInterface.TransportProtocol.UNRECOGNIZED -> + throw IllegalArgumentException("Unrecognized transport protocol") + } + +internal fun ManagementInterface.AfterDisconnect.toDomain(): ActionAfterDisconnect = + when (this) { + ManagementInterface.AfterDisconnect.NOTHING -> ActionAfterDisconnect.Nothing + ManagementInterface.AfterDisconnect.RECONNECT -> ActionAfterDisconnect.Reconnect + ManagementInterface.AfterDisconnect.BLOCK -> ActionAfterDisconnect.Block + ManagementInterface.AfterDisconnect.UNRECOGNIZED -> + throw IllegalArgumentException("Unrecognized action after disconnect") + } + +internal fun ManagementInterface.ErrorState.toDomain(): ErrorState = + ErrorState( + cause = + when (cause!!) { + ManagementInterface.ErrorState.Cause.AUTH_FAILED -> + ErrorStateCause.AuthFailed(authFailedError.name) + ManagementInterface.ErrorState.Cause.IPV6_UNAVAILABLE -> + ErrorStateCause.Ipv6Unavailable + ManagementInterface.ErrorState.Cause.SET_FIREWALL_POLICY_ERROR -> + policyError.toDomain() + ManagementInterface.ErrorState.Cause.SET_DNS_ERROR -> ErrorStateCause.DnsError + ManagementInterface.ErrorState.Cause.START_TUNNEL_ERROR -> + ErrorStateCause.StartTunnelError + ManagementInterface.ErrorState.Cause.TUNNEL_PARAMETER_ERROR -> + ErrorStateCause.TunnelParameterError(parameterError.toDomain()) + ManagementInterface.ErrorState.Cause.IS_OFFLINE -> ErrorStateCause.IsOffline + ManagementInterface.ErrorState.Cause.VPN_PERMISSION_DENIED -> + ErrorStateCause.VpnPermissionDenied + ManagementInterface.ErrorState.Cause.SPLIT_TUNNEL_ERROR -> + ErrorStateCause.StartTunnelError + ManagementInterface.ErrorState.Cause.UNRECOGNIZED, + ManagementInterface.ErrorState.Cause.NEED_FULL_DISK_PERMISSIONS, + ManagementInterface.ErrorState.Cause.CREATE_TUNNEL_DEVICE -> + throw IllegalArgumentException("Unrecognized error state cause") + }, + isBlocking = !hasBlockingError() + ) + +internal fun ManagementInterface.ErrorState.FirewallPolicyError.toDomain(): + ErrorStateCause.FirewallPolicyError = + when (type!!) { + ManagementInterface.ErrorState.FirewallPolicyError.ErrorType.GENERIC -> + ErrorStateCause.FirewallPolicyError.Generic + ManagementInterface.ErrorState.FirewallPolicyError.ErrorType.LOCKED, + ManagementInterface.ErrorState.FirewallPolicyError.ErrorType.UNRECOGNIZED -> + throw IllegalArgumentException("Unrecognized firewall policy error") + } + +internal fun ManagementInterface.ErrorState.GenerationError.toDomain(): ParameterGenerationError = + when (this) { + ManagementInterface.ErrorState.GenerationError.NO_MATCHING_RELAY -> + ParameterGenerationError.NoMatchingRelay + ManagementInterface.ErrorState.GenerationError.NO_MATCHING_BRIDGE_RELAY -> + ParameterGenerationError.NoMatchingBridgeRelay + ManagementInterface.ErrorState.GenerationError.NO_WIREGUARD_KEY -> + ParameterGenerationError.NoWireguardKey + ManagementInterface.ErrorState.GenerationError.CUSTOM_TUNNEL_HOST_RESOLUTION_ERROR -> + ParameterGenerationError.CustomTunnelHostResultionError + ManagementInterface.ErrorState.GenerationError.UNRECOGNIZED -> + throw IllegalArgumentException("Unrecognized parameter generation error") + } + +internal fun ManagementInterface.Settings.toDomain(): Settings = + Settings( + relaySettings = relaySettings.toDomain(), + obfuscationSettings = obfuscationSettings.toDomain(), + customLists = customLists.customListsList.map { it.toDomain() }, + allowLan = allowLan, + autoConnect = autoConnect, + tunnelOptions = tunnelOptions.toDomain(), + relayOverrides = relayOverridesList.map { it.toDomain() }, + showBetaReleases = showBetaReleases, + splitTunnelSettings = splitTunnel.toDomain() + ) + +internal fun ManagementInterface.RelayOverride.toDomain(): RelayOverride = + RelayOverride( + hostname = hostname, + ipv4AddressIn = if (hasIpv4AddrIn()) InetAddress.getByName(ipv4AddrIn) else null, + ipv6AddressIn = if (hasIpv6AddrIn()) InetAddress.getByName(ipv6AddrIn) else null + ) + +internal fun ManagementInterface.RelaySettings.toDomain(): RelaySettings = + when (endpointCase) { + ManagementInterface.RelaySettings.EndpointCase.CUSTOM -> + throw IllegalArgumentException("CustomTunnelEndpoint is not supported") + ManagementInterface.RelaySettings.EndpointCase.NORMAL -> RelaySettings(normal.toDomain()) + ManagementInterface.RelaySettings.EndpointCase.ENDPOINT_NOT_SET -> + throw IllegalArgumentException("RelaySettings endpoint not set") + else -> throw NullPointerException("RelaySettings endpoint is null") + } + +internal fun ManagementInterface.NormalRelaySettings.toDomain(): RelayConstraints = + RelayConstraints( + location = location.toDomain(), + providers = providersList.toDomain(), + ownership = ownership.toDomain(), + wireguardConstraints = wireguardConstraints.toDomain() + ) + +internal fun ManagementInterface.LocationConstraint.toDomain(): Constraint = + when (typeCase) { + ManagementInterface.LocationConstraint.TypeCase.CUSTOM_LIST -> + Constraint.Only(CustomListId(customList)) + ManagementInterface.LocationConstraint.TypeCase.LOCATION -> + Constraint.Only(location.toDomain()) + ManagementInterface.LocationConstraint.TypeCase.TYPE_NOT_SET -> Constraint.Any + else -> throw IllegalArgumentException("Location constraint type is null") + } + +@Suppress("ReturnCount") +internal fun ManagementInterface.GeographicLocationConstraint.toDomain(): GeoLocationId { + val country = GeoLocationId.Country(country) + if (!hasCity()) { + return country + } + + val city = GeoLocationId.City(country, city) + if (!hasHostname()) { + return city + } + return GeoLocationId.Hostname(city, hostname) +} + +internal fun List.toDomain(): Constraint = + if (isEmpty()) Constraint.Any else Constraint.Only(Providers(map { ProviderId(it) }.toSet())) + +internal fun ManagementInterface.WireguardConstraints.toDomain(): WireguardConstraints = + WireguardConstraints( + port = + if (hasPort()) { + Constraint.Only(Port(port)) + } else { + Constraint.Any + }, + ) + +internal fun ManagementInterface.Ownership.toDomain(): Constraint = + when (this) { + ManagementInterface.Ownership.ANY -> Constraint.Any + ManagementInterface.Ownership.MULLVAD_OWNED -> Constraint.Only(Ownership.MullvadOwned) + ManagementInterface.Ownership.RENTED -> Constraint.Only(Ownership.Rented) + ManagementInterface.Ownership.UNRECOGNIZED -> + throw IllegalArgumentException("Unrecognized ownership") + } + +internal fun ManagementInterface.ObfuscationSettings.toDomain(): ObfuscationSettings = + ObfuscationSettings( + selectedObfuscation = selectedObfuscation.toDomain(), + udp2tcp = udp2Tcp.toDomain() + ) + +internal fun ManagementInterface.ObfuscationSettings.SelectedObfuscation.toDomain(): + SelectedObfuscation = + when (this) { + ManagementInterface.ObfuscationSettings.SelectedObfuscation.AUTO -> SelectedObfuscation.Auto + ManagementInterface.ObfuscationSettings.SelectedObfuscation.OFF -> SelectedObfuscation.Off + ManagementInterface.ObfuscationSettings.SelectedObfuscation.UDP2TCP -> + SelectedObfuscation.Udp2Tcp + ManagementInterface.ObfuscationSettings.SelectedObfuscation.UNRECOGNIZED -> + throw IllegalArgumentException("Unrecognized selected obfuscation") + } + +internal fun ManagementInterface.Udp2TcpObfuscationSettings.toDomain(): Udp2TcpObfuscationSettings = + if (hasPort()) { + Udp2TcpObfuscationSettings(Constraint.Only(Port(port))) + } else { + Udp2TcpObfuscationSettings(Constraint.Any) + } + +internal fun ManagementInterface.CustomList.toDomain(): CustomList = + CustomList( + id = CustomListId(id), + name = CustomListName.fromString(name), + locations = locationsList.map { it.toDomain() } + ) + +internal fun ManagementInterface.TunnelOptions.toDomain(): TunnelOptions = + TunnelOptions(wireguard = wireguard.toDomain(), dnsOptions = dnsOptions.toDomain()) + +internal fun ManagementInterface.TunnelOptions.WireguardOptions.toDomain(): WireguardTunnelOptions = + WireguardTunnelOptions( + mtu = if (hasMtu()) Mtu(mtu) else null, + quantumResistant = quantumResistant.toDomain(), + ) + +internal fun ManagementInterface.QuantumResistantState.toDomain(): QuantumResistantState = + when (state) { + ManagementInterface.QuantumResistantState.State.AUTO -> QuantumResistantState.Auto + ManagementInterface.QuantumResistantState.State.ON -> QuantumResistantState.On + ManagementInterface.QuantumResistantState.State.OFF -> QuantumResistantState.Off + ManagementInterface.QuantumResistantState.State.UNRECOGNIZED -> + throw IllegalArgumentException("Unrecognized quantum resistant state") + else -> throw NullPointerException("Quantum resistant state is null") + } + +internal fun ManagementInterface.DnsOptions.toDomain(): DnsOptions = + DnsOptions( + state = state.toDomain(), + defaultOptions = defaultOptions.toDomain(), + customOptions = customOptions.toDomain() + ) + +internal fun ManagementInterface.DnsOptions.DnsState.toDomain(): DnsState = + when (this) { + ManagementInterface.DnsOptions.DnsState.DEFAULT -> DnsState.Default + ManagementInterface.DnsOptions.DnsState.CUSTOM -> DnsState.Custom + ManagementInterface.DnsOptions.DnsState.UNRECOGNIZED -> + throw IllegalArgumentException("Unrecognized dns state") + } + +internal fun ManagementInterface.DefaultDnsOptions.toDomain() = + DefaultDnsOptions( + blockAds = blockAds, + blockMalware = blockMalware, + blockAdultContent = blockAdultContent, + blockGambling = blockGambling, + blockSocialMedia = blockSocialMedia, + blockTrackers = blockTrackers + ) + +internal fun ManagementInterface.CustomDnsOptions.toDomain() = + CustomDnsOptions(addressesList.map { InetAddress.getByName(it) }) + +internal fun QuantumResistantState.toDomain(): ManagementInterface.QuantumResistantState = + ManagementInterface.QuantumResistantState.newBuilder() + .setState( + when (this) { + QuantumResistantState.Auto -> ManagementInterface.QuantumResistantState.State.AUTO + QuantumResistantState.On -> ManagementInterface.QuantumResistantState.State.ON + QuantumResistantState.Off -> ManagementInterface.QuantumResistantState.State.OFF + } + ) + .build() + +internal fun SelectedObfuscation.toDomain(): + ManagementInterface.ObfuscationSettings.SelectedObfuscation = + when (this) { + SelectedObfuscation.Udp2Tcp -> + ManagementInterface.ObfuscationSettings.SelectedObfuscation.UDP2TCP + SelectedObfuscation.Auto -> ManagementInterface.ObfuscationSettings.SelectedObfuscation.AUTO + SelectedObfuscation.Off -> ManagementInterface.ObfuscationSettings.SelectedObfuscation.OFF + } + +internal fun Udp2TcpObfuscationSettings.toDomain(): ManagementInterface.Udp2TcpObfuscationSettings = + when (val port = port) { + is Constraint.Any -> + ManagementInterface.Udp2TcpObfuscationSettings.newBuilder().clearPort().build() + is Constraint.Only -> + ManagementInterface.Udp2TcpObfuscationSettings.newBuilder() + .setPort(port.value.value) + .build() + } + +internal fun ManagementInterface.AppVersionInfo.toDomain(): AppVersionInfo = + AppVersionInfo( + supported = supported, + suggestedUpgrade = if (hasSuggestedUpgrade()) suggestedUpgrade else null + ) + +internal fun ConnectivityState.toDomain(): GrpcConnectivityState = + when (this) { + ConnectivityState.CONNECTING -> GrpcConnectivityState.Connecting + ConnectivityState.READY -> GrpcConnectivityState.Ready + ConnectivityState.IDLE -> GrpcConnectivityState.Idle + ConnectivityState.TRANSIENT_FAILURE -> GrpcConnectivityState.TransientFailure + ConnectivityState.SHUTDOWN -> GrpcConnectivityState.Shutdown + } + +internal fun ManagementInterface.RelayList.toDomain(): RelayList = + RelayList(countriesList.toDomain(), wireguard.toDomain()) + +internal fun ManagementInterface.WireguardEndpointData.toDomain(): WireguardEndpointData = + WireguardEndpointData(portRangesList.map { it.toDomain() }) + +internal fun ManagementInterface.PortRange.toDomain(): PortRange = PortRange(first..last) + +/** + * Convert from a list of ManagementInterface.RelayListCountry to a model.RelayList. Non-wireguard + * relays are filtered out. So are also cities that only contains non-wireguard relays and countries + * that does not have any cities. Countries, cities and relays are ordered by name. + */ +internal fun List.toDomain(): + List = + map(ManagementInterface.RelayListCountry::toDomain) + .filter { it.cities.isNotEmpty() } + .sortedBy { it.name } + +internal fun ManagementInterface.RelayListCountry.toDomain(): RelayItem.Location.Country { + val countryCode = GeoLocationId.Country(code) + return RelayItem.Location.Country( + countryCode, + name, + false, + citiesList + .map { city -> city.toDomain(countryCode) } + .filter { it.relays.isNotEmpty() } + .sortedBy { it.name } + ) +} + +internal fun ManagementInterface.RelayListCity.toDomain( + countryCode: GeoLocationId.Country +): RelayItem.Location.City { + val cityCode = GeoLocationId.City(countryCode, code) + return RelayItem.Location.City( + name = name, + id = cityCode, + expanded = false, + relays = + relaysList + .filter { it.endpointType == ManagementInterface.Relay.RelayType.WIREGUARD } + .map { it.toDomain(cityCode) } + .sortedWith(RelayNameComparator) + ) +} + +internal fun ManagementInterface.Relay.toDomain( + cityCode: GeoLocationId.City +): RelayItem.Location.Relay = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(cityCode, hostname), + active = active, + provider = + Provider( + ProviderId(provider), + ownership = if (owned) Ownership.MullvadOwned else Ownership.Rented + ) + ) + +internal fun ManagementInterface.Device.toDomain(): Device = + Device(DeviceId.fromString(id), name, Instant.ofEpochSecond(created.seconds).toDateTime()) + +internal fun ManagementInterface.DeviceState.toDomain(): DeviceState = + when (state) { + ManagementInterface.DeviceState.State.LOGGED_IN -> + DeviceState.LoggedIn(AccountToken(device.accountToken), device.device.toDomain()) + ManagementInterface.DeviceState.State.LOGGED_OUT -> DeviceState.LoggedOut + ManagementInterface.DeviceState.State.REVOKED -> DeviceState.Revoked + ManagementInterface.DeviceState.State.UNRECOGNIZED -> + throw IllegalArgumentException("Non valid device state") + else -> throw NullPointerException("Device state is null") + } + +internal fun ManagementInterface.AccountData.toDomain(): AccountData = + AccountData( + AccountId(UUID.fromString(id)), + expiryDate = Instant.ofEpochSecond(expiry.seconds).toDateTime() + ) + +internal fun ManagementInterface.VoucherSubmission.toDomain(): RedeemVoucherSuccess = + RedeemVoucherSuccess( + timeAdded = secondsAdded, + newExpiryDate = Instant.ofEpochSecond(newExpiry.seconds).toDateTime() + ) + +internal fun ManagementInterface.SplitTunnelSettings.toDomain(): SplitTunnelSettings = + SplitTunnelSettings( + enabled = enableExclusions, + excludedApps = appsList.map { AppId(it) }.toSet() + ) + +internal fun ManagementInterface.PlayPurchasePaymentToken.toDomain(): PlayPurchasePaymentToken = + PlayPurchasePaymentToken(value = token) diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/util/LogInterceptor.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/util/LogInterceptor.kt new file mode 100644 index 000000000000..fde87ecdd556 --- /dev/null +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/util/LogInterceptor.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn.lib.daemon.grpc.util + +import android.util.Log +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.ClientInterceptor +import io.grpc.MethodDescriptor +import net.mullvad.mullvadvpn.lib.common.constant.TAG + +internal class LogInterceptor : ClientInterceptor { + override fun interceptCall( + method: MethodDescriptor?, + callOptions: CallOptions?, + next: Channel? + ): ClientCall { + Log.d(TAG, "Intercepted call: ${method?.fullMethodName}") + return next!!.newCall(method, callOptions) + } +} diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/util/ManagedChannel.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/util/ManagedChannel.kt new file mode 100644 index 000000000000..3f98ae93d8d7 --- /dev/null +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/util/ManagedChannel.kt @@ -0,0 +1,24 @@ +package net.mullvad.mullvadvpn.lib.daemon.grpc.util + +import io.grpc.ConnectivityState +import io.grpc.ManagedChannel +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive + +internal fun ManagedChannel.connectivityFlow(): Flow { + return callbackFlow { + var currentState = getState(false) + send(currentState) + + while (isActive) { + currentState = + suspendCoroutine { + notifyWhenStateChanged(currentState) { it.resume(getState(false)) } + } + send(currentState) + } + } +} diff --git a/android/lib/daemon-grpc/src/test/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/RelayNameComparatorTest.kt b/android/lib/daemon-grpc/src/test/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/RelayNameComparatorTest.kt new file mode 100644 index 000000000000..42cf7455107e --- /dev/null +++ b/android/lib/daemon-grpc/src/test/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/RelayNameComparatorTest.kt @@ -0,0 +1,282 @@ +package net.mullvad.mullvadvpn.lib.daemon.grpc + +import io.mockk.mockk +import io.mockk.unmockkAll +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 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.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se9-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ), + ) + val relay10 = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se10-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("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.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se9-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relay9b = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se9-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("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.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "001"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relay1 = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "1"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relay3 = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "3"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relay100 = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "100"), + active = false, + provider = + Provider( + providerId = ProviderId("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.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relay9b = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("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.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se001-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relay005 = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se005-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + + relay001 assertOrderBothDirection relay005 + } + + @Test + fun `given 4 relays comparator should sort by prefix then number`() { + val relayAr2 = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "ar2-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relayAr8 = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "ar8-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relaySe5 = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se5-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relaySe10 = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se10-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("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.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se2-cloud"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relay2w = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se2-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("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.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se22"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + val relay22b = + RelayItem.Location.Relay( + id = GeoLocationId.Hostname(city = mockk(), "se22-wireguard"), + active = false, + provider = + Provider( + providerId = ProviderId("Provider"), + ownership = Ownership.MullvadOwned + ) + ) + + relay22a assertOrderBothDirection relay22b + } + + private infix fun RelayItem.Location.Relay.assertOrderBothDirection( + other: RelayItem.Location.Relay + ) { + assertTrue(RelayNameComparator.compare(this, other) < 0) + assertTrue(RelayNameComparator.compare(other, this) > 0) + } +} diff --git a/android/lib/ipc/build.gradle.kts b/android/lib/intent-provider/build.gradle.kts similarity index 54% rename from android/lib/ipc/build.gradle.kts rename to android/lib/intent-provider/build.gradle.kts index 35fa3c4f1e7c..f63a9c7f69a8 100644 --- a/android/lib/ipc/build.gradle.kts +++ b/android/lib/intent-provider/build.gradle.kts @@ -2,17 +2,13 @@ plugins { id(Dependencies.Plugin.androidLibraryId) id(Dependencies.Plugin.kotlinAndroidId) id(Dependencies.Plugin.kotlinParcelizeId) - id(Dependencies.Plugin.junit5) version Versions.Plugin.junit5 } android { - namespace = "net.mullvad.mullvadvpn.lib.ipc" + namespace = "net.mullvad.mullvadvpn.lib.intent" compileSdk = Versions.Android.compileSdkVersion - defaultConfig { - minSdk = Versions.Android.minSdkVersion - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } + defaultConfig { minSdk = Versions.Android.minSdkVersion } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -26,16 +22,10 @@ android { abortOnError = true warningsAsErrors = true } + buildFeatures { buildConfig = true } } dependencies { - implementation(project(Dependencies.Mullvad.modelLib)) - implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.KotlinX.coroutinesAndroid) - - androidTestImplementation(Dependencies.junitApi) - androidTestImplementation(Dependencies.junitEngine) - androidTestImplementation(Dependencies.AndroidX.testRunner) - androidTestImplementation(Dependencies.Kotlin.test) } diff --git a/android/lib/intent-provider/src/main/AndroidManifest.xml b/android/lib/intent-provider/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..cc947c567995 --- /dev/null +++ b/android/lib/intent-provider/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/android/lib/intent-provider/src/main/kotlin/net/mullvad/mullvadvpn/lib/intent/IntentProvider.kt b/android/lib/intent-provider/src/main/kotlin/net/mullvad/mullvadvpn/lib/intent/IntentProvider.kt new file mode 100644 index 000000000000..86ad970b5df6 --- /dev/null +++ b/android/lib/intent-provider/src/main/kotlin/net/mullvad/mullvadvpn/lib/intent/IntentProvider.kt @@ -0,0 +1,16 @@ +package net.mullvad.mullvadvpn.lib.intent + +import android.content.Intent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class IntentProvider { + private val _intents = MutableStateFlow(null) + val intents: Flow = _intents + + fun setStartIntent(intent: Intent?) { + _intents.tryEmit(intent) + } + + fun getLatestIntent(): Intent? = _intents.value +} diff --git a/android/lib/ipc/src/androidTest/kotlin/net/mullvad/mullvadvpn/lib/ipc/HandlerFlowTest.kt b/android/lib/ipc/src/androidTest/kotlin/net/mullvad/mullvadvpn/lib/ipc/HandlerFlowTest.kt deleted file mode 100644 index a125af605996..000000000000 --- a/android/lib/ipc/src/androidTest/kotlin/net/mullvad/mullvadvpn/lib/ipc/HandlerFlowTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package net.mullvad.mullvadvpn.lib.ipc - -import android.os.Bundle -import android.os.Looper -import android.os.Message -import android.os.Parcelable -import kotlin.test.assertEquals -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import kotlinx.parcelize.Parcelize -import org.junit.jupiter.api.Test - -class HandlerFlowTest { - val looper by lazy { Looper.getMainLooper() } - - val handler: HandlerFlow by lazy { - HandlerFlow(looper) { message -> message.data.getParcelable(DATA_KEY) } - } - - @Test - fun test_message_extraction() { - sendMessage(Data(1)) - sendMessage(Data(2)) - sendMessage(Data(3)) - - val extractedData = runBlocking { handler.take(3).toList() } - - assertEquals(listOf(Data(1), Data(2), Data(3)), extractedData) - } - - private fun sendMessage(messageData: Data) { - val message = - Message().apply { data = Bundle().apply { putParcelable(DATA_KEY, messageData) } } - - handler.handleMessage(message) - } - - companion object { - const val DATA_KEY = "data" - - @Parcelize data class Data(val id: Int) : Parcelable - } -} diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/DispatchingHandler.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/DispatchingHandler.kt deleted file mode 100644 index efaa1b78f802..000000000000 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/DispatchingHandler.kt +++ /dev/null @@ -1,53 +0,0 @@ -package net.mullvad.mullvadvpn.lib.ipc - -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.util.Log -import java.util.concurrent.locks.ReentrantReadWriteLock -import kotlin.concurrent.withLock -import kotlin.reflect.KClass -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -class DispatchingHandler(looper: Looper, private val extractor: (Message) -> T?) : - Handler(looper), MessageDispatcher { - private val handlers = HashMap, (T) -> Unit>() - private val lock = ReentrantReadWriteLock() - - private val _parsedMessages = - MutableSharedFlow(extraBufferCapacity = MESSAGES_BUFFER_CAPACITY) - val parsedMessages = _parsedMessages.asSharedFlow() - - @Deprecated("Use parsedMessages instead.") - override fun registerHandler(variant: KClass, handler: (V) -> Unit) { - lock.writeLock().withLock { - handlers.put(variant) { instance -> @Suppress("UNCHECKED_CAST") handler(instance as V) } - } - } - - override fun handleMessage(message: Message) { - lock.readLock().withLock { - val instance = extractor(message) - - if (instance != null) { - val handler = handlers.get(instance::class) - - handler?.invoke(instance) - _parsedMessages.tryEmit(instance) - } else { - Log.e("mullvad", "Dispatching handler received an unexpected message") - } - } - } - - fun onDestroy() { - lock.writeLock().withLock { handlers.clear() } - - removeCallbacksAndMessages(null) - } - - companion object { - private const val MESSAGES_BUFFER_CAPACITY = 10 - } -} diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt deleted file mode 100644 index 36ea17036ee5..000000000000 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt +++ /dev/null @@ -1,86 +0,0 @@ -package net.mullvad.mullvadvpn.lib.ipc - -import android.os.Messenger -import kotlinx.parcelize.Parcelize -import net.mullvad.mullvadvpn.model.AccountCreationResult -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.AccountHistory -import net.mullvad.mullvadvpn.model.CreateCustomListResult -import net.mullvad.mullvadvpn.model.DeviceListEvent -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.model.LoginResult -import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult -import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult -import net.mullvad.mullvadvpn.model.RelayList -import net.mullvad.mullvadvpn.model.RemoveDeviceResult -import net.mullvad.mullvadvpn.model.Settings -import net.mullvad.mullvadvpn.model.SettingsPatchError -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.model.UpdateCustomListResult - -// Events that can be sent from the service -sealed class Event : Message.EventMessage() { - override val messageKey = MESSAGE_KEY - - @Parcelize data class AccountCreationEvent(val result: AccountCreationResult) : Event() - - @Parcelize data class AccountExpiryEvent(val expiry: AccountExpiry) : Event() - - @Parcelize data class AccountHistoryEvent(val history: AccountHistory) : Event() - - @Parcelize - data class AppVersionInfo(val versionInfo: net.mullvad.mullvadvpn.model.AppVersionInfo?) : - Event() - - @Parcelize data class AuthToken(val token: String?) : Event() - - @Parcelize data class CurrentVersion(val version: String?) : Event() - - @Parcelize data class DeviceStateEvent(val newState: DeviceState) : Event() - - @Parcelize data class DeviceListUpdate(val event: DeviceListEvent) : Event() - - @Parcelize - data class DeviceRemovalEvent(val deviceId: String, val result: RemoveDeviceResult) : Event() - - @Parcelize data class ListenerReady(val connection: Messenger, val listenerId: Int) : Event() - - @Parcelize data class LoginEvent(val result: LoginResult) : Event() - - @Parcelize data class NewRelayList(val relayList: RelayList?) : Event() - - @Parcelize data class SettingsUpdate(val settings: Settings?) : Event() - - @Parcelize data class SplitTunnelingUpdate(val excludedApps: List?) : Event() - - @Parcelize data class TunnelStateChange(val tunnelState: TunnelState) : Event() - - @Parcelize - data class VoucherSubmissionResult( - val voucher: String, - val result: net.mullvad.mullvadvpn.model.VoucherSubmissionResult - ) : Event() - - @Parcelize data class PlayPurchaseInitResultEvent(val result: PlayPurchaseInitResult) : Event() - - @Parcelize - data class PlayPurchaseVerifyResultEvent(val result: PlayPurchaseVerifyResult) : Event() - - @Parcelize object VpnPermissionRequest : Event() - - @Parcelize data class CreateCustomListResultEvent(val result: CreateCustomListResult) : Event() - - @Parcelize data class UpdateCustomListResultEvent(val result: UpdateCustomListResult) : Event() - - @Parcelize data class ExportJsonSettingsResult(val json: String) : Event() - - @Parcelize data class ApplyJsonSettingsResult(val error: SettingsPatchError?) : Event() - - companion object { - private const val MESSAGE_KEY = "event" - - fun fromMessage(message: android.os.Message): Event? = fromMessage(message, MESSAGE_KEY) - } -} - -typealias EventDispatcher = MessageDispatcher diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/HandlerFlow.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/HandlerFlow.kt deleted file mode 100644 index 7b839a365835..000000000000 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/HandlerFlow.kt +++ /dev/null @@ -1,38 +0,0 @@ -package net.mullvad.mullvadvpn.lib.ipc - -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.util.Log -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.InternalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.onCompletion - -class HandlerFlow(looper: Looper, private val extractor: (Message) -> T) : - Handler(looper), Flow { - private val channel = Channel(Channel.UNLIMITED) - private val flow = channel.consumeAsFlow().onCompletion { removeCallbacksAndMessages(null) } - - @InternalCoroutinesApi - override suspend fun collect(collector: FlowCollector) = flow.collect(collector) - - override fun handleMessage(message: Message) { - val extractedData = extractor(message) - - try { - channel.trySendBlocking(extractedData) - } catch (exception: ClosedSendChannelException) { - Log.w("mullvad", "Received a message after HandlerFlow was closed", exception) - removeCallbacksAndMessages(null) - } catch (exception: CancellationException) { - Log.w("mullvad", "Received a message after HandlerFlow was cancelled", exception) - removeCallbacksAndMessages(null) - } - } -} diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Message.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Message.kt deleted file mode 100644 index 7cc293b373bf..000000000000 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Message.kt +++ /dev/null @@ -1,31 +0,0 @@ -package net.mullvad.mullvadvpn.lib.ipc - -import android.os.Bundle -import android.os.Message as RawMessage -import android.os.Parcelable - -sealed class Message(private val messageId: Int) : Parcelable { - abstract class EventMessage : Message(1) - - abstract class RequestMessage : Message(2) - - protected abstract val messageKey: String - - val message: RawMessage - get() = - RawMessage.obtain().also { message -> - message.what = messageId - message.data = Bundle() - message.data.putParcelable(messageKey, this) - } - - companion object { - internal fun fromMessage(message: RawMessage, key: String): T? { - val data = message.data - - data.classLoader = Message::class.java.classLoader - - return data.getParcelable(key) - } - } -} diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/MessageDispatcher.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/MessageDispatcher.kt deleted file mode 100644 index 8bb670347949..000000000000 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/MessageDispatcher.kt +++ /dev/null @@ -1,7 +0,0 @@ -package net.mullvad.mullvadvpn.lib.ipc - -import kotlin.reflect.KClass - -interface MessageDispatcher { - fun registerHandler(variant: KClass, handler: (V) -> Unit) -} diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/MessageHandler.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/MessageHandler.kt deleted file mode 100644 index 04de35e3bda2..000000000000 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/MessageHandler.kt +++ /dev/null @@ -1,14 +0,0 @@ -package net.mullvad.mullvadvpn.lib.ipc - -import kotlin.reflect.KClass -import kotlinx.coroutines.flow.Flow - -interface MessageHandler { - fun events(klass: KClass): Flow - - fun trySendRequest(request: Request): Boolean -} - -inline fun MessageHandler.events(): Flow { - return this.events(R::class) -} diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt deleted file mode 100644 index 4bcf871acc1a..000000000000 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt +++ /dev/null @@ -1,134 +0,0 @@ -package net.mullvad.mullvadvpn.lib.ipc - -import android.os.Message as RawMessage -import android.os.Messenger -import java.net.InetAddress -import kotlinx.parcelize.Parcelize -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.CustomList -import net.mullvad.mullvadvpn.model.DnsOptions -import net.mullvad.mullvadvpn.model.LocationConstraint -import net.mullvad.mullvadvpn.model.ObfuscationSettings -import net.mullvad.mullvadvpn.model.Ownership -import net.mullvad.mullvadvpn.model.PlayPurchase -import net.mullvad.mullvadvpn.model.Providers -import net.mullvad.mullvadvpn.model.QuantumResistantState -import net.mullvad.mullvadvpn.model.RelayOverride -import net.mullvad.mullvadvpn.model.WireguardConstraints - -// Requests that the service can handle -sealed class Request : Message.RequestMessage() { - override val messageKey = MESSAGE_KEY - - @Parcelize - @Deprecated("Use SetDnsOptions") - data class AddCustomDnsServer(val address: InetAddress) : Request() - - @Parcelize object Connect : Request() - - @Parcelize object CreateAccount : Request() - - @Parcelize object Disconnect : Request() - - @Parcelize data class ExcludeApp(val packageName: String) : Request() - - @Parcelize object FetchAccountExpiry : Request() - - @Parcelize object FetchAccountHistory : Request() - - @Parcelize object FetchAuthToken : Request() - - @Parcelize data class IncludeApp(val packageName: String) : Request() - - @Parcelize data class Login(val account: String?) : Request() - - @Parcelize object RefreshDeviceState : Request() - - @Parcelize object GetDevice : Request() - - @Parcelize data class GetDeviceList(val accountToken: String) : Request() - - @Parcelize data class RemoveDevice(val accountToken: String, val deviceId: String) : Request() - - @Parcelize object Logout : Request() - - @Parcelize object PersistExcludedApps : Request() - - @Parcelize object Reconnect : Request() - - @Parcelize data class RegisterListener(val listener: Messenger) : Request() - - @Parcelize object ClearAccountHistory : Request() - - @Parcelize - @Deprecated("Use SetDnsOptions") - data class RemoveCustomDnsServer(val address: InetAddress) : Request() - - @Parcelize - @Deprecated("Use SetDnsOptions") - data class ReplaceCustomDnsServer(val oldAddress: InetAddress, val newAddress: InetAddress) : - Request() - - @Parcelize data class SetAllowLan(val allow: Boolean) : Request() - - @Parcelize data class SetAutoConnect(val autoConnect: Boolean) : Request() - - @Parcelize - @Deprecated("Use SetDnsOptions") - data class SetEnableCustomDns(val enable: Boolean) : Request() - - @Parcelize data class SetEnableSplitTunneling(val enable: Boolean) : Request() - - @Parcelize data class SetRelayLocation(val locationConstraint: LocationConstraint) : Request() - - @Parcelize data class SetWireGuardMtu(val mtu: Int?) : Request() - - @Parcelize data class SubmitVoucher(val voucher: String) : Request() - - @Parcelize data object InitPlayPurchase : Request() - - @Parcelize data class VerifyPlayPurchase(val playPurchase: PlayPurchase) : Request() - - @Parcelize data class UnregisterListener(val listenerId: Int) : Request() - - @Parcelize data class VpnPermissionResponse(val isGranted: Boolean) : Request() - - @Parcelize data class SetDnsOptions(val dnsOptions: DnsOptions) : Request() - - @Parcelize data class SetObfuscationSettings(val settings: ObfuscationSettings?) : Request() - - @Parcelize - data class SetWireguardConstraints(val wireguardConstraints: WireguardConstraints) : Request() - - @Parcelize - data class SetWireGuardQuantumResistant(val quantumResistant: QuantumResistantState) : - Request() - - @Parcelize data object FetchRelayList : Request() - - @Parcelize - data class SetOwnershipAndProviders( - val ownership: Constraint, - val providers: Constraint - ) : Request() - - @Parcelize data class CreateCustomList(val name: String) : Request() - - @Parcelize data class DeleteCustomList(val id: String) : Request() - - @Parcelize data class UpdateCustomList(val customList: CustomList) : Request() - - @Parcelize data object ClearAllRelayOverrides : Request() - - @Parcelize data class ApplyJsonSettings(val json: String) : Request() - - @Parcelize data object ExportJsonSettings : Request() - - @Parcelize data class SetRelayOverride(val override: RelayOverride) : Request() - - companion object { - private const val MESSAGE_KEY = "request" - - fun fromMessage(message: RawMessage): Request? = fromMessage(message, MESSAGE_KEY) - } -} diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/extensions/MessengerExtensions.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/extensions/MessengerExtensions.kt deleted file mode 100644 index 26cade5cb43c..000000000000 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/extensions/MessengerExtensions.kt +++ /dev/null @@ -1,40 +0,0 @@ -package net.mullvad.mullvadvpn.lib.ipc.extensions - -import android.os.DeadObjectException -import android.os.Message -import android.os.Messenger -import android.os.RemoteException -import android.util.Log -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request - -fun Messenger.trySendEvent(event: Event, logErrors: Boolean): Boolean { - return trySend(event.message, logErrors, event::class.qualifiedName) -} - -fun Messenger.trySendRequest(request: Request, logErrors: Boolean): Boolean { - return trySend(request.message, logErrors, request::class.qualifiedName) -} - -private fun Messenger.trySend(message: Message, logErrors: Boolean, messageName: String?): Boolean { - return try { - this.send(message) - true - } catch (deadObjectException: DeadObjectException) { - if (logErrors) { - Log.e( - "mullvad", - "Failed to send message ${messageName ?: ""} due to DeadObjectException" - ) - } - false - } catch (remoteException: RemoteException) { - if (logErrors) { - Log.e( - "mullvad", - "Failed to send message ${messageName ?: ""} due to RemoteException" - ) - } - false - } -} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt index 6ce7690bc59d..40477838259c 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt @@ -18,9 +18,9 @@ import net.mullvad.mullvadvpn.lib.map.internal.MAX_ANIMATION_MILLIS import net.mullvad.mullvadvpn.lib.map.internal.MAX_MULTIPLIER_PEAK_TIMING import net.mullvad.mullvadvpn.lib.map.internal.MIN_ANIMATION_MILLIS import net.mullvad.mullvadvpn.lib.map.internal.SHORT_ANIMATION_CUTOFF_MILLIS -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 @Composable fun animatedCameraPosition( diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt index a143a63cb818..b1ea1144f95a 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt @@ -13,7 +13,7 @@ import net.mullvad.mullvadvpn.lib.map.data.GlobeColors import net.mullvad.mullvadvpn.lib.map.data.MapViewState import net.mullvad.mullvadvpn.lib.map.data.Marker import net.mullvad.mullvadvpn.lib.map.internal.MapGLSurfaceView -import net.mullvad.mullvadvpn.model.LatLong +import net.mullvad.mullvadvpn.lib.model.LatLong @Composable fun Map( diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt index d837bcadfc66..b66b0ea65733 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt @@ -1,7 +1,7 @@ package net.mullvad.mullvadvpn.lib.map.data import androidx.compose.runtime.Immutable -import net.mullvad.mullvadvpn.model.LatLong +import net.mullvad.mullvadvpn.lib.model.LatLong @Immutable data class CameraPosition(val latLong: LatLong, val zoom: Float, val verticalBias: Float) diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt index 9f464612f1bf..4d26348d4580 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt @@ -1,7 +1,7 @@ package net.mullvad.mullvadvpn.lib.map.data import androidx.compose.runtime.Immutable -import net.mullvad.mullvadvpn.model.LatLong +import net.mullvad.mullvadvpn.lib.model.LatLong @Immutable data class Marker( diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt index 41ac903fb17c..887e64bebd8a 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt @@ -13,7 +13,7 @@ import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors import net.mullvad.mullvadvpn.lib.map.data.MapViewState import net.mullvad.mullvadvpn.lib.map.internal.shapes.Globe import net.mullvad.mullvadvpn.lib.map.internal.shapes.LocationMarker -import net.mullvad.mullvadvpn.model.toRadians +import net.mullvad.mullvadvpn.lib.model.toRadians internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.Renderer { diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt index 9d03a540c5d5..c67a0a1bb7f8 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt @@ -12,7 +12,7 @@ import net.mullvad.mullvadvpn.lib.map.internal.VERTEX_COMPONENT_SIZE import net.mullvad.mullvadvpn.lib.map.internal.initArrayBuffer import net.mullvad.mullvadvpn.lib.map.internal.initShaderProgram import net.mullvad.mullvadvpn.lib.map.internal.toFloatArray -import net.mullvad.mullvadvpn.model.LatLong +import net.mullvad.mullvadvpn.lib.model.LatLong internal class LocationMarker(val colors: LocationMarkerColors) { diff --git a/android/lib/model/build.gradle.kts b/android/lib/model/build.gradle.kts index 7264c6041a79..28a5804b5f4f 100644 --- a/android/lib/model/build.gradle.kts +++ b/android/lib/model/build.gradle.kts @@ -3,10 +3,11 @@ plugins { id(Dependencies.Plugin.junit5) version Versions.Plugin.junit5 id(Dependencies.Plugin.kotlinAndroidId) id(Dependencies.Plugin.kotlinParcelizeId) + id(Dependencies.Plugin.ksp) version Versions.Plugin.ksp } android { - namespace = "net.mullvad.mullvadvpn.model" + namespace = "net.mullvad.mullvadvpn.lib.model" compileSdk = Versions.Android.compileSdkVersion defaultConfig { @@ -29,11 +30,12 @@ android { } dependencies { - implementation(project(Dependencies.Mullvad.talpidLib)) - implementation(Dependencies.jodaTime) implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.KotlinX.coroutinesAndroid) + implementation(Dependencies.Arrow.core) + implementation(Dependencies.Arrow.optics) + ksp(Dependencies.Arrow.opticsKsp) // Test dependencies testRuntimeOnly(Dependencies.junitEngine) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountData.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountData.kt new file mode 100644 index 000000000000..60395721d845 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountData.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.lib.model + +import org.joda.time.DateTime + +data class AccountData( + val id: AccountId, + val expiryDate: DateTime, +) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountId.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountId.kt new file mode 100644 index 000000000000..75550259fd75 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountId.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.lib.model + +import java.util.UUID + +@JvmInline +value class AccountId(val value: UUID) { + companion object { + fun fromString(value: String) = AccountId(UUID.fromString(value)) + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountToken.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountToken.kt new file mode 100644 index 000000000000..d03a0d672105 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AccountToken.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@JvmInline @Parcelize value class AccountToken(val value: String) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ActionAfterDisconnect.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ActionAfterDisconnect.kt new file mode 100644 index 000000000000..531fc1c0735e --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ActionAfterDisconnect.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.lib.model + +enum class ActionAfterDisconnect { + Nothing, + Block, + Reconnect +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AddSplitTunnelingAppError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AddSplitTunnelingAppError.kt new file mode 100644 index 000000000000..338162db8c83 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AddSplitTunnelingAppError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +interface AddSplitTunnelingAppError { + data class Unknown(val throwable: Throwable) : AddSplitTunnelingAppError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AppId.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AppId.kt new file mode 100644 index 000000000000..0663b530a10d --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AppId.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +@JvmInline value class AppId(val value: String) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AppVersionInfo.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AppVersionInfo.kt new file mode 100644 index 000000000000..9af168bf28e8 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/AppVersionInfo.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class AppVersionInfo(val supported: Boolean, val suggestedUpgrade: String?) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/BuildVersion.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/BuildVersion.kt new file mode 100644 index 000000000000..980ea239611e --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/BuildVersion.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class BuildVersion(val name: String, val code: Int) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ClearAllOverridesError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ClearAllOverridesError.kt new file mode 100644 index 000000000000..ce1bc0af12dd --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ClearAllOverridesError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface ClearAllOverridesError { + data class Unknown(val throwable: Throwable) : ClearAllOverridesError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ConnectError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ConnectError.kt new file mode 100644 index 000000000000..307a235314c4 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ConnectError.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface ConnectError { + data class Unknown(val throwable: Throwable) : ConnectError + + data object NoVpnPermission : ConnectError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Constraint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Constraint.kt new file mode 100644 index 000000000000..95e7d95154f7 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Constraint.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.lib.model + +import arrow.optics.optics + +@optics +sealed interface Constraint { + data object Any : Constraint + + @optics + data class Only(val value: T) : Constraint { + companion object + } + + fun getOrNull(): T? = + when (this) { + Any -> null + is Only -> value + } + + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateAccountError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateAccountError.kt new file mode 100644 index 000000000000..eeeaf11fca0e --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateAccountError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed class CreateAccountError { + data class Unknown(val error: Throwable) : CreateAccountError() +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateCustomListError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateCustomListError.kt new file mode 100644 index 000000000000..adbac22d9b1c --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CreateCustomListError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface CreateCustomListError + +data object CustomListAlreadyExists : CreateCustomListError diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CustomDnsOptions.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CustomDnsOptions.kt new file mode 100644 index 000000000000..4fd64b289270 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CustomDnsOptions.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.lib.model + +import arrow.optics.optics +import java.net.InetAddress + +@optics +data class CustomDnsOptions(val addresses: List) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CustomList.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CustomList.kt new file mode 100644 index 000000000000..ed43ac1097a7 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CustomList.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.lib.model + +import arrow.optics.optics + +@optics +data class CustomList( + val id: CustomListId, + val name: CustomListName, + val locations: List +) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListName.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CustomListName.kt similarity index 91% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListName.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CustomListName.kt index 5822eec2b3a1..186d74dc92c1 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListName.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/CustomListName.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DefaultDnsOptions.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DefaultDnsOptions.kt similarity index 79% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DefaultDnsOptions.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DefaultDnsOptions.kt index 69f4d4d220dd..6979320ce66b 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DefaultDnsOptions.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DefaultDnsOptions.kt @@ -1,9 +1,8 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize +import arrow.optics.optics -@Parcelize +@optics data class DefaultDnsOptions( val blockAds: Boolean = false, val blockTrackers: Boolean = false, @@ -11,7 +10,7 @@ data class DefaultDnsOptions( val blockAdultContent: Boolean = false, val blockGambling: Boolean = false, val blockSocialMedia: Boolean = false, -) : Parcelable { +) { fun isAnyBlockerEnabled(): Boolean { return blockAds || blockTrackers || @@ -20,4 +19,6 @@ data class DefaultDnsOptions( blockGambling || blockSocialMedia } + + companion object } diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeleteCustomListError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeleteCustomListError.kt new file mode 100644 index 000000000000..d9c93c87cfd3 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeleteCustomListError.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface DeleteCustomListError diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeleteDeviceError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeleteDeviceError.kt new file mode 100644 index 000000000000..1c6c54bcf021 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeleteDeviceError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface DeleteDeviceError { + data class Unknown(val error: Throwable) : DeleteDeviceError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Device.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Device.kt new file mode 100644 index 000000000000..e8303f0eca7f --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Device.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.lib.model.extensions.startCase +import org.joda.time.DateTime + +@Parcelize +data class Device(val id: DeviceId, private val name: String, val creationDate: DateTime) : + Parcelable { + fun displayName(): String = name.startCase() +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeviceId.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeviceId.kt new file mode 100644 index 000000000000..863d15fd6747 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeviceId.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import java.util.UUID +import kotlinx.parcelize.Parcelize + +@JvmInline +@Parcelize +value class DeviceId(val value: UUID) : Parcelable { + companion object { + fun fromString(value: String): DeviceId = DeviceId(UUID.fromString(value)) + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeviceState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeviceState.kt new file mode 100644 index 000000000000..4546cd46b345 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DeviceState.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class DeviceState : Parcelable { + @Parcelize + data class LoggedIn(val accountToken: AccountToken, val device: Device) : DeviceState() + + @Parcelize data object LoggedOut : DeviceState() + + @Parcelize data object Revoked : DeviceState() + + fun displayName(): String? { + return (this as? LoggedIn)?.device?.displayName() + } + + fun token(): AccountToken? { + return (this as? LoggedIn)?.accountToken + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DnsOptions.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DnsOptions.kt similarity index 51% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DnsOptions.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DnsOptions.kt index 1ce3acc09592..ae27e4745776 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DnsOptions.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DnsOptions.kt @@ -1,11 +1,12 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize +import arrow.optics.optics -@Parcelize +@optics data class DnsOptions( val state: DnsState, val defaultOptions: DefaultDnsOptions, val customOptions: CustomDnsOptions -) : Parcelable +) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DnsState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DnsState.kt similarity index 54% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DnsState.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DnsState.kt index 9c8677ba7d62..4bf053eef1ae 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DnsState.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/DnsState.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model enum class DnsState { Default, diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/EnableSplitTunnelingError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/EnableSplitTunnelingError.kt new file mode 100644 index 000000000000..43a4cc41b1b4 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/EnableSplitTunnelingError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +interface EnableSplitTunnelingError { + data class Unknown(val throwable: Throwable) : EnableSplitTunnelingError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Endpoint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Endpoint.kt new file mode 100644 index 000000000000..4eae8b08ec97 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Endpoint.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +import java.net.InetSocketAddress + +data class Endpoint(val address: InetSocketAddress, val protocol: TransportProtocol) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ErrorState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ErrorState.kt new file mode 100644 index 000000000000..fb7673b7b5ae --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ErrorState.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class ErrorState(val cause: ErrorStateCause, val isBlocking: Boolean) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ErrorStateCause.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ErrorStateCause.kt new file mode 100644 index 000000000000..0ba63a4b0856 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ErrorStateCause.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.lib.model + +import java.net.InetAddress + +sealed class ErrorStateCause { + class AuthFailed(private val reason: String?) : ErrorStateCause() { + fun isCausedByExpiredAccount(): Boolean { + return reason == AUTH_FAILED_REASON_EXPIRED_ACCOUNT + } + + companion object { + private const val AUTH_FAILED_REASON_EXPIRED_ACCOUNT = "[EXPIRED_ACCOUNT]" + } + } + + data object Ipv6Unavailable : ErrorStateCause() + + sealed class FirewallPolicyError : ErrorStateCause() { + data object Generic : FirewallPolicyError() + } + + data object DnsError : ErrorStateCause() + + // Regression + data class InvalidDnsServers(val addresses: List) : ErrorStateCause() + + data object StartTunnelError : ErrorStateCause() + + data class TunnelParameterError(val error: ParameterGenerationError) : ErrorStateCause() + + data object IsOffline : ErrorStateCause() + + data object VpnPermissionDenied : ErrorStateCause() +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GeoIpLocation.kt similarity index 65% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GeoIpLocation.kt index 625de76b291f..3334b458d7a7 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeoIpLocation.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GeoIpLocation.kt @@ -1,10 +1,7 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model -import android.os.Parcelable import java.net.InetAddress -import kotlinx.parcelize.Parcelize -@Parcelize data class GeoIpLocation( val ipv4: InetAddress?, val ipv6: InetAddress?, @@ -13,4 +10,4 @@ data class GeoIpLocation( val latitude: Double, val longitude: Double, val hostname: String?, -) : Parcelable +) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetAccountDataError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetAccountDataError.kt new file mode 100644 index 000000000000..6f3ba64848e8 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetAccountDataError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface GetAccountDataError { + data class Unknown(val error: Throwable) : GetAccountDataError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetAccountHistoryError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetAccountHistoryError.kt new file mode 100644 index 000000000000..7803a98ad1d2 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetAccountHistoryError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface GetAccountHistoryError { + data class Unknown(val error: Throwable) : GetAccountHistoryError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetDeviceListError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetDeviceListError.kt new file mode 100644 index 000000000000..bcad0165802f --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetDeviceListError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface GetDeviceListError { + data class Unknown(val error: Throwable) : GetDeviceListError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetDeviceStateError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetDeviceStateError.kt new file mode 100644 index 000000000000..675973ee1efe --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GetDeviceStateError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface GetDeviceStateError { + data class Unknown(val error: Throwable) : GetDeviceStateError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LatLong.kt similarity index 94% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LatLong.kt index d6749a16a2d2..19f757ffc3cd 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LatLong.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LatLong.kt @@ -1,9 +1,9 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model import kotlin.math.cos import kotlin.math.pow import kotlin.math.sqrt -import net.mullvad.mullvadvpn.model.Latitude.Companion.mean +import net.mullvad.mullvadvpn.lib.model.Latitude.Companion.mean data class LatLong(val latitude: Latitude, val longitude: Longitude) { diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Latitude.kt similarity index 98% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Latitude.kt index 21d113f3bcd9..9b0cc7fbbe95 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Latitude.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Latitude.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model import kotlin.math.absoluteValue diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ListDevicesError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ListDevicesError.kt new file mode 100644 index 000000000000..6530450d425d --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ListDevicesError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +interface ListDevicesError { + data class Unknown(val throwable: Throwable) : ListDevicesError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginAccountError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginAccountError.kt new file mode 100644 index 000000000000..1c58f80beee8 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/LoginAccountError.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class LoginAccountError : Parcelable { + data object InvalidAccount : LoginAccountError() + + data class MaxDevicesReached(val accountToken: AccountToken) : LoginAccountError() + + data object RpcError : LoginAccountError() + + data class Unknown(val error: Throwable) : LoginAccountError() +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Longitude.kt similarity index 97% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Longitude.kt index 9f73a6ff17bb..b772801da7f0 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Longitude.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Longitude.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model import kotlin.math.absoluteValue diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Mtu.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Mtu.kt new file mode 100644 index 000000000000..68b4b71bd9ff --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Mtu.kt @@ -0,0 +1,28 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import kotlinx.parcelize.Parcelize + +@JvmInline +@Parcelize +value class Mtu(val value: Int) : Parcelable { + companion object { + fun fromString(value: String): Either = either { + val number = value.toIntOrNull() ?: raise(ParseMtuError.NotANumber) + ensure(number in MIN_VALUE..MAX_VALUE) { ParseMtuError.OutOfRange(number) } + Mtu(number) + } + + private const val MIN_VALUE = 1280 + private const val MAX_VALUE = 1420 + } +} + +sealed interface ParseMtuError { + data object NotANumber : ParseMtuError + + data class OutOfRange(val number: Int) : ParseMtuError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Notification.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Notification.kt new file mode 100644 index 000000000000..5dda03aa9dd7 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Notification.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.lib.model + +import org.joda.time.Duration + +sealed interface Notification { + val actions: List + val ongoing: Boolean + val channelId: NotificationChannelId + + data class Tunnel( + override val channelId: NotificationChannelId, + val state: NotificationTunnelState, + override val actions: List, + override val ongoing: Boolean, + ) : Notification + + data class AccountExpiry( + override val channelId: NotificationChannelId, + override val actions: List, + val websiteAuthToken: WebsiteAuthToken?, + val durationUntilExpiry: Duration + ) : Notification { + override val ongoing: Boolean = false + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationAction.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationAction.kt new file mode 100644 index 000000000000..ec938a9fbf67 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationAction.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface NotificationAction { + + sealed interface AccountExpiry : NotificationAction { + data object Open : AccountExpiry + } + + sealed interface Tunnel : NotificationAction { + data object Connect : Tunnel + + data object Disconnect : Tunnel + + data object Cancel : Tunnel + + data object Dismiss : Tunnel + + data object RequestPermission : Tunnel + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationChannel.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationChannel.kt new file mode 100644 index 000000000000..166c20b82660 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationChannel.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface NotificationChannel { + val id: NotificationChannelId + + data object TunnelUpdates : NotificationChannel { + private const val CHANNEL_ID = "vpn_tunnel_status" + override val id: NotificationChannelId = NotificationChannelId(CHANNEL_ID) + } + + data object AccountUpdates : NotificationChannel { + private const val CHANNEL_ID = "mullvad_account_time" + override val id: NotificationChannelId = NotificationChannelId(CHANNEL_ID) + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationChannelId.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationChannelId.kt new file mode 100644 index 000000000000..c4231deb8cf1 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationChannelId.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +@JvmInline value class NotificationChannelId(val value: String) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationId.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationId.kt new file mode 100644 index 000000000000..9c20bf942055 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationId.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +@JvmInline value class NotificationId(val value: Int) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationTunnelState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationTunnelState.kt new file mode 100644 index 000000000000..fffe86c247b8 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationTunnelState.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface NotificationTunnelState { + data class Disconnected(val hasVpnPermission: Boolean) : NotificationTunnelState + + data object Connecting : NotificationTunnelState + + data object Connected : NotificationTunnelState + + data object Reconnecting : NotificationTunnelState + + data object Disconnecting : NotificationTunnelState + + sealed interface Error : NotificationTunnelState { + data object DeviceOffline : Error + + data object Blocking : Error + + data object VpnPermissionDenied : Error + + data object AlwaysOnVpn : Error + + data object Critical : Error + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationUpdate.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationUpdate.kt new file mode 100644 index 000000000000..00d64cbc3e7b --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/NotificationUpdate.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface NotificationUpdate { + val notificationId: NotificationId + + data class Notify(override val notificationId: NotificationId, val value: D) : + NotificationUpdate + + data class Cancel(override val notificationId: NotificationId) : NotificationUpdate +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ObfuscationEndpoint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ObfuscationEndpoint.kt new file mode 100644 index 000000000000..020ef8e5c185 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ObfuscationEndpoint.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class ObfuscationEndpoint(val endpoint: Endpoint, val obfuscationType: ObfuscationType) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/ObfuscationSettings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ObfuscationSettings.kt similarity index 50% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/ObfuscationSettings.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ObfuscationSettings.kt index 19b5c0e5f213..b8a26973a25d 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/ObfuscationSettings.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ObfuscationSettings.kt @@ -1,10 +1,11 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize +import arrow.optics.optics -@Parcelize +@optics data class ObfuscationSettings( val selectedObfuscation: SelectedObfuscation, val udp2tcp: Udp2TcpObfuscationSettings -) : Parcelable +) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ObfuscationType.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ObfuscationType.kt new file mode 100644 index 000000000000..cd71d645afde --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ObfuscationType.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +enum class ObfuscationType { + Udp2Tcp +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Ownership.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Ownership.kt new file mode 100644 index 000000000000..5257f944d309 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Ownership.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.lib.model + +enum class Ownership { + MullvadOwned, + Rented +} diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ParameterGenerationError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ParameterGenerationError.kt similarity index 77% rename from android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ParameterGenerationError.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ParameterGenerationError.kt index b1504c676fb7..476aed1407ab 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ParameterGenerationError.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ParameterGenerationError.kt @@ -1,4 +1,4 @@ -package net.mullvad.talpid.tunnel +package net.mullvad.mullvadvpn.lib.model enum class ParameterGenerationError { NoMatchingRelay, diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchase.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchase.kt new file mode 100644 index 000000000000..9384f9f5b89f --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchase.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class PlayPurchase(val productId: String, val purchaseToken: PlayPurchasePaymentToken) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchaseInitError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchaseInitError.kt new file mode 100644 index 000000000000..6326bab8e885 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchaseInitError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +enum class PlayPurchaseInitError { + OtherError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchasePaymentToken.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchasePaymentToken.kt new file mode 100644 index 000000000000..bfcae64d451d --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchasePaymentToken.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +@JvmInline value class PlayPurchasePaymentToken(val value: String) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchaseVerifyError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchaseVerifyError.kt new file mode 100644 index 000000000000..dc06b8ffbf2d --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PlayPurchaseVerifyError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +enum class PlayPurchaseVerifyError { + OtherError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Port.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Port.kt new file mode 100644 index 000000000000..bcb5a8dd9964 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Port.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@JvmInline @Parcelize value class Port(val value: Int) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PortRange.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PortRange.kt new file mode 100644 index 000000000000..77767a1011a4 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/PortRange.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcel +import android.os.Parcelable +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler + +@JvmInline +@Parcelize +@TypeParceler +value class PortRange(val value: IntRange) : Parcelable { + operator fun contains(port: Port): Boolean = port.value in value + + fun toFormattedString(): String = + if (value.first == value.last) { + value.first.toString() + } else { + "${value.first}-${value.last}" + } +} + +object IntRangeParceler : Parceler { + override fun create(parcel: Parcel) = IntRange(parcel.readInt(), parcel.readInt()) + + override fun IntRange.write(parcel: Parcel, flags: Int) { + parcel.writeInt(start) + parcel.writeInt(endInclusive) + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Provider.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Provider.kt new file mode 100644 index 000000000000..e704e9554d79 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Provider.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class Provider(val providerId: ProviderId, val ownership: Ownership) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ProviderId.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ProviderId.kt new file mode 100644 index 000000000000..cc23c3e9b65f --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/ProviderId.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +@JvmInline value class ProviderId(val value: String) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Providers.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Providers.kt new file mode 100644 index 000000000000..73cf9facdbf7 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Providers.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.lib.model + +import arrow.optics.optics + +@optics +data class Providers(val providers: Set) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/QuantumResistantState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/QuantumResistantState.kt new file mode 100644 index 000000000000..c77dab72d390 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/QuantumResistantState.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.lib.model + +enum class QuantumResistantState { + Auto, + On, + Off +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RedeemVoucherError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RedeemVoucherError.kt new file mode 100644 index 000000000000..d14a2f236b16 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RedeemVoucherError.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed class RedeemVoucherError { + data object InvalidVoucher : RedeemVoucherError() + + data object VoucherAlreadyUsed : RedeemVoucherError() + + data object RpcError : RedeemVoucherError() + + data class Unknown(val error: Throwable) : RedeemVoucherError() +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RedeemVoucherSuccess.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RedeemVoucherSuccess.kt new file mode 100644 index 000000000000..9c81042b8c82 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RedeemVoucherSuccess.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +import org.joda.time.DateTime + +data class RedeemVoucherSuccess(val timeAdded: Long, val newExpiryDate: DateTime) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayConstraints.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayConstraints.kt new file mode 100644 index 000000000000..f3573933e3ce --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayConstraints.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.lib.model + +import arrow.optics.optics + +@optics +data class RelayConstraints( + val location: Constraint, + val providers: Constraint, + val ownership: Constraint, + val wireguardConstraints: WireguardConstraints, +) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt new file mode 100644 index 000000000000..a31a6f67df02 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItem.kt @@ -0,0 +1,88 @@ +package net.mullvad.mullvadvpn.lib.model + +import arrow.optics.optics + +@optics +sealed interface RelayItem { + val id: RelayItemId + val name: String + val active: Boolean + val hasChildren: Boolean + val expanded: Boolean + + @optics + data class CustomList( + override val id: CustomListId, + val customListName: CustomListName, + val locations: List, + override val expanded: Boolean, + ) : RelayItem { + override val name: String = customListName.value + + override val active + get() = locations.any { location -> location.active } + + override val hasChildren + get() = locations.isNotEmpty() + + companion object + } + + @optics + sealed interface Location : RelayItem { + override val id: GeoLocationId + + @optics + data class Country( + override val id: GeoLocationId.Country, + override val name: String, + override val expanded: Boolean, + val cities: List + ) : Location { + val relays = cities.flatMap { city -> city.relays } + + override val active + get() = cities.any { city -> city.active } + + override val hasChildren + get() = cities.isNotEmpty() + + companion object + } + + @optics + data class City( + override val id: GeoLocationId.City, + override val name: String, + override val expanded: Boolean, + val relays: List + ) : Location { + + override val active + get() = relays.any { relay -> relay.active } + + override val hasChildren + get() = relays.isNotEmpty() + + companion object + } + + @optics + data class Relay( + override val id: GeoLocationId.Hostname, + val provider: Provider, + override val active: Boolean, + ) : Location { + override val name: String = id.hostname + + override val hasChildren = false + override val expanded = false + + companion object + } + + companion object + } + + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemId.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemId.kt new file mode 100644 index 000000000000..da59481269ad --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayItemId.kt @@ -0,0 +1,48 @@ +package net.mullvad.mullvadvpn.lib.model + +import android.os.Parcelable +import arrow.optics.optics +import kotlinx.parcelize.Parcelize + +@optics +sealed interface RelayItemId : Parcelable { + companion object +} + +@optics +@Parcelize +@JvmInline +value class CustomListId(val value: String) : RelayItemId, Parcelable { + companion object +} + +@optics +sealed interface GeoLocationId : RelayItemId { + @optics + @Parcelize + data class Country(val countryCode: String) : GeoLocationId { + companion object + } + + @optics + @Parcelize + data class City(val countryCode: Country, val cityCode: String) : GeoLocationId { + companion object + } + + @optics + @Parcelize + data class Hostname(val city: City, val hostname: String) : GeoLocationId { + companion object + } + + val country: Country + get() = + when (this) { + is Country -> this + is City -> this.countryCode + is Hostname -> this.city.countryCode + } + + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayList.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayList.kt new file mode 100644 index 000000000000..39e43a713ede --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayList.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.lib.model + +data class RelayList( + val countries: List, + val wireguardEndpointData: WireguardEndpointData +) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayOverride.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayOverride.kt similarity index 54% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayOverride.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayOverride.kt index f738218ee727..3bd0a2f0a1d9 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayOverride.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelayOverride.kt @@ -1,12 +1,13 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model -import android.os.Parcelable +import arrow.optics.optics import java.net.InetAddress -import kotlinx.parcelize.Parcelize -@Parcelize +@optics data class RelayOverride( val hostname: String, val ipv4AddressIn: InetAddress?, val ipv6AddressIn: InetAddress? -) : Parcelable +) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelaySettings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelaySettings.kt new file mode 100644 index 000000000000..ea40c980d034 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RelaySettings.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.lib.model + +import arrow.optics.optics + +@optics +data class RelaySettings(val relayConstraints: RelayConstraints) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RemoveDeviceError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RemoveDeviceError.kt new file mode 100644 index 000000000000..d00272ec6319 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RemoveDeviceError.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface RemoveDeviceError { + data object NotFound : RemoveDeviceError + + data object RpcError : RemoveDeviceError + + data class Unknown(val throwable: Throwable) : RemoveDeviceError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RemoveSplitTunnelingAppError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RemoveSplitTunnelingAppError.kt new file mode 100644 index 000000000000..aa4dcfd8beb5 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/RemoveSplitTunnelingAppError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +interface RemoveSplitTunnelingAppError { + data class Unknown(val throwable: Throwable) : RemoveSplitTunnelingAppError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SelectedObfuscation.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SelectedObfuscation.kt new file mode 100644 index 000000000000..1651d61db7ab --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SelectedObfuscation.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.lib.model + +enum class SelectedObfuscation { + Auto, + Off, + Udp2Tcp +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAllowLanError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAllowLanError.kt new file mode 100644 index 000000000000..e30eba0d9e4c --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAllowLanError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface SetAllowLanError { + data class Unknown(val throwable: Throwable) : SetAllowLanError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAutoConnectError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAutoConnectError.kt new file mode 100644 index 000000000000..b2b3b74edf36 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAutoConnectError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface SetAutoConnectError { + data class Unknown(val throwable: Throwable) : SetAutoConnectError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetDnsOptionsError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetDnsOptionsError.kt new file mode 100644 index 000000000000..8d72d8cebe12 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetDnsOptionsError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface SetDnsOptionsError { + data class Unknown(val throwable: Throwable) : SetDnsOptionsError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetObfuscationOptionsError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetObfuscationOptionsError.kt new file mode 100644 index 000000000000..d9c5acf65001 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetObfuscationOptionsError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface SetObfuscationOptionsError { + data class Unknown(val throwable: Throwable) : SetObfuscationOptionsError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetRelayLocationError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetRelayLocationError.kt new file mode 100644 index 000000000000..4606c46125cc --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetRelayLocationError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface SetRelayLocationError { + data class Unknown(val throwable: Throwable) : SetRelayLocationError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetWireguardConstraintsError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetWireguardConstraintsError.kt new file mode 100644 index 000000000000..ccf8b4c8dc16 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetWireguardConstraintsError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface SetWireguardConstraintsError { + data class Unknown(val throwable: Throwable) : SetWireguardConstraintsError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetWireguardMtuError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetWireguardMtuError.kt new file mode 100644 index 000000000000..ca4f135fb15b --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetWireguardMtuError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface SetWireguardMtuError { + data class Unknown(val throwable: Throwable) : SetWireguardMtuError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetWireguardQuantumResistantError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetWireguardQuantumResistantError.kt new file mode 100644 index 000000000000..8121120c67c8 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetWireguardQuantumResistantError.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface SetWireguardQuantumResistantError { + data class Unknown(val throwable: Throwable) : SetWireguardQuantumResistantError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt similarity index 50% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt index 847b80cd7021..c5191531bead 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Settings.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt @@ -1,16 +1,18 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize +import arrow.optics.optics -@Parcelize +@optics data class Settings( val relaySettings: RelaySettings, val obfuscationSettings: ObfuscationSettings, - val customLists: CustomListsSettings, + val customLists: List, val allowLan: Boolean, val autoConnect: Boolean, val tunnelOptions: TunnelOptions, - val relayOverrides: ArrayList, + val relayOverrides: List, val showBetaReleases: Boolean, -) : Parcelable + val splitTunnelSettings: SplitTunnelSettings +) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SettingsPatchError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SettingsPatchError.kt similarity index 78% rename from android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SettingsPatchError.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SettingsPatchError.kt index 5e3cb29911a8..1db1dc6f68a4 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SettingsPatchError.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SettingsPatchError.kt @@ -1,10 +1,6 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -sealed class SettingsPatchError : Parcelable { +sealed class SettingsPatchError { // E.g hostname is number instead of String data class InvalidOrMissingValue(val value: String) : SettingsPatchError() diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SplitTunnelSettings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SplitTunnelSettings.kt new file mode 100644 index 000000000000..a937d53baec1 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SplitTunnelSettings.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class SplitTunnelSettings(val enabled: Boolean, val excludedApps: Set) diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/TransportProtocol.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TransportProtocol.kt similarity index 77% rename from android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/TransportProtocol.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TransportProtocol.kt index 89fdedaba1d8..b25e3061bea5 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/TransportProtocol.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TransportProtocol.kt @@ -1,4 +1,4 @@ -package net.mullvad.talpid.net +package net.mullvad.mullvadvpn.lib.model import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/TunnelEndpoint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelEndpoint.kt similarity index 52% rename from android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/TunnelEndpoint.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelEndpoint.kt index 9c45833eb2b0..d715f1676610 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/TunnelEndpoint.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelEndpoint.kt @@ -1,11 +1,7 @@ -package net.mullvad.talpid.net +package net.mullvad.mullvadvpn.lib.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class TunnelEndpoint( val endpoint: Endpoint, val quantumResistant: Boolean, val obfuscation: ObfuscationEndpoint? -) : Parcelable +) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelOptions.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelOptions.kt new file mode 100644 index 000000000000..de1d760d3080 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelOptions.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.lib.model + +import arrow.optics.optics + +@optics +data class TunnelOptions(val wireguard: WireguardTunnelOptions, val dnsOptions: DnsOptions) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelState.kt new file mode 100644 index 000000000000..3fae41802a2c --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/TunnelState.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed class TunnelState { + data class Disconnected(val location: GeoIpLocation? = null) : TunnelState() + + data class Connecting(val endpoint: TunnelEndpoint?, val location: GeoIpLocation?) : + TunnelState() + + data class Connected(val endpoint: TunnelEndpoint, val location: GeoIpLocation?) : + TunnelState() + + data class Disconnecting(val actionAfterDisconnect: ActionAfterDisconnect) : TunnelState() + + data class Error(val errorState: ErrorState) : TunnelState() + + fun location(): GeoIpLocation? { + return when (this) { + is Connected -> location + is Connecting -> location + is Disconnecting -> null + is Disconnected -> location + is Error -> null + } + } + + fun isSecured(): Boolean { + return when (this) { + is Connected, + is Connecting, + is Disconnecting, -> true + is Disconnected -> false + is Error -> this.errorState.isBlocking + } + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Udp2TcpObfuscationSettings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Udp2TcpObfuscationSettings.kt new file mode 100644 index 000000000000..7447f7a4cf71 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Udp2TcpObfuscationSettings.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class Udp2TcpObfuscationSettings(val port: Constraint) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/UpdateCustomListError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/UpdateCustomListError.kt new file mode 100644 index 000000000000..ef49018dca3e --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/UpdateCustomListError.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.lib.model + +sealed interface UpdateCustomListNameError { + companion object { + fun from(error: UpdateCustomListError): UpdateCustomListNameError = + when (error) { + is NameAlreadyExists -> error + is UnknownCustomListError -> error + } + } +} + +sealed interface UpdateCustomListLocationsError { + companion object { + fun from(error: UpdateCustomListError): UpdateCustomListLocationsError = + when (error) { + is NameAlreadyExists -> error("Not supported error") + is UnknownCustomListError -> error + } + } +} + +sealed interface UpdateCustomListError + +data class NameAlreadyExists(val name: String) : UpdateCustomListError, UpdateCustomListNameError + +data class UnknownCustomListError(val throwable: Throwable) : + UpdateCustomListError, + UpdateCustomListNameError, + UpdateCustomListLocationsError, + CreateCustomListError, + DeleteCustomListError + +data class GetCustomListError(val id: CustomListId) : + UpdateCustomListLocationsError, UpdateCustomListNameError diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WebsiteAuthToken.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WebsiteAuthToken.kt new file mode 100644 index 000000000000..8ad9b8578789 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WebsiteAuthToken.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.lib.model + +@JvmInline +value class WebsiteAuthToken private constructor(val value: String) { + companion object { + fun fromString(value: String) = WebsiteAuthToken(value) + } +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt new file mode 100644 index 000000000000..8affb8107731 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardConstraints.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.lib.model + +import arrow.optics.optics + +@optics +data class WireguardConstraints(val port: Constraint) { + companion object +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardEndpointData.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardEndpointData.kt new file mode 100644 index 000000000000..8aff7d2895bd --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardEndpointData.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class WireguardEndpointData(val portRanges: List) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardTunnelOptions.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardTunnelOptions.kt new file mode 100644 index 000000000000..573f08213ef0 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/WireguardTunnelOptions.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.model + +data class WireguardTunnelOptions(val mtu: Mtu?, val quantumResistant: QuantumResistantState) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/extensions/String.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/extensions/String.kt new file mode 100644 index 000000000000..0df57eb057f0 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/extensions/String.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.lib.model.extensions + +fun String.startCase() = + split(" ").joinToString(" ") { word -> + word.replaceFirstChar { firstChar -> firstChar.uppercase() } + } diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountAndDevice.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountAndDevice.kt deleted file mode 100644 index f5137ebbb72c..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountAndDevice.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class AccountAndDevice(val account_token: String, val device: Device) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountCreationResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountCreationResult.kt deleted file mode 100644 index 4bb4c613842d..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountCreationResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class AccountCreationResult : Parcelable { - @Parcelize data class Success(val accountToken: String) : AccountCreationResult() - - @Parcelize object Failure : AccountCreationResult() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountData.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountData.kt deleted file mode 100644 index 6dda6b835251..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountData.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.mullvad.mullvadvpn.model - -data class AccountData(val expiry: String) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt deleted file mode 100644 index f856ef8c8961..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt +++ /dev/null @@ -1,15 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import org.joda.time.DateTime - -sealed class AccountExpiry : Parcelable { - @Parcelize data class Available(val expiryDateTime: DateTime) : AccountExpiry() - - @Parcelize data object Missing : AccountExpiry() - - fun date(): DateTime? { - return (this as? Available)?.expiryDateTime - } -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt deleted file mode 100644 index f003ee316ba9..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt +++ /dev/null @@ -1,12 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class AccountHistory : Parcelable { - @Parcelize data class Available(val accountToken: String) : AccountHistory() - - @Parcelize object Missing : AccountHistory() - - fun accountToken() = (this as? Available)?.accountToken -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt deleted file mode 100644 index 2aeca352d088..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.mullvad.mullvadvpn.model - -@JvmInline value class AccountToken(val value: String) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AppVersionInfo.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AppVersionInfo.kt deleted file mode 100644 index bbe99ce65607..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AppVersionInfo.kt +++ /dev/null @@ -1,7 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class AppVersionInfo(val supported: Boolean, val suggestedUpgrade: String?) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Constraint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Constraint.kt deleted file mode 100644 index d9ca22b164a0..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Constraint.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class Constraint : Parcelable { - @Parcelize @Suppress("PARCELABLE_PRIMARY_CONSTRUCTOR_IS_EMPTY") class Any : Constraint() - - @Parcelize data class Only(val value: T) : Constraint() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CreateCustomListResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CreateCustomListResult.kt deleted file mode 100644 index 73eaa209c8b5..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CreateCustomListResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class CreateCustomListResult : Parcelable { - @Parcelize data class Ok(val id: String) : CreateCustomListResult() - - @Parcelize data class Error(val error: CustomListsError) : CreateCustomListResult() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomDnsOptions.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomDnsOptions.kt deleted file mode 100644 index bbf029dd4d0b..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomDnsOptions.kt +++ /dev/null @@ -1,7 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import java.net.InetAddress -import kotlinx.parcelize.Parcelize - -@Parcelize data class CustomDnsOptions(val addresses: ArrayList) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomList.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomList.kt deleted file mode 100644 index cdfa1b96878f..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomList.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class CustomList( - val id: String, - val name: String, - val locations: ArrayList -) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsError.kt deleted file mode 100644 index 83806af4f74b..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsError.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -enum class CustomListsError { - CustomListExists, - OtherError -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsSettings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsSettings.kt deleted file mode 100644 index 8a8c03ef054b..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsSettings.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class CustomListsSettings(val customLists: ArrayList) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomTunnelEndpoint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomTunnelEndpoint.kt deleted file mode 100644 index 72276c65e438..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomTunnelEndpoint.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.mullvad.mullvadvpn.model - -class CustomTunnelEndpoint diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Device.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Device.kt deleted file mode 100644 index 0f0a55d05dad..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Device.kt +++ /dev/null @@ -1,40 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class Device( - val id: String, - private val name: String, - val pubkey: ByteArray, - val created: String -) : Parcelable { - // Generated by Android Studio - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Device - - if (id != other.id) return false - if (name != other.name) return false - return pubkey.contentEquals(other.pubkey) - } - - // Generated by Android Studio - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + name.hashCode() - result = 31 * result + pubkey.contentHashCode() - return result - } - - fun displayName(): String = name.capitalizeFirstCharOfEachWord() -} - -private fun String.capitalizeFirstCharOfEachWord(): String { - return split(" ") - .joinToString(" ") { word -> word.replaceFirstChar { firstChar -> firstChar.uppercase() } } - .trimEnd() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceEvent.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceEvent.kt deleted file mode 100644 index 741108612d38..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceEvent.kt +++ /dev/null @@ -1,7 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class DeviceEvent(val cause: DeviceEventCause, val newState: DeviceState) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceEventCause.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceEventCause.kt deleted file mode 100644 index b4c1d21761e7..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceEventCause.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class DeviceEventCause : Parcelable { - LoggedIn, - LoggedOut, - Revoked, - Updated, - RotatedKey -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceList.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceList.kt deleted file mode 100644 index afe5982ed5af..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceList.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.mullvad.mullvadvpn.model - -sealed class DeviceList { - object Unavailable : DeviceList() - - data class Available(val devices: List) : DeviceList() - - object Error : DeviceList() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceListEvent.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceListEvent.kt deleted file mode 100644 index 7a2883617bdf..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceListEvent.kt +++ /dev/null @@ -1,15 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class DeviceListEvent : Parcelable { - @Parcelize - data class Available(val accountToken: String, val devices: List) : DeviceListEvent() - - @Parcelize object Error : DeviceListEvent() - - fun isAvailable(): Boolean { - return (this is Available) - } -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DevicePort.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DevicePort.kt deleted file mode 100644 index e43eae3e6bab..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DevicePort.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class DevicePort(val id: String) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt deleted file mode 100644 index fb34c9e64588..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt +++ /dev/null @@ -1,28 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class DeviceState : Parcelable { - @Parcelize object Initial : DeviceState() - - @Parcelize object Unknown : DeviceState() - - @Parcelize data class LoggedIn(val accountAndDevice: AccountAndDevice) : DeviceState() - - @Parcelize object LoggedOut : DeviceState() - - @Parcelize object Revoked : DeviceState() - - fun isUnknown(): Boolean { - return this is Unknown - } - - fun deviceName(): String? { - return (this as? LoggedIn)?.accountAndDevice?.device?.displayName() - } - - fun token(): String? { - return (this as? LoggedIn)?.accountAndDevice?.account_token - } -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt deleted file mode 100644 index 386257a72aa3..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GeographicLocationConstraint.kt +++ /dev/null @@ -1,28 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class GeographicLocationConstraint : Parcelable { - abstract val location: GeoIpLocation - - @Parcelize - data class Country(val countryCode: String) : GeographicLocationConstraint() { - override val location: GeoIpLocation - get() = GeoIpLocation(null, null, countryCode, null, 0.0, 0.0, null) - } - - @Parcelize - data class City(val countryCode: String, val cityCode: String) : - GeographicLocationConstraint() { - override val location: GeoIpLocation - get() = GeoIpLocation(null, null, countryCode, cityCode, 0.0, 0.0, null) - } - - @Parcelize - data class Hostname(val countryCode: String, val cityCode: String, val hostname: String) : - GeographicLocationConstraint() { - override val location: GeoIpLocation - get() = GeoIpLocation(null, null, countryCode, cityCode, 0.0, 0.0, hostname) - } -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GetAccountDataResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GetAccountDataResult.kt deleted file mode 100644 index 2e94266e2ad7..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/GetAccountDataResult.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.mullvad.mullvadvpn.model - -sealed class GetAccountDataResult { - class Ok(val accountData: AccountData) : GetAccountDataResult() - - object InvalidAccount : GetAccountDataResult() - - object RpcError : GetAccountDataResult() - - object OtherError : GetAccountDataResult() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LocationConstraint.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LocationConstraint.kt deleted file mode 100644 index 0c9d331e3b88..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LocationConstraint.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class LocationConstraint : Parcelable { - @Parcelize - data class Location(val location: GeographicLocationConstraint) : LocationConstraint() - - @Parcelize data class CustomList(val listId: String) : LocationConstraint() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LoginResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LoginResult.kt deleted file mode 100644 index 29fb68203df9..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/LoginResult.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class LoginResult : Parcelable { - Ok, - InvalidAccount, - MaxDevicesReached, - RpcError, - OtherError -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Ownership.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Ownership.kt deleted file mode 100644 index 43037be67624..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Ownership.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class Ownership : Parcelable { - MullvadOwned, - Rented -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchase.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchase.kt deleted file mode 100644 index 8ae46a07a9b6..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchase.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class PlayPurchase(val productId: String, val purchaseToken: String) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitError.kt deleted file mode 100644 index 39aebabbe260..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitError.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class PlayPurchaseInitError : Parcelable { - // TODO: Add more errors here. - OtherError -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitResult.kt deleted file mode 100644 index 41407474af7c..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class PlayPurchaseInitResult : Parcelable { - @Parcelize data class Ok(val obfuscatedId: String) : PlayPurchaseInitResult() - - @Parcelize data class Error(val error: PlayPurchaseInitError) : PlayPurchaseInitResult() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyError.kt deleted file mode 100644 index b0434c22f969..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyError.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class PlayPurchaseVerifyError : Parcelable { - // TODO: Add more errors here. - OtherError -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyResult.kt deleted file mode 100644 index 7c5ee4d95314..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class PlayPurchaseVerifyResult : Parcelable { - @Parcelize data object Ok : PlayPurchaseVerifyResult() - - @Parcelize data class Error(val error: PlayPurchaseVerifyError) : PlayPurchaseVerifyResult() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Port.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Port.kt deleted file mode 100644 index 52f495a7a7fd..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Port.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class Port(val value: Int) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PortRange.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PortRange.kt deleted file mode 100644 index 376f5ef7a4f4..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PortRange.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class PortRange(val from: Int, val to: Int) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Providers.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Providers.kt deleted file mode 100644 index d3c6aacba98e..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Providers.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Suppress("ensure value classes property is named value") -@JvmInline -@Parcelize -value class Providers(val providers: HashSet) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PublicKey.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PublicKey.kt deleted file mode 100644 index 169b6c3856fb..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PublicKey.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class PublicKey(val key: ByteArray, val dateCreated: String) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/QuantumResistantState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/QuantumResistantState.kt deleted file mode 100644 index a19267388a43..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/QuantumResistantState.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class QuantumResistantState : Parcelable { - Auto, - On, - Off -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Relay.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Relay.kt deleted file mode 100644 index 461648209c37..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Relay.kt +++ /dev/null @@ -1,16 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class Relay( - val hostname: String, - val active: Boolean, - val owned: Boolean, - val provider: String, - val endpointData: RelayEndpointData -) : Parcelable { - val isWireguardRelay - get() = endpointData is RelayEndpointData.Wireguard -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayConstraints.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayConstraints.kt deleted file mode 100644 index 031b09bacecc..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayConstraints.kt +++ /dev/null @@ -1,12 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class RelayConstraints( - val location: Constraint, - val providers: Constraint, - val ownership: Constraint, - val wireguardConstraints: WireguardConstraints, -) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayEndpointData.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayEndpointData.kt deleted file mode 100644 index 86b3f0fa35df..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayEndpointData.kt +++ /dev/null @@ -1,14 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class RelayEndpointData : Parcelable { - @Parcelize object Openvpn : RelayEndpointData() - - @Parcelize object Bridge : RelayEndpointData() - - @Parcelize - data class Wireguard(val wireguardRelayEndpointData: WireguardRelayEndpointData) : - RelayEndpointData() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayList.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayList.kt deleted file mode 100644 index 60d8b6dd35f9..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayList.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class RelayList( - val countries: ArrayList, - val wireguardEndpointData: WireguardEndpointData -) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCity.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCity.kt deleted file mode 100644 index 2376609ceda6..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCity.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class RelayListCity(val name: String, val code: String, val relays: ArrayList) : - Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCountry.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCountry.kt deleted file mode 100644 index d6d4b8ec6a1d..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelayListCountry.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class RelayListCountry( - val name: String, - val code: String, - val cities: ArrayList -) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelaySettings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelaySettings.kt deleted file mode 100644 index 642046f1b84b..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RelaySettings.kt +++ /dev/null @@ -1,12 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class RelaySettings : Parcelable { - @Parcelize data object CustomTunnelEndpoint : RelaySettings() - - @Parcelize data class Normal(val relayConstraints: RelayConstraints) : RelaySettings() - - fun relayConstraints(): RelayConstraints? = (this as? Normal)?.relayConstraints -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RemoveDeviceEvent.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RemoveDeviceEvent.kt deleted file mode 100644 index cc6e7db2bb2d..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RemoveDeviceEvent.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class RemoveDeviceEvent(val accountToken: String, val newDevices: ArrayList) : - Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RemoveDeviceResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RemoveDeviceResult.kt deleted file mode 100644 index 67bf165a3777..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/RemoveDeviceResult.kt +++ /dev/null @@ -1,12 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class RemoveDeviceResult : Parcelable { - Ok, - NotFound, - RpcError, - OtherError -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SelectedObfuscation.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SelectedObfuscation.kt deleted file mode 100644 index 8124bcc6a69b..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/SelectedObfuscation.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class SelectedObfuscation : Parcelable { - Auto, - Off, - Udp2Tcp -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/ServiceResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/ServiceResult.kt deleted file mode 100644 index e597797e5ac8..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/ServiceResult.kt +++ /dev/null @@ -1,23 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.IBinder - -data class ServiceResult(val binder: IBinder?) { - enum class ConnectionState { - CONNECTED, - DISCONNECTED - } - - val connectionState: ConnectionState - get() { - return if (binder == null) { - ConnectionState.DISCONNECTED - } else { - ConnectionState.CONNECTED - } - } - - companion object { - val NOT_CONNECTED = ServiceResult(null) - } -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelOptions.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelOptions.kt deleted file mode 100644 index 108fd32e04f2..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelOptions.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class TunnelOptions(val wireguard: WireguardTunnelOptions, val dnsOptions: DnsOptions) : - Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt deleted file mode 100644 index 4ab925d01427..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt +++ /dev/null @@ -1,46 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import net.mullvad.talpid.net.TunnelEndpoint -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.tunnel.ErrorState - -sealed class TunnelState : Parcelable { - @Parcelize - data class Disconnected(val location: GeoIpLocation? = null) : TunnelState(), Parcelable - - @Parcelize - class Connecting(val endpoint: TunnelEndpoint?, val location: GeoIpLocation?) : - TunnelState(), Parcelable - - @Parcelize - class Connected(val endpoint: TunnelEndpoint, val location: GeoIpLocation?) : - TunnelState(), Parcelable - - @Parcelize - class Disconnecting(val actionAfterDisconnect: ActionAfterDisconnect) : - TunnelState(), Parcelable - - @Parcelize class Error(val errorState: ErrorState) : TunnelState(), Parcelable - - fun location(): GeoIpLocation? { - return when (this) { - is Connected -> location - is Connecting -> location - is Disconnecting -> null - is Disconnected -> location - is Error -> null - } - } - - fun isSecured(): Boolean { - return when (this) { - is Connected, - is Connecting, - is Disconnecting, -> true - is Disconnected -> false - is Error -> this.errorState.isBlocking - } - } -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Udp2TcpObfuscationSettings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Udp2TcpObfuscationSettings.kt deleted file mode 100644 index f01bb35c6f5e..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/Udp2TcpObfuscationSettings.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class Udp2TcpObfuscationSettings(val port: Constraint) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/UpdateCustomListResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/UpdateCustomListResult.kt deleted file mode 100644 index ebfe9e8cd6dc..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/UpdateCustomListResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class UpdateCustomListResult : Parcelable { - @Parcelize data object Ok : UpdateCustomListResult() - - @Parcelize data class Error(val error: CustomListsError) : UpdateCustomListResult() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmission.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmission.kt deleted file mode 100644 index efe05e2f5c11..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmission.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class VoucherSubmission(val timeAdded: Long, val newExpiry: String) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionError.kt deleted file mode 100644 index 1cf778400afe..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionError.kt +++ /dev/null @@ -1,12 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class VoucherSubmissionError : Parcelable { - InvalidVoucher, - VoucherAlreadyUsed, - RpcError, - OtherError, -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionResult.kt deleted file mode 100644 index 4163b782d4d8..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed class VoucherSubmissionResult : Parcelable { - @Parcelize data class Ok(val submission: VoucherSubmission) : VoucherSubmissionResult() - - @Parcelize data class Error(val error: VoucherSubmissionError) : VoucherSubmissionResult() -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardConstraints.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardConstraints.kt deleted file mode 100644 index 1725b01f0fb0..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardConstraints.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class WireguardConstraints(val port: Constraint) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardEndpointData.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardEndpointData.kt deleted file mode 100644 index 0a21221bb079..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardEndpointData.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class WireguardEndpointData(val portRanges: ArrayList) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardRelayEndpointData.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardRelayEndpointData.kt deleted file mode 100644 index 4a1930dd4376..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardRelayEndpointData.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize object WireguardRelayEndpointData : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardTunnelOptions.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardTunnelOptions.kt deleted file mode 100644 index f4a869a4ea7c..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/WireguardTunnelOptions.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class WireguardTunnelOptions(val mtu: Int?, val quantumResistant: QuantumResistantState) : - Parcelable diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/LatLongTest.kt similarity index 97% rename from android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt rename to android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/LatLongTest.kt index b8608ca55c38..8abef5d9b3fa 100644 --- a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatLongTest.kt +++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/LatLongTest.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model import kotlin.math.sqrt import org.junit.jupiter.api.Assertions.assertEquals diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/LatitudeTest.kt similarity index 99% rename from android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt rename to android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/LatitudeTest.kt index c883f20bfce8..214afef12749 100644 --- a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LatitudeTest.kt +++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/LatitudeTest.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model import kotlin.math.absoluteValue import kotlin.test.assertEquals diff --git a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/LongitudeTest.kt similarity index 99% rename from android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt rename to android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/LongitudeTest.kt index 69d34454179c..88017cdcea73 100644 --- a/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/model/LongitudeTest.kt +++ b/android/lib/model/src/test/kotlin/net/mullvad/mullvadvpn/lib/model/LongitudeTest.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.model +package net.mullvad.mullvadvpn.lib.model import kotlin.math.absoluteValue import kotlin.test.assertEquals diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 38a839bed3be..f9acacb5d9cb 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -80,19 +80,18 @@ There is no VPN settings on your device The Auto-connect and Lockdown mode settings can be found in the Android system settings, follow this guide to enable one or both. - Go to VPN settings button below, click on the cogwheel next to the Mullvad VPN name.]]> + Go to VPN settings button below, click on the cogwheel next to the Mullvad VPN name.]]> Auto-connect is called Always-on VPN in the Android system settings and it makes sure you are constantly connected to the VPN tunnel and auto connects after restart. - Always-on VPN.]]> - + Always-on VPN.]]> + Warning: This setting blocks split apps and the Local Network Sharing feature.]]> Block connections without VPN.]]> - Automatically connect to a server when the app launches. WireGuard MTU Set WireGuard MTU value. Valid range: %1$d - %2$d. @@ -281,7 +280,9 @@ Verifying purchase... Copied logs to clipboard Auto-connect (legacy) - Always-on system setting instead by following the guide in %s above.]]> + + Always-on system setting instead by following the guide in %s above.]]> + Custom lists All locations Edit lists @@ -295,9 +296,7 @@ List name Locations Edit locations - - Delete \"%s\"? - + Delete \"%s\"? Name is already taken. Update list name No custom lists available @@ -344,4 +343,5 @@ Recursion limit Import successful, overrides active Overrides cleared + Unsecured (No VPN permission) diff --git a/android/lib/shared/build.gradle.kts b/android/lib/shared/build.gradle.kts new file mode 100644 index 000000000000..88b5cfb3c95b --- /dev/null +++ b/android/lib/shared/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id(Dependencies.Plugin.androidLibraryId) + id(Dependencies.Plugin.kotlinAndroidId) + id(Dependencies.Plugin.kotlinParcelizeId) + id(Dependencies.Plugin.junit5) version Versions.Plugin.junit5 +} + +android { + namespace = "net.mullvad.mullvadvpn.lib.shared" + compileSdk = Versions.Android.compileSdkVersion + + defaultConfig { minSdk = Versions.Android.minSdkVersion } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { jvmTarget = Versions.jvmTarget } + + lint { + lintConfig = file("${rootProject.projectDir}/config/lint.xml") + abortOnError = true + warningsAsErrors = true + } + buildFeatures { buildConfig = true } +} + +dependencies { + implementation(project(Dependencies.Mullvad.commonLib)) + implementation(project(Dependencies.Mullvad.daemonGrpc)) + implementation(project(Dependencies.Mullvad.modelLib)) + + implementation(Dependencies.Arrow.core) + implementation(Dependencies.Kotlin.stdlib) + implementation(Dependencies.KotlinX.coroutinesAndroid) + implementation(Dependencies.jodaTime) + + testImplementation(Dependencies.Kotlin.test) + testImplementation(Dependencies.KotlinX.coroutinesTest) + testImplementation(Dependencies.MockK.core) + testImplementation(Dependencies.junitApi) + testImplementation(Dependencies.junitParams) + testImplementation(Dependencies.turbine) + testImplementation(project(Dependencies.Mullvad.commonTestLib)) + testRuntimeOnly(Dependencies.junitEngine) +} diff --git a/android/lib/shared/src/main/AndroidManifest.xml b/android/lib/shared/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..cc947c567995 --- /dev/null +++ b/android/lib/shared/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt new file mode 100644 index 000000000000..432d113fbad9 --- /dev/null +++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt @@ -0,0 +1,84 @@ +package net.mullvad.mullvadvpn.lib.shared + +import arrow.core.Either +import arrow.core.raise.nullable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.CreateAccountError +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.LoginAccountError +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken +import org.joda.time.DateTime + +class AccountRepository( + private val managementService: ManagementService, + private val deviceRepository: DeviceRepository, + val scope: CoroutineScope +) { + + private val _mutableAccountDataCache: MutableSharedFlow = MutableSharedFlow() + + private val _isNewAccount: MutableStateFlow = MutableStateFlow(false) + val isNewAccount: StateFlow = _isNewAccount + val accountData: StateFlow = + merge( + managementService.deviceState.filterNotNull().map { deviceState -> + when (deviceState) { + is DeviceState.LoggedIn -> { + managementService.getAccountData(deviceState.accountToken).getOrNull() + } + DeviceState.LoggedOut, + DeviceState.Revoked -> null + } + }, + _mutableAccountDataCache + ) + .distinctUntilChanged() + .stateIn(scope = scope, SharingStarted.Eagerly, null) + + suspend fun createAccount(): Either = + managementService.createAccount().onRight { _isNewAccount.update { true } } + + suspend fun login(accountToken: AccountToken): Either = + managementService.loginAccount(accountToken) + + suspend fun logout() { + managementService.logoutAccount() + _isNewAccount.update { false } + } + + suspend fun fetchAccountHistory(): AccountToken? = + managementService.getAccountHistory().getOrNull() + + suspend fun clearAccountHistory() = managementService.clearAccountHistory() + + suspend fun getAccountData(): AccountData? = nullable { + val deviceState = ensureNotNull(deviceRepository.deviceState.value as? DeviceState.LoggedIn) + + val accountData = + managementService.getAccountData(deviceState.accountToken).getOrNull().bind() + + // Update stateflow cache + _mutableAccountDataCache.emit(accountData) + accountData + } + + suspend fun getWebsiteAuthToken(): WebsiteAuthToken? = + managementService.getWebsiteAuthToken().getOrNull() + + internal suspend fun onVoucherRedeemed(newExpiry: DateTime) { + accountData.value?.copy(expiryDate = newExpiry)?.let { _mutableAccountDataCache.emit(it) } + } +} diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt new file mode 100644 index 000000000000..6ea373e4260c --- /dev/null +++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.lib.shared + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.ConnectError + +class ConnectionProxy( + private val managementService: ManagementService, + private val vpnPermissionRepository: VpnPermissionRepository +) { + val tunnelState = managementService.tunnelState + + suspend fun connect(): Either = either { + ensure(vpnPermissionRepository.hasVpnPermission()) { ConnectError.NoVpnPermission } + managementService.connect().bind() + } + + suspend fun connectWithoutPermissionCheck(): Either = + managementService.connect() + + suspend fun disconnect() = managementService.disconnect() + + suspend fun reconnect() = managementService.reconnect() +} diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/DeviceRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/DeviceRepository.kt new file mode 100644 index 000000000000..b1b8f4fa412f --- /dev/null +++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/DeviceRepository.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.lib.shared + +import arrow.core.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.stateIn +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.AccountToken +import net.mullvad.mullvadvpn.lib.model.DeleteDeviceError +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.model.GetDeviceListError + +class DeviceRepository( + private val managementService: ManagementService, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) { + val deviceState: StateFlow = + managementService.deviceState.stateIn( + CoroutineScope(dispatcher), + SharingStarted.Eagerly, + null + ) + + suspend fun removeDevice( + accountToken: AccountToken, + deviceId: DeviceId + ): Either = managementService.removeDevice(accountToken, deviceId) + + suspend fun deviceList(accountToken: AccountToken): Either> = + managementService.getDeviceList(accountToken) +} diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VoucherRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VoucherRepository.kt new file mode 100644 index 000000000000..a5783a832eca --- /dev/null +++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VoucherRepository.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.lib.shared + +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService + +class VoucherRepository( + private val managementService: ManagementService, + private val accountRepository: AccountRepository +) { + suspend fun submitVoucher(voucher: String) = + managementService.submitVoucher(voucher).onRight { + accountRepository.onVoucherRedeemed(it.newExpiryDate) + } +} diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VpnPermissionRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VpnPermissionRepository.kt new file mode 100644 index 000000000000..b97c60316cd0 --- /dev/null +++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VpnPermissionRepository.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.lib.shared + +import android.content.Context +import android.net.VpnService +import net.mullvad.mullvadvpn.lib.common.util.getAlwaysOnVpnAppName + +class VpnPermissionRepository(private val applicationContext: Context) { + fun hasVpnPermission(): Boolean = VpnService.prepare(applicationContext) == null + + fun getAlwaysOnVpnAppName() = applicationContext.getAlwaysOnVpnAppName() +} diff --git a/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxyTest.kt b/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxyTest.kt new file mode 100644 index 000000000000..74ab4f6b6453 --- /dev/null +++ b/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxyTest.kt @@ -0,0 +1,54 @@ +package net.mullvad.mullvadvpn.lib.shared + +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test + +class ConnectionProxyTest { + + private val mockManagementService: ManagementService = mockk(relaxed = true) + private val mockVpnPermissionRepository: VpnPermissionRepository = mockk() + + private val connectionProxy: ConnectionProxy = + ConnectionProxy( + managementService = mockManagementService, + vpnPermissionRepository = mockVpnPermissionRepository + ) + + @Test + fun `connect with vpn permission allowed should call managementService connect`() = runTest { + every { mockVpnPermissionRepository.hasVpnPermission() } returns true + connectionProxy.connect() + coVerify(exactly = 1) { mockManagementService.connect() } + } + + @Test + fun `connect with vpn permission not allowed should not call managementService connect`() = + runTest { + every { mockVpnPermissionRepository.hasVpnPermission() } returns false + connectionProxy.connect() + coVerify(exactly = 0) { mockManagementService.connect() } + } + + @Test + fun `disconnect should call managementService disconnect`() = runTest { + connectionProxy.disconnect() + coVerify(exactly = 1) { mockManagementService.disconnect() } + } + + @Test + fun `reconnect should call managementService reconnect`() = runTest { + connectionProxy.reconnect() + coVerify(exactly = 1) { mockManagementService.reconnect() } + } + + @AfterEach + fun tearDown() { + unmockkAll() + } +} diff --git a/android/lib/talpid/build.gradle.kts b/android/lib/talpid/build.gradle.kts index ac760a860ed0..00409f948272 100644 --- a/android/lib/talpid/build.gradle.kts +++ b/android/lib/talpid/build.gradle.kts @@ -25,6 +25,9 @@ android { } dependencies { + implementation(project(Dependencies.Mullvad.modelLib)) + implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.KotlinX.coroutinesAndroid) + implementation(Dependencies.AndroidX.lifecycleService) } diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt index cdc16567e188..905f59f313a4 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt @@ -7,7 +7,6 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import kotlin.properties.Delegates.observable -import net.mullvad.talpid.util.EventNotifier class ConnectivityListener { private val availableNetworks = HashSet() @@ -21,22 +20,19 @@ class ConnectivityListener { override fun onLost(network: Network) { availableNetworks.remove(network) - isConnected = !availableNetworks.isEmpty() + isConnected = availableNetworks.isNotEmpty() } } private lateinit var connectivityManager: ConnectivityManager - val connectivityNotifier = EventNotifier(false) - + // Used by JNI var isConnected by observable(false) { _, oldValue, newValue -> if (newValue != oldValue) { if (senderAddress != 0L) { notifyConnectivityChange(newValue, senderAddress) } - - connectivityNotifier.notify(newValue) } } diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/LifecycleVpnService.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/LifecycleVpnService.kt new file mode 100644 index 000000000000..efb29c31c644 --- /dev/null +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/LifecycleVpnService.kt @@ -0,0 +1,56 @@ +package net.mullvad.talpid + +import android.content.Intent +import android.net.VpnService +import android.os.IBinder +import androidx.annotation.CallSuper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ServiceLifecycleDispatcher + +/** + * A VpnService that is also a [LifecycleOwner]. See source: + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:lifecycle/lifecycle-service/src/main/java/androidx/lifecycle/LifecycleService.kt?q=file:androidx%2Flifecycle%2FLifecycleService.kt%20class:androidx.lifecycle.LifecycleService + */ +open class LifecycleVpnService : VpnService(), LifecycleOwner { + + private val dispatcher = ServiceLifecycleDispatcher(this) + + @CallSuper + override fun onCreate() { + dispatcher.onServicePreSuperOnCreate() + super.onCreate() + } + + @CallSuper + override fun onBind(intent: Intent?): IBinder? { + dispatcher.onServicePreSuperOnBind() + return super.onBind(intent) + } + + @Deprecated("Deprecated in Java") + @Suppress("DEPRECATION") + @CallSuper + override fun onStart(intent: Intent?, startId: Int) { + dispatcher.onServicePreSuperOnStart() + super.onStart(intent, startId) + } + + // this method is added only to annotate it with @CallSuper. + // In usual Service, super.onStartCommand is no-op, but in LifecycleService + // it results in dispatcher.onServicePreSuperOnStart() call, because + // super.onStartCommand calls onStart(). + @CallSuper + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return super.onStartCommand(intent, flags, startId) + } + + @CallSuper + override fun onDestroy() { + dispatcher.onServicePreSuperOnDestroy() + super.onDestroy() + } + + override val lifecycle: Lifecycle + get() = dispatcher.lifecycle +} diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt index 76abde2a01f1..e89c841d255d 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt @@ -1,16 +1,17 @@ package net.mullvad.talpid -import android.net.VpnService import android.os.ParcelFileDescriptor import android.util.Log +import androidx.annotation.CallSuper import java.net.Inet4Address import java.net.Inet6Address import java.net.InetAddress import kotlin.properties.Delegates.observable -import net.mullvad.talpid.tun_provider.TunConfig +import net.mullvad.talpid.model.CreateTunResult +import net.mullvad.talpid.model.TunConfig import net.mullvad.talpid.util.TalpidSdkUtils.setMeteredIfSupported -open class TalpidVpnService : VpnService() { +open class TalpidVpnService : LifecycleVpnService() { private var activeTunStatus by observable(null) { _, oldTunStatus, _ -> val oldTunFd = @@ -29,17 +30,19 @@ open class TalpidVpnService : VpnService() { get() = activeTunStatus?.isOpen ?: false private var currentTunConfig = defaultTunConfig() - private var tunIsStale = false - - protected var disallowedApps: List? = null + // Used by JNI val connectivityListener = ConnectivityListener() + @CallSuper override fun onCreate() { + super.onCreate() connectivityListener.register(this) } + @CallSuper override fun onDestroy() { + super.onDestroy() connectivityListener.unregister() } @@ -47,14 +50,13 @@ open class TalpidVpnService : VpnService() { synchronized(this) { val tunStatus = activeTunStatus - if (config == currentTunConfig && tunIsOpen && !tunIsStale) { + if (config == currentTunConfig && tunIsOpen) { return tunStatus!! } else { val newTunStatus = createTun(config) currentTunConfig = config activeTunStatus = newTunStatus - tunIsStale = false return newTunStatus } @@ -78,17 +80,13 @@ open class TalpidVpnService : VpnService() { synchronized(this) { activeTunStatus = null } } - fun markTunAsStale() { - synchronized(this) { tunIsStale = true } - } - private fun createTun(config: TunConfig): CreateTunResult { if (prepare(this) != null) { // VPN permission wasn't granted return CreateTunResult.PermissionDenied } - var invalidDnsServerAddresses = ArrayList() + val invalidDnsServerAddresses = ArrayList() val builder = Builder().apply { @@ -120,11 +118,7 @@ open class TalpidVpnService : VpnService() { addRoute(route.address, route.prefixLength.toInt()) } - disallowedApps?.let { apps -> - for (app in apps) { - addDisallowedApplication(app) - } - } + config.excludedPackages.forEach { app -> addDisallowedApplication(app) } setMtu(config.mtu) setBlocking(false) setMeteredIfSupported(false) diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/CreateTunResult.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt similarity index 73% rename from android/lib/talpid/src/main/kotlin/net/mullvad/talpid/CreateTunResult.kt rename to android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt index 33f62026d648..089112e3ab41 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/CreateTunResult.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt @@ -1,4 +1,4 @@ -package net.mullvad.talpid +package net.mullvad.talpid.model import java.net.InetAddress @@ -17,7 +17,7 @@ sealed class CreateTunResult { get() = true } - object PermissionDenied : CreateTunResult() + data object PermissionDenied : CreateTunResult() - object TunnelDeviceError : CreateTunResult() + data object TunnelDeviceError : CreateTunResult() } diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tun_provider/InetNetwork.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/InetNetwork.kt similarity index 81% rename from android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tun_provider/InetNetwork.kt rename to android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/InetNetwork.kt index a8490b48bfa2..a9c257c3e462 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tun_provider/InetNetwork.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/InetNetwork.kt @@ -1,4 +1,4 @@ -package net.mullvad.talpid.tun_provider +package net.mullvad.talpid.model import java.net.Inet6Address import java.net.InetAddress diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tun_provider/TunConfig.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/TunConfig.kt similarity index 71% rename from android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tun_provider/TunConfig.kt rename to android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/TunConfig.kt index 7efd3f7763a9..955a6f445432 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tun_provider/TunConfig.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/TunConfig.kt @@ -1,4 +1,4 @@ -package net.mullvad.talpid.tun_provider +package net.mullvad.talpid.model import java.net.InetAddress @@ -6,5 +6,6 @@ data class TunConfig( val addresses: ArrayList, val dnsServers: ArrayList, val routes: ArrayList, + val excludedPackages: ArrayList, val mtu: Int ) diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/Endpoint.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/Endpoint.kt deleted file mode 100644 index 8937bd012299..000000000000 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/Endpoint.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.mullvad.talpid.net - -import android.os.Parcelable -import java.net.InetSocketAddress -import kotlinx.parcelize.Parcelize - -@Parcelize -data class Endpoint(val address: InetSocketAddress, val protocol: TransportProtocol) : Parcelable diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/ObfuscationEndpoint.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/ObfuscationEndpoint.kt deleted file mode 100644 index 9ec96b149477..000000000000 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/ObfuscationEndpoint.kt +++ /dev/null @@ -1,8 +0,0 @@ -package net.mullvad.talpid.net - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class ObfuscationEndpoint(val endpoint: Endpoint, val obfuscationType: ObfuscationType) : - Parcelable diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/ObfuscationType.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/ObfuscationType.kt deleted file mode 100644 index 72409d902694..000000000000 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/net/ObfuscationType.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.mullvad.talpid.net - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class ObfuscationType : Parcelable { - Udp2Tcp -} diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ActionAfterDisconnect.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ActionAfterDisconnect.kt deleted file mode 100644 index a62abaacd029..000000000000 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ActionAfterDisconnect.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.mullvad.talpid.tunnel - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class ActionAfterDisconnect : Parcelable { - Nothing, - Block, - Reconnect -} diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorState.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorState.kt deleted file mode 100644 index 070d190beb0c..000000000000 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.talpid.tunnel - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize data class ErrorState(val cause: ErrorStateCause, val isBlocking: Boolean) : Parcelable diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorStateCause.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorStateCause.kt deleted file mode 100644 index fc35e4e23efe..000000000000 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/ErrorStateCause.kt +++ /dev/null @@ -1,36 +0,0 @@ -package net.mullvad.talpid.tunnel - -import android.os.Parcelable -import java.net.InetAddress -import kotlinx.parcelize.Parcelize - -private const val AUTH_FAILED_REASON_EXPIRED_ACCOUNT = "[EXPIRED_ACCOUNT]" - -sealed class ErrorStateCause : Parcelable { - @Parcelize - class AuthFailed(private val reason: String?) : ErrorStateCause() { - fun isCausedByExpiredAccount(): Boolean { - return reason == AUTH_FAILED_REASON_EXPIRED_ACCOUNT - } - } - - @Parcelize data object Ipv6Unavailable : ErrorStateCause() - - @Parcelize - data class SetFirewallPolicyError(val firewallPolicyError: FirewallPolicyError) : - ErrorStateCause() - - @Parcelize data object SetDnsError : ErrorStateCause() - - @Parcelize - data class InvalidDnsServers(val addresses: ArrayList) : ErrorStateCause() - - @Parcelize data object StartTunnelError : ErrorStateCause() - - @Parcelize - data class TunnelParameterError(val error: ParameterGenerationError) : ErrorStateCause() - - @Parcelize data object IsOffline : ErrorStateCause() - - @Parcelize data object VpnPermissionDenied : ErrorStateCause() -} diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/FirewallPolicyError.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/FirewallPolicyError.kt deleted file mode 100644 index c6f19e71af4e..000000000000 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/tunnel/FirewallPolicyError.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.mullvad.talpid.tunnel - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -enum class FirewallPolicyError : Parcelable { - Generic -} diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/EventNotifier.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/EventNotifier.kt deleted file mode 100644 index 148b56eb456e..000000000000 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/EventNotifier.kt +++ /dev/null @@ -1,76 +0,0 @@ -package net.mullvad.talpid.util - -import kotlin.properties.Delegates.observable - -// Manages listeners interested in receiving events of type T -// -// The listeners subscribe using an ID object. This ID is used later on for unsubscribing. The only -// requirement is that the object uses the default implementation of the `hashCode` and `equals` -// methods inherited from `Any` (or `Object` in Java). -// -// If the ID object class (or any of its super-classes) overrides `hashCode` or `equals`, -// unsubscribe might not work correctly. -class EventNotifier(private val initialValue: T) { - private val listeners = LinkedHashMap Unit>() - - var latestEvent = initialValue - private set - - fun notify(event: T) { - synchronized(this) { - latestEvent = event - - for (listener in listeners.values) { - listener(event) - } - } - } - - fun notifyIfChanged(event: T) { - synchronized(this) { - if (latestEvent != event) { - notify(event) - } - } - } - - fun subscribe(id: Any, listener: (T) -> Unit) { - subscribe(id, true, listener) - } - - fun subscribe(id: Any, startWithLatestEvent: Boolean, listener: (T) -> Unit) { - synchronized(this) { - listeners.put(id, listener) - if (startWithLatestEvent) listener(latestEvent) - } - } - - fun hasListeners(): Boolean { - synchronized(this) { - return !listeners.isEmpty() - } - } - - fun unsubscribe(id: Any) { - synchronized(this) { listeners.remove(id) } - } - - fun unsubscribeAll() { - synchronized(this) { listeners.clear() } - } - - fun notifiable() = observable(latestEvent) { _, _, newValue -> notify(newValue) } -} - -fun autoSubscribable(id: Any, fallback: T, listener: (T) -> Unit) = - observable?>(null) { _, old, new -> - if (old != new) { - old?.unsubscribe(id) - - if (new == null) { - listener.invoke(fallback) - } else { - new.subscribe(id, listener) - } - } - } diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/EventNotifierExtensions.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/EventNotifierExtensions.kt deleted file mode 100644 index add362fcb144..000000000000 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/EventNotifierExtensions.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.mullvad.talpid.util - -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow - -fun EventNotifier.callbackFlowFromSubscription(id: Any) = callbackFlow { - this@callbackFlowFromSubscription.subscribe(id) { this.trySend(it) } - awaitClose { this@callbackFlowFromSubscription.unsubscribe(id) } -} diff --git a/android/service/build.gradle.kts b/android/service/build.gradle.kts index 73daa9bcb059..2dcedca5d8dd 100644 --- a/android/service/build.gradle.kts +++ b/android/service/build.gradle.kts @@ -44,21 +44,25 @@ android { buildConfigField("String", "API_ENDPOINT", "\"api.stagemole.eu\"") } } - buildFeatures { - buildConfig = true - } + + buildFeatures { buildConfig = true } } dependencies { implementation(project(Dependencies.Mullvad.commonLib)) + implementation(project(Dependencies.Mullvad.daemonGrpc)) implementation(project(Dependencies.Mullvad.endpointLib)) - implementation(project(Dependencies.Mullvad.ipcLib)) + implementation(project(Dependencies.Mullvad.intentLib)) implementation(project(Dependencies.Mullvad.modelLib)) + implementation(project(Dependencies.Mullvad.sharedLib)) implementation(project(Dependencies.Mullvad.talpidLib)) - implementation(Dependencies.jodaTime) - implementation(Dependencies.Koin.core) + implementation(Dependencies.AndroidX.coreKtx) + implementation(Dependencies.AndroidX.lifecycleService) + implementation(Dependencies.Arrow.core) implementation(Dependencies.Koin.android) + implementation(Dependencies.Koin.core) implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.KotlinX.coroutinesAndroid) + implementation(Dependencies.jodaTime) } diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt deleted file mode 100644 index 8cc292fb95be..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt +++ /dev/null @@ -1,102 +0,0 @@ -package net.mullvad.mullvadvpn.service - -import java.io.File -import kotlin.properties.Delegates.observable -import kotlin.reflect.KClass -import kotlin.reflect.safeCast -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking -import net.mullvad.mullvadvpn.lib.common.util.Intermittent -import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration - -private const val RELAYS_FILE = "relays.json" - -class DaemonInstance(private val vpnService: MullvadVpnService) { - sealed class Command { - data class Start(val apiEndpointConfiguration: ApiEndpointConfiguration) : Command() - - object Stop : Command() - } - - private val commandChannel = spawnActor() - - private var daemon by - observable(null) { _, oldInstance, _ -> oldInstance?.onDestroy() } - - val intermittentDaemon = Intermittent() - - fun start(apiEndpointConfiguration: ApiEndpointConfiguration) { - commandChannel.trySendBlocking(Command.Start(apiEndpointConfiguration)) - } - - fun stop() { - commandChannel.trySendBlocking(Command.Stop) - } - - fun onDestroy() { - commandChannel.close() - intermittentDaemon.onDestroy() - } - - private fun spawnActor() = - GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) { - var isRunning = true - - prepareFiles() - - while (isRunning) { - val startCommand = waitForCommand(channel, Command.Start::class) ?: break - startDaemon(startCommand.apiEndpointConfiguration) - isRunning = waitForCommand(channel, Command.Stop::class) is Command.Stop - stopDaemon() - } - } - - private suspend fun waitForCommand( - channel: ReceiveChannel, - command: KClass - ): T? { - return try { - var receivedCommand: T? - do { - receivedCommand = command.safeCast(channel.receive()) - } while (receivedCommand == null) - receivedCommand - } catch (exception: ClosedReceiveChannelException) { - null - } - } - - private fun prepareFiles() { - val shouldOverwriteRelayList = - lastUpdatedTime() > File(vpnService.filesDir, RELAYS_FILE).lastModified() - - FileResourceExtractor(vpnService).apply { extract(RELAYS_FILE, shouldOverwriteRelayList) } - } - - private suspend fun startDaemon(apiEndpointConfiguration: ApiEndpointConfiguration) { - val newDaemon = - MullvadDaemon(vpnService, apiEndpointConfiguration).apply { - onDaemonStopped = { - intermittentDaemon.spawnUpdate(null) - daemon = null - } - } - - daemon = newDaemon - intermittentDaemon.update(newDaemon) - } - - private fun stopDaemon() { - daemon?.shutdown() - } - - private fun lastUpdatedTime(): Long { - return vpnService.run { packageManager.getPackageInfo(packageName, 0).lastUpdateTime } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt deleted file mode 100644 index dad6ea5b5681..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt +++ /dev/null @@ -1,133 +0,0 @@ -package net.mullvad.mullvadvpn.service - -import android.app.Service -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED -import android.net.VpnService -import android.os.Build -import kotlin.properties.Delegates.observable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onStart -import net.mullvad.mullvadvpn.lib.common.util.Intermittent -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.service.endpoint.ConnectionProxy -import net.mullvad.mullvadvpn.service.notifications.TunnelStateNotification - -class ForegroundNotificationManager( - val service: MullvadVpnService, - val connectionProxy: ConnectionProxy, - val intermittentDaemon: Intermittent -) { - private sealed class UpdaterMessage { - class UpdateNotification : UpdaterMessage() - - class UpdateAction : UpdaterMessage() - - class NewTunnelState(val newState: TunnelState) : UpdaterMessage() - } - - private val jobTracker = JobTracker() - private val updater = runUpdater() - - private val tunnelStateNotification = TunnelStateNotification(service) - - private var loggedIn by - observable(false) { _, _, _ -> updater.trySendBlocking(UpdaterMessage.UpdateAction()) } - - private val tunnelState - get() = connectionProxy.onStateChange.latestEvent - - private val shouldBeOnForeground - get() = lockedToForeground || !(tunnelState is TunnelState.Disconnected) - - var onForeground = false - private set - - var lockedToForeground by - observable(false) { _, _, _ -> - updater.trySendBlocking(UpdaterMessage.UpdateNotification()) - } - - init { - connectionProxy.onStateChange.subscribe(this) { newState -> - updater.trySendBlocking(UpdaterMessage.NewTunnelState(newState)) - } - - intermittentDaemon.registerListener(this) { daemon -> - jobTracker.newBackgroundJob("notificationLoggedInJob") { - daemon - ?.deviceStateUpdates - ?.onStart { daemon.getAndEmitDeviceState()?.let { emit(it) } } - ?.collect { deviceState -> loggedIn = deviceState is DeviceState.LoggedIn } - } - } - - updater.trySendBlocking(UpdaterMessage.UpdateNotification()) - } - - fun onDestroy() { - jobTracker.cancelAllJobs() - intermittentDaemon.unregisterListener(this) - connectionProxy.onStateChange.unsubscribe(this) - updater.close() - } - - private fun runUpdater() = - GlobalScope.actor(Dispatchers.Main, Channel.UNLIMITED) { - for (message in channel) { - when (message) { - is UpdaterMessage.UpdateNotification -> updateNotification() - is UpdaterMessage.UpdateAction -> updateNotificationAction() - is UpdaterMessage.NewTunnelState -> { - tunnelStateNotification.tunnelState = message.newState - updateNotification() - } - } - } - } - - fun showOnForeground() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - if (VpnService.prepare(service) == null) { - service.startForeground( - TunnelStateNotification.NOTIFICATION_ID, - tunnelStateNotification.build(), - FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED - ) - } else { - return - } - } else { - service.startForeground( - TunnelStateNotification.NOTIFICATION_ID, - tunnelStateNotification.build(), - ) - } - onForeground = true - } - - fun updateNotification() { - if (shouldBeOnForeground != onForeground) { - if (shouldBeOnForeground) { - showOnForeground() - } else { - service.stopForeground(Service.STOP_FOREGROUND_DETACH) - onForeground = false - } - } - } - - fun cancelNotification() { - tunnelStateNotification.visible = false - } - - private fun updateNotificationAction() { - tunnelStateNotification.showAction = loggedIn - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt index 1d87987cf3de..aa6f07e9bb0e 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt @@ -1,227 +1,70 @@ package net.mullvad.mullvadvpn.service -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import android.annotation.SuppressLint +import android.content.Context +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpoint import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration -import net.mullvad.mullvadvpn.model.AppVersionInfo -import net.mullvad.mullvadvpn.model.CreateCustomListResult -import net.mullvad.mullvadvpn.model.CustomList -import net.mullvad.mullvadvpn.model.Device -import net.mullvad.mullvadvpn.model.DeviceEvent -import net.mullvad.mullvadvpn.model.DeviceListEvent -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.model.DnsOptions -import net.mullvad.mullvadvpn.model.GetAccountDataResult -import net.mullvad.mullvadvpn.model.LoginResult -import net.mullvad.mullvadvpn.model.ObfuscationSettings -import net.mullvad.mullvadvpn.model.PlayPurchase -import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult -import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult -import net.mullvad.mullvadvpn.model.QuantumResistantState -import net.mullvad.mullvadvpn.model.RelayList -import net.mullvad.mullvadvpn.model.RelayOverride -import net.mullvad.mullvadvpn.model.RelaySettings -import net.mullvad.mullvadvpn.model.RemoveDeviceEvent -import net.mullvad.mullvadvpn.model.RemoveDeviceResult -import net.mullvad.mullvadvpn.model.Settings -import net.mullvad.mullvadvpn.model.SettingsPatchError -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.model.UpdateCustomListResult -import net.mullvad.mullvadvpn.model.VoucherSubmissionResult -import net.mullvad.talpid.util.EventNotifier +import net.mullvad.mullvadvpn.service.migration.MigrateSplitTunneling +private const val RELAYS_FILE = "relays.json" + +@SuppressLint("SdCardPath") class MullvadDaemon( vpnService: MullvadVpnService, - apiEndpointConfiguration: ApiEndpointConfiguration + apiEndpointConfiguration: ApiEndpointConfiguration, + migrateSplitTunneling: MigrateSplitTunneling ) { - protected var daemonInterfaceAddress = 0L - - val onSettingsChange = EventNotifier(null) - var onTunnelStateChange = EventNotifier(TunnelState.Disconnected()) - - var onAppVersionInfoChange: ((AppVersionInfo) -> Unit)? = null - var onRelayListChange: ((RelayList) -> Unit)? = null - var onDaemonStopped: (() -> Unit)? = null - - private val _deviceStateUpdates = MutableSharedFlow(extraBufferCapacity = 1) - val deviceStateUpdates = _deviceStateUpdates.asSharedFlow() + // Used by JNI + @Suppress("ProtectedMemberInFinalClass") protected var daemonInterfaceAddress = 0L - private val _deviceListUpdates = MutableSharedFlow(extraBufferCapacity = 1) - val deviceListUpdates = _deviceListUpdates.asSharedFlow() + private val shutdownSignal = Channel() init { System.loadLibrary("mullvad_jni") + prepareFiles(vpnService) + + migrateSplitTunneling.migrate() + initialize( vpnService = vpnService, cacheDirectory = vpnService.cacheDir.absolutePath, resourceDirectory = vpnService.filesDir.absolutePath, apiEndpoint = apiEndpointConfiguration.apiEndpoint() ) - - onSettingsChange.notify(getSettings()) - - onTunnelStateChange.notify(getState() ?: TunnelState.Disconnected()) - } - - fun connect() { - connect(daemonInterfaceAddress) - } - - fun createNewAccount(): String? { - return createNewAccount(daemonInterfaceAddress) - } - - fun disconnect() { - disconnect(daemonInterfaceAddress) - } - - fun getAccountData(accountToken: String): GetAccountDataResult { - return getAccountData(daemonInterfaceAddress, accountToken) - } - - fun getAccountHistory(): String? { - return getAccountHistory(daemonInterfaceAddress) - } - - fun getWwwAuthToken(): String { - return getWwwAuthToken(daemonInterfaceAddress) ?: "" - } - - fun getCurrentVersion(): String? { - return getCurrentVersion(daemonInterfaceAddress) - } - - fun getRelayLocations(): RelayList? { - return getRelayLocations(daemonInterfaceAddress) - } - - fun getSettings(): Settings? { - return getSettings(daemonInterfaceAddress) - } - - fun getState(): TunnelState? { - return getState(daemonInterfaceAddress) - } - - fun getVersionInfo(): AppVersionInfo? { - return getVersionInfo(daemonInterfaceAddress) - } - - fun reconnect() { - reconnect(daemonInterfaceAddress) - } - - fun clearAccountHistory() { - clearAccountHistory(daemonInterfaceAddress) - } - - fun loginAccount(accountToken: String): LoginResult { - return loginAccount(daemonInterfaceAddress, accountToken) } - fun logoutAccount() = logoutAccount(daemonInterfaceAddress) - - fun getAndEmitDeviceList(accountToken: String): List? { - return listDevices(daemonInterfaceAddress, accountToken).also { deviceList -> - _deviceListUpdates.tryEmit( - if (deviceList == null) { - DeviceListEvent.Error - } else { - DeviceListEvent.Available(accountToken, deviceList) - } - ) + suspend fun shutdown() = + withContext(Dispatchers.IO) { + val shutdownSignal = async { shutdownSignal.receive() } + shutdown(daemonInterfaceAddress) + shutdownSignal.await() + deinitialize() } - } - - fun getAndEmitDeviceState(): DeviceState? { - return getDevice(daemonInterfaceAddress)?.also { deviceState -> - _deviceStateUpdates.tryEmit(deviceState) - } - } - fun refreshDevice() { - updateDevice(daemonInterfaceAddress) - getAndEmitDeviceState() - } - - fun removeDevice(accountToken: String, deviceId: String): RemoveDeviceResult { - return removeDevice(daemonInterfaceAddress, accountToken, deviceId) - } - - fun setAllowLan(allowLan: Boolean) { - setAllowLan(daemonInterfaceAddress, allowLan) - } + private fun prepareFiles(context: Context) { + val shouldOverwriteRelayList = + lastUpdatedTime(context) > File(context.filesDir, RELAYS_FILE).lastModified() - fun setAutoConnect(autoConnect: Boolean) { - setAutoConnect(daemonInterfaceAddress, autoConnect) + FileResourceExtractor(context).apply { extract(RELAYS_FILE, shouldOverwriteRelayList) } } - fun setDnsOptions(dnsOptions: DnsOptions) { - setDnsOptions(daemonInterfaceAddress, dnsOptions) - } - - fun setWireguardMtu(wireguardMtu: Int?) { - setWireguardMtu(daemonInterfaceAddress, wireguardMtu) - } - - fun shutdown() { - shutdown(daemonInterfaceAddress) - } - - fun submitVoucher(voucher: String): VoucherSubmissionResult { - return submitVoucher(daemonInterfaceAddress, voucher) - } - - fun initPlayPurchase(): PlayPurchaseInitResult { - return initPlayPurchase(daemonInterfaceAddress) - } - - fun verifyPlayPurchase(playPurchase: PlayPurchase): PlayPurchaseVerifyResult { - return verifyPlayPurchase(daemonInterfaceAddress, playPurchase) - } - - fun setRelaySettings(update: RelaySettings) { - setRelaySettings(daemonInterfaceAddress, update) - } - - fun setObfuscationSettings(settings: ObfuscationSettings?) { - setObfuscationSettings(daemonInterfaceAddress, settings) - } - - fun setQuantumResistant(quantumResistant: QuantumResistantState) { - setQuantumResistantTunnel(daemonInterfaceAddress, quantumResistant) - } - - fun createCustomList(name: String): CreateCustomListResult = - createCustomList(daemonInterfaceAddress, name) - - fun deleteCustomList(id: String) { - deleteCustomList(daemonInterfaceAddress, id) - } - - fun updateCustomList(customList: CustomList): UpdateCustomListResult = - updateCustomList(daemonInterfaceAddress, customList) - - fun clearAllRelayOverrides() = clearAllRelayOverrides(daemonInterfaceAddress) - - fun applyJsonSettings(json: String) = applyJsonSettings(daemonInterfaceAddress, json) - - fun exportJsonSettings(): String = exportJsonSettings(daemonInterfaceAddress) - - fun setRelayOverride(relayOverride: RelayOverride) = - setRelayOverride(daemonInterfaceAddress, relayOverride) - - fun onDestroy() { - onSettingsChange.unsubscribeAll() - onTunnelStateChange.unsubscribeAll() - - onAppVersionInfoChange = null - onRelayListChange = null - onDaemonStopped = null + private fun lastUpdatedTime(context: Context): Long = + context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime - deinitialize() + // Used by JNI + @Suppress("unused") + private fun notifyDaemonStopped() { + runBlocking { + shutdownSignal.send(Unit) + shutdownSignal.close() + } } private external fun initialize( @@ -233,159 +76,5 @@ class MullvadDaemon( private external fun deinitialize() - private external fun connect(daemonInterfaceAddress: Long) - - private external fun createNewAccount(daemonInterfaceAddress: Long): String? - - private external fun disconnect(daemonInterfaceAddress: Long) - - private external fun getAccountData( - daemonInterfaceAddress: Long, - accountToken: String - ): GetAccountDataResult - - private external fun getAccountHistory(daemonInterfaceAddress: Long): String? - - private external fun getWwwAuthToken(daemonInterfaceAddress: Long): String? - - private external fun getCurrentVersion(daemonInterfaceAddress: Long): String? - - private external fun getRelayLocations(daemonInterfaceAddress: Long): RelayList? - - private external fun getSettings(daemonInterfaceAddress: Long): Settings? - - private external fun getState(daemonInterfaceAddress: Long): TunnelState? - - private external fun getVersionInfo(daemonInterfaceAddress: Long): AppVersionInfo? - - private external fun reconnect(daemonInterfaceAddress: Long) - - private external fun clearAccountHistory(daemonInterfaceAddress: Long) - - private external fun loginAccount( - daemonInterfaceAddress: Long, - accountToken: String? - ): LoginResult - - private external fun logoutAccount(daemonInterfaceAddress: Long) - - private external fun listDevices( - daemonInterfaceAddress: Long, - accountToken: String? - ): List? - - // TODO: Review this method when redoing Daemon communication, it can be null which was not - // considered when this method was initially added. - private external fun getDevice(daemonInterfaceAddress: Long): DeviceState? - - private external fun updateDevice(daemonInterfaceAddress: Long) - - private external fun removeDevice( - daemonInterfaceAddress: Long, - accountToken: String?, - deviceId: String - ): RemoveDeviceResult - - private external fun setAllowLan(daemonInterfaceAddress: Long, allowLan: Boolean) - - private external fun setAutoConnect(daemonInterfaceAddress: Long, alwaysOn: Boolean) - - private external fun setDnsOptions(daemonInterfaceAddress: Long, dnsOptions: DnsOptions) - - private external fun setWireguardMtu(daemonInterfaceAddress: Long, wireguardMtu: Int?) - private external fun shutdown(daemonInterfaceAddress: Long) - - private external fun submitVoucher( - daemonInterfaceAddress: Long, - voucher: String - ): VoucherSubmissionResult - - private external fun initPlayPurchase(daemonInterfaceAddress: Long): PlayPurchaseInitResult - - private external fun verifyPlayPurchase( - daemonInterfaceAddress: Long, - playPurchase: PlayPurchase, - ): PlayPurchaseVerifyResult - - private external fun setRelaySettings(daemonInterfaceAddress: Long, update: RelaySettings) - - private external fun setObfuscationSettings( - daemonInterfaceAddress: Long, - settings: ObfuscationSettings? - ) - - private external fun setQuantumResistantTunnel( - daemonInterfaceAddress: Long, - quantumResistant: QuantumResistantState - ) - - // Used by JNI - - private external fun createCustomList( - daemonInterfaceAddress: Long, - name: String - ): CreateCustomListResult - - private external fun deleteCustomList(daemonInterfaceAddress: Long, id: String) - - private external fun updateCustomList( - daemonInterfaceAddress: Long, - customList: CustomList - ): UpdateCustomListResult - - private external fun clearAllRelayOverrides(daemonInterfaceAddress: Long) - - private external fun applyJsonSettings( - daemonInterfaceAddress: Long, - json: String - ): SettingsPatchError - - private external fun exportJsonSettings(daemonInterfaceAddress: Long): String - - private external fun setRelayOverride( - daemonInterfaceAddress: Long, - relayOverride: RelayOverride - ) - - @Suppress("unused") - private fun notifyAppVersionInfoEvent(appVersionInfo: AppVersionInfo) { - onAppVersionInfoChange?.invoke(appVersionInfo) - } - - // Used by JNI - @Suppress("unused") - private fun notifyRelayListEvent(relayList: RelayList) { - onRelayListChange?.invoke(relayList) - } - - // Used by JNI - @Suppress("unused") - private fun notifySettingsEvent(settings: Settings) { - onSettingsChange.notify(settings) - } - - // Used by JNI - @Suppress("unused") - private fun notifyTunnelStateEvent(event: TunnelState) { - onTunnelStateChange.notify(event) - } - - // Used by JNI - @Suppress("unused") - private fun notifyDaemonStopped() { - onDaemonStopped?.invoke() - } - - // Used by JNI - @Suppress("unused") - private fun notifyDeviceEvent(event: DeviceEvent) { - _deviceStateUpdates.tryEmit(event.newState) - } - - // Used by JNI - @Suppress("unused") - private fun notifyRemoveDeviceEvent(event: RemoveDeviceEvent) { - _deviceListUpdates.tryEmit(DeviceListEvent.Available(event.accountToken, event.newDevices)) - } } diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt index afd07d7584e6..e3940c816679 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt @@ -1,280 +1,202 @@ package net.mullvad.mullvadvpn.service -import android.annotation.SuppressLint import android.app.KeyguardManager -import android.content.Context import android.content.Intent +import android.os.Binder +import android.os.Build import android.os.IBinder -import android.os.Looper import android.util.Log -import kotlin.properties.Delegates.observable +import androidx.core.content.getSystemService +import androidx.lifecycle.lifecycleScope +import arrow.atomic.AtomicInt import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import net.mullvad.mullvadvpn.lib.common.constant.KEY_CONNECT_ACTION import net.mullvad.mullvadvpn.lib.common.constant.KEY_DISCONNECT_ACTION -import net.mullvad.mullvadvpn.lib.common.constant.KEY_QUIT_ACTION -import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS +import net.mullvad.mullvadvpn.lib.common.constant.TAG +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras -import net.mullvad.mullvadvpn.model.Settings -import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.lib.intent.IntentProvider +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.service.di.apiEndpointModule import net.mullvad.mullvadvpn.service.di.vpnServiceModule -import net.mullvad.mullvadvpn.service.endpoint.ServiceEndpoint -import net.mullvad.mullvadvpn.service.notifications.AccountExpiryNotification +import net.mullvad.mullvadvpn.service.migration.MigrateSplitTunneling +import net.mullvad.mullvadvpn.service.notifications.ForegroundNotificationManager +import net.mullvad.mullvadvpn.service.notifications.NotificationChannelFactory +import net.mullvad.mullvadvpn.service.notifications.NotificationManager +import net.mullvad.mullvadvpn.service.notifications.ShouldBeOnForegroundProvider import net.mullvad.talpid.TalpidVpnService -import org.koin.android.ext.android.get +import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules -class MullvadVpnService : TalpidVpnService() { +class MullvadVpnService : TalpidVpnService(), ShouldBeOnForegroundProvider { + private val _shouldBeOnForeground = MutableStateFlow(false) + override val shouldBeOnForeground: StateFlow = _shouldBeOnForeground - private enum class PendingAction { - Connect, - Disconnect, - } - - private enum class State { - Running, - Stopping, - Stopped, - } - - private val connectionProxy - get() = endpoint.connectionProxy - - private var state = State.Running - - private var setUpDaemonJob: Job? = null - - private lateinit var accountExpiryNotification: AccountExpiryNotification - private lateinit var daemonInstance: DaemonInstance - private lateinit var endpoint: ServiceEndpoint private lateinit var keyguardManager: KeyguardManager - private lateinit var notificationManager: ForegroundNotificationManager - - private var pendingAction by - observable(null) { _, _, _ -> - endpoint.settingsListener.settings?.let { settings -> handlePendingAction(settings) } - } + private lateinit var daemonInstance: MullvadDaemon private lateinit var apiEndpointConfiguration: ApiEndpointConfiguration + private lateinit var managementService: ManagementService + private lateinit var migrateSplitTunneling: MigrateSplitTunneling + private lateinit var intentProvider: IntentProvider + private lateinit var connectionProxy: ConnectionProxy + + private lateinit var foregroundNotificationHandler: ForegroundNotificationManager + + // Count number of binds to know if the service is needed. If user actively using the VPN, a + // bind from the system, should always be present. + private val bindCount = AtomicInt() - // Suppressing since the tunnel state pref should be writted immediately. - @SuppressLint("ApplySharedPref") override fun onCreate() { super.onCreate() - Log.d(TAG, "Initializing service") + Log.d(TAG, "MullvadVpnService: onCreate") loadKoinModules(listOf(vpnServiceModule, apiEndpointModule)) + with(getKoin()) { + // Needed to create all the notification channels + get() - daemonInstance = DaemonInstance(this) - keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + managementService = get() - endpoint = - ServiceEndpoint( - Looper.getMainLooper(), - daemonInstance.intermittentDaemon, - connectivityListener, - this - ) + foregroundNotificationHandler = + ForegroundNotificationManager(this@MullvadVpnService, get(), lifecycleScope) + get() - endpoint.splitTunneling.onChange.subscribe(this@MullvadVpnService) { excludedApps -> - disallowedApps = excludedApps - markTunAsStale() - connectionProxy.reconnect() + apiEndpointConfiguration = get() + migrateSplitTunneling = get() + intentProvider = get() + connectionProxy = get() } - notificationManager = - ForegroundNotificationManager(this, connectionProxy, daemonInstance.intermittentDaemon) - - accountExpiryNotification = - AccountExpiryNotification( - this, - daemonInstance.intermittentDaemon, - endpoint.accountCache - ) - - // Remove any leftover tunnel state persistence data - getSharedPreferences("tunnel_state", MODE_PRIVATE).edit().clear().commit() + keyguardManager = getSystemService()!! + + lifecycleScope.launch { foregroundNotificationHandler.start(this@MullvadVpnService) } + + // TODO We should avoid lifecycleScope.launch (current needed due to InetSocketAddress + // with intent from API) + lifecycleScope.launch(context = Dispatchers.IO) { + managementService.start() + daemonInstance = + MullvadDaemon( + vpnService = this@MullvadVpnService, + apiEndpointConfiguration = + intentProvider.getLatestIntent()?.getApiEndpointConfigurationExtras() + ?: apiEndpointConfiguration, + migrateSplitTunneling = migrateSplitTunneling + ) + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(TAG, "Starting service") - - val intentProvidedConfiguration = - if (BuildConfig.DEBUG) { - intent?.getApiEndpointConfigurationExtras() - } else { - null - } - - apiEndpointConfiguration = intentProvidedConfiguration ?: get() - - daemonInstance.apply { - intermittentDaemon.registerListener(this@MullvadVpnService) { daemon -> - handleDaemonInstance(daemon) - } - - start(apiEndpointConfiguration) - } + Log.d( + TAG, + "onStartCommand (intent=$intent, action=${intent?.action}, flags=$flags, startId=$startId)" + ) val startResult = super.onStartCommand(intent, flags, startId) - var quitCommand = false // Always promote to foreground if connect/disconnect actions are provided to mitigate cases // where the service would potentially otherwise be too slow running `startForeground`. - if (intent?.action == KEY_CONNECT_ACTION || intent?.action == KEY_DISCONNECT_ACTION) { - notificationManager.showOnForeground() - } - - notificationManager.updateNotification() - - if (!keyguardManager.isDeviceLocked) { - val action = intent?.action - - if (action == SERVICE_INTERFACE || action == KEY_CONNECT_ACTION) { - pendingAction = PendingAction.Connect - } else if (action == KEY_DISCONNECT_ACTION) { - pendingAction = PendingAction.Disconnect - } else if (action == KEY_QUIT_ACTION && !notificationManager.onForeground) { - quitCommand = true - stop() + when { + keyguardManager.isKeyguardLocked -> { + Log.d(TAG, "Keyguard is locked, ignoring command") + } + intent.isFromSystem() || intent?.action == KEY_CONNECT_ACTION -> { + // Only show on foreground if we have permission + if (prepare(this) == null) { + _shouldBeOnForeground.update { true } + } + lifecycleScope.launch { connectionProxy.connectWithoutPermissionCheck() } + } + intent?.action == KEY_DISCONNECT_ACTION -> { + lifecycleScope.launch { connectionProxy.disconnect() } } - } - - if (state == State.Stopping && !quitCommand) { - restart() } return startResult } - override fun onBind(intent: Intent): IBinder { - Log.d(TAG, "New connection to service") - return super.onBind(intent) ?: endpoint.messenger.binder - } + override fun onBind(intent: Intent?): IBinder { + bindCount.incrementAndGet() + Log.d(TAG, "onBind: $intent") - override fun onRebind(intent: Intent) { - Log.d(TAG, "Connection to service restored") - if (state == State.Stopping) { - restart() + if (intent.isFromSystem()) { + Log.d(TAG, "onBind from system") + _shouldBeOnForeground.update { true } } - } - override fun onRevoke() { - pendingAction = PendingAction.Disconnect + // We always need to return a binder. If the system binds to our VPN service, VpnService + // will return a binder that shall be user, otherwise we return an empty dummy binder to + // keep connection service alive since the actual communication happens over gRPC. + return super.onBind(intent) ?: emptyBinder() } - override fun onUnbind(intent: Intent): Boolean { - Log.d(TAG, "Closed all connections to service") - - if (state != State.Running) { - stop() + private fun emptyBinder() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Binder(this.toString()) + } else { + Binder() } - return true - } - - override fun onDestroy() { - Log.d(TAG, "Service has stopped") - state = State.Stopped - accountExpiryNotification.onDestroy() - notificationManager.onDestroy() - daemonInstance.onDestroy() - super.onDestroy() - } - - override fun onTaskRemoved(rootIntent: Intent?) { - connectionProxy.onStateChange.latestEvent.let { tunnelState -> - Log.d(TAG, "Task removed") - if (tunnelState is TunnelState.Disconnected) { - notificationManager.cancelNotification() - stop() - } - } + override fun onRevoke() { + runBlocking { connectionProxy.disconnect() } } - private fun handleDaemonInstance(daemon: MullvadDaemon?) { - setUpDaemonJob?.cancel() - - if (daemon != null) { - setUpDaemonJob = setUpDaemon(daemon) - } else { - Log.d(TAG, "Daemon has stopped") + override fun onUnbind(intent: Intent): Boolean { + val count = bindCount.decrementAndGet() - if (state == State.Running) { - restart() - } + // Foreground? + if (intent.isFromSystem()) { + Log.d(TAG, "onUnbind from system") + _shouldBeOnForeground.update { false } } - } - - private fun setUpDaemon(daemon: MullvadDaemon) = - GlobalScope.launch(Dispatchers.Main) { - if (state != State.Stopped) { - val settings = daemon.getSettings() - if (settings != null) { - handlePendingAction(settings) - } else { - restart() + if (count == 0) { + Log.d(TAG, "No one bound to the service, stopSelf()") + lifecycleScope.launch { + Log.d(TAG, "Waiting for disconnected state") + // TODO This needs reworking, we should not wait for the disconnected state, what we + // want is the notification of disconnected to go out before we start shutting down + connectionProxy.tunnelState + .filter { + it is TunnelState.Disconnected || + (it is TunnelState.Error && !it.errorState.isBlocking) + } + .first() + + if (bindCount.get() == 0) { + Log.d(TAG, "Stopping service") + stopSelf() } } } - - private fun stop() { - Log.d(TAG, "Stopping service") - state = State.Stopping - daemonInstance.stop() - stopSelf() - } - - private fun restart() { - if (state != State.Stopped) { - Log.d(TAG, "Restarting service") - - state = State.Running - - daemonInstance.apply { - stop() - start(apiEndpointConfiguration) - } - } else { - Log.d(TAG, "Ignoring restart because onDestroy has executed") - } + return false } - private fun handlePendingAction(settings: Settings) { - when (pendingAction) { - PendingAction.Connect -> { - if (settings != null) { - connectionProxy.connect() - } else { - openUi() - } - } - PendingAction.Disconnect -> connectionProxy.disconnect() - null -> return - } + override fun onDestroy() { + Log.d(TAG, "MullvadVpnService: onDestroy") + managementService.stop() - pendingAction = null + // Shutting down the daemon gracefully + runBlocking { daemonInstance.shutdown() } + super.onDestroy() } - private fun openUi() { - val intent = - Intent().apply { - setClassName(applicationContext.packageName, MAIN_ACTIVITY_CLASS) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - - startActivity(intent) + // If an intent is from the system it is because of the OS starting/stopping the VPN. + private fun Intent?.isFromSystem(): Boolean { + return this?.action == SERVICE_INTERFACE } companion object { - private const val TAG = "mullvad" - init { System.loadLibrary("mullvad_jni") } diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt index 0a7d3dec3935..e0370396751d 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt @@ -1,7 +1,53 @@ package net.mullvad.mullvadvpn.service.di import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.MainScope +import net.mullvad.mullvadvpn.lib.model.NotificationChannel +import net.mullvad.mullvadvpn.service.migration.MigrateSplitTunneling +import net.mullvad.mullvadvpn.service.notifications.NotificationChannelFactory +import net.mullvad.mullvadvpn.service.notifications.NotificationManager +import net.mullvad.mullvadvpn.service.notifications.NotificationProvider +import net.mullvad.mullvadvpn.service.notifications.accountexpiry.AccountExpiryNotificationProvider +import net.mullvad.mullvadvpn.service.notifications.tunnelstate.TunnelStateNotificationProvider import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.createdAtStart +import org.koin.core.module.dsl.withOptions +import org.koin.dsl.bind import org.koin.dsl.module -val vpnServiceModule = module { single { NotificationManagerCompat.from(androidContext()) } } +val vpnServiceModule = module { + single { NotificationManagerCompat.from(androidContext()) } + single { androidContext().resources } + + single { NotificationChannel.TunnelUpdates } bind NotificationChannel::class + single { NotificationChannel.AccountUpdates } bind NotificationChannel::class + single { NotificationChannelFactory(get(), get(), getAll()) } withOptions { createdAtStart() } + + single { + TunnelStateNotificationProvider( + get(), + get(), + get(), + get().id, + MainScope() + ) + } bind NotificationProvider::class + single { + AccountExpiryNotificationProvider( + get().id, + get(), + get() + ) + } bind NotificationProvider::class + + single { + NotificationManager( + get(), + getAll(), + get(), + MainScope(), + ) + } withOptions { createdAtStart() } + + single { MigrateSplitTunneling(androidContext()) } +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt deleted file mode 100644 index 093f13403d27..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt +++ /dev/null @@ -1,184 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.lib.common.util.parseAsDateTime -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.AccountCreationResult -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.AccountHistory -import net.mullvad.mullvadvpn.model.GetAccountDataResult -import net.mullvad.talpid.util.EventNotifier - -class AccountCache(private val endpoint: ServiceEndpoint) { - - private val commandChannel = spawnActor() - - private val daemon - get() = endpoint.intermittentDaemon - - val onAccountExpiryChange = EventNotifier(AccountExpiry.Missing) - val onAccountHistoryChange = EventNotifier(AccountHistory.Missing) - - private val jobTracker = JobTracker() - - private var accountExpiry by onAccountExpiryChange.notifiable() - private var accountHistory by onAccountHistoryChange.notifiable() - - private var cachedAccountToken: String? = null - private var cachedCreatedAccountToken: String? = null - - val isNewAccount: Boolean - get() = cachedAccountToken == cachedCreatedAccountToken - - init { - jobTracker.newBackgroundJob("autoFetchAccountExpiry") { - daemon.await().deviceStateUpdates.collect { deviceState -> - accountExpiry = - deviceState - .token() - .also { cachedAccountToken = it } - ?.let { fetchAccountExpiry(it) } ?: AccountExpiry.Missing - } - } - - onAccountHistoryChange.subscribe(this) { history -> - endpoint.sendEvent(Event.AccountHistoryEvent(history)) - } - - onAccountExpiryChange.subscribe(this) { endpoint.sendEvent(Event.AccountExpiryEvent(it)) } - - endpoint.dispatcher.apply { - registerHandler(Request.CreateAccount::class) { _ -> - commandChannel.trySendBlocking(Command.CreateAccount) - } - - registerHandler(Request.Login::class) { request -> - request.account?.let { account -> - commandChannel.trySendBlocking(Command.Login(account)) - } - } - - registerHandler(Request.Logout::class) { _ -> - commandChannel.trySendBlocking(Command.Logout) - } - - registerHandler(Request.FetchAccountExpiry::class) { _ -> - jobTracker.newBackgroundJob("fetchAccountExpiry") { - val token = cachedAccountToken ?: return@newBackgroundJob - val newAccountExpiry = fetchAccountExpiry(token) ?: return@newBackgroundJob - accountExpiry = newAccountExpiry - } - } - - registerHandler(Request.FetchAccountHistory::class) { _ -> - jobTracker.newBackgroundJob("fetchAccountHistory") { - accountHistory = fetchAccountHistory() - } - } - - registerHandler(Request.ClearAccountHistory::class) { _ -> - jobTracker.newBackgroundJob("clearAccountHistory") { clearAccountHistory() } - } - } - } - - fun onDestroy() { - jobTracker.cancelAllJobs() - - onAccountExpiryChange.unsubscribeAll() - onAccountHistoryChange.unsubscribeAll() - - commandChannel.close() - } - - private fun spawnActor() = - GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) { - try { - for (command in channel) { - when (command) { - is Command.CreateAccount -> doCreateAccount() - is Command.Login -> doLogin(command.account) - is Command.Logout -> doLogout() - } - } - } catch (exception: ClosedReceiveChannelException) { - // Command channel was closed, stop the actor - } - } - - private suspend fun clearAccountHistory() { - daemon.await().clearAccountHistory() - accountHistory = fetchAccountHistory() - } - - private suspend fun doCreateAccount() { - daemon - .await() - .createNewAccount() - .also { newAccountToken -> cachedCreatedAccountToken = newAccountToken } - .let { newAccountToken -> - if (newAccountToken != null) { - AccountCreationResult.Success(newAccountToken) - } else { - AccountCreationResult.Failure - } - } - .also { result -> endpoint.sendEvent(Event.AccountCreationEvent(result)) } - } - - private suspend fun doLogin(account: String) { - daemon.await().loginAccount(account).also { result -> - endpoint.sendEvent(Event.LoginEvent(result)) - } - } - - private suspend fun doLogout() { - daemon.await().logoutAccount() - accountExpiry = AccountExpiry.Missing - accountHistory = fetchAccountHistory() - } - - private suspend fun fetchAccountHistory(): AccountHistory { - return daemon.await().getAccountHistory().let { history -> - if (history != null) { - AccountHistory.Available(history) - } else { - AccountHistory.Missing - } - } - } - - private suspend fun fetchAccountExpiry(accountToken: String): AccountExpiry? { - return fetchAccountData(accountToken).let { result -> - when (result) { - is GetAccountDataResult.Ok -> { - result.accountData.expiry.parseAsDateTime()?.let { AccountExpiry.Available(it) } - } - GetAccountDataResult.InvalidAccount -> AccountExpiry.Missing - GetAccountDataResult.OtherError -> null - GetAccountDataResult.RpcError -> null - } - } - } - - private suspend fun fetchAccountData(accountToken: String): GetAccountDataResult { - return daemon.await().getAccountData(accountToken) - } - - companion object { - private sealed class Command { - object CreateAccount : Command() - - data class Login(val account: String) : Command() - - object Logout : Command() - } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt deleted file mode 100644 index 767ac3e2510d..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt +++ /dev/null @@ -1,56 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.model.AppVersionInfo -import net.mullvad.mullvadvpn.service.MullvadDaemon - -class AppVersionInfoCache(endpoint: ServiceEndpoint) { - private val daemon = endpoint.intermittentDaemon - - var appVersionInfo by - observable(null) { _, _, info -> - endpoint.sendEvent(Event.AppVersionInfo(info)) - } - private set - - var currentVersion by - observable(null) { _, _, version -> - endpoint.sendEvent(Event.CurrentVersion(version)) - } - private set - - init { - daemon.registerListener(this) { newDaemon -> - newDaemon?.let { daemon -> - initializeCurrentVersion(daemon) - registerVersionInfoListener(daemon) - fetchInitialVersionInfo(daemon) - } - } - } - - fun onDestroy() { - daemon.unregisterListener(this) - } - - private fun initializeCurrentVersion(daemon: MullvadDaemon) { - if (currentVersion == null) { - currentVersion = daemon.getCurrentVersion() - } - } - - private fun registerVersionInfoListener(daemon: MullvadDaemon) { - daemon.onAppVersionInfoChange = { newAppVersionInfo -> - synchronized(this@AppVersionInfoCache) { appVersionInfo = newAppVersionInfo } - } - } - - private fun fetchInitialVersionInfo(daemon: MullvadDaemon) { - synchronized(this@AppVersionInfoCache) { - if (appVersionInfo == null) { - appVersionInfo = daemon.getVersionInfo() - } - } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt deleted file mode 100644 index 08b0943c4d6f..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt +++ /dev/null @@ -1,49 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlin.properties.Delegates.observable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request - -class AuthTokenCache(endpoint: ServiceEndpoint) { - private val daemon = endpoint.intermittentDaemon - private val requestQueue = spawnActor() - - var authToken by - observable(null) { _, _, token -> endpoint.sendEvent(Event.AuthToken(token)) } - private set - - init { - endpoint.dispatcher.registerHandler(Request.FetchAuthToken::class) { _ -> - requestQueue.trySendBlocking(Command.Fetch) - } - } - - fun onDestroy() { - requestQueue.close() - } - - private fun spawnActor() = - GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) { - try { - for (command in channel) { - when (command) { - Command.Fetch -> authToken = daemon.await().getWwwAuthToken() - } - } - } catch (exception: ClosedReceiveChannelException) { - // Closed sender, so stop the actor - } - } - - companion object { - private enum class Command { - Fetch - } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt deleted file mode 100644 index 65a27c8f695b..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt +++ /dev/null @@ -1,85 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.talpid.util.EventNotifier - -class ConnectionProxy(val vpnPermission: VpnPermission, endpoint: ServiceEndpoint) { - private enum class Command { - CONNECT, - RECONNECT, - DISCONNECT, - } - - private val commandChannel = spawnActor() - private val daemon = endpoint.intermittentDaemon - private val initialState = TunnelState.Disconnected() - - var onStateChange = EventNotifier(initialState) - - var state by onStateChange.notifiable() - private set - - init { - daemon.registerListener(this) { newDaemon -> - newDaemon?.onTunnelStateChange?.subscribe(this@ConnectionProxy) { newState -> - state = newState - } - } - - onStateChange.subscribe(this) { tunnelState -> - endpoint.sendEvent(Event.TunnelStateChange(tunnelState)) - } - - endpoint.dispatcher.apply { - registerHandler(Request.Connect::class) { _ -> connect() } - registerHandler(Request.Reconnect::class) { _ -> reconnect() } - registerHandler(Request.Disconnect::class) { _ -> disconnect() } - } - } - - fun connect() { - commandChannel.trySendBlocking(Command.CONNECT) - } - - fun reconnect() { - commandChannel.trySendBlocking(Command.RECONNECT) - } - - fun disconnect() { - commandChannel.trySendBlocking(Command.DISCONNECT) - } - - fun onDestroy() { - commandChannel.close() - onStateChange.unsubscribeAll() - daemon.unregisterListener(this) - } - - private fun spawnActor() = - GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) { - try { - while (true) { - val command = channel.receive() - - when (command) { - Command.CONNECT -> { - vpnPermission.request() - daemon.await().connect() - } - Command.RECONNECT -> daemon.await().reconnect() - Command.DISCONNECT -> daemon.await().disconnect() - } - } - } catch (exception: ClosedReceiveChannelException) { - // Closed sender, so stop the actor - } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt deleted file mode 100644 index 7ecfe02d581b..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt +++ /dev/null @@ -1,136 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import java.net.InetAddress -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.CustomDnsOptions -import net.mullvad.mullvadvpn.model.DefaultDnsOptions -import net.mullvad.mullvadvpn.model.DnsOptions -import net.mullvad.mullvadvpn.model.DnsState - -class CustomDns(private val endpoint: ServiceEndpoint) { - private sealed class Command { - @Deprecated("Use SetDnsOptions") class AddDnsServer(val server: InetAddress) : Command() - - @Deprecated("Use SetDnsOptions") class RemoveDnsServer(val server: InetAddress) : Command() - - @Deprecated("Use SetDnsOptions") - class ReplaceDnsServer(val oldServer: InetAddress, val newServer: InetAddress) : Command() - - @Deprecated("Use SetDnsOptions") class SetEnabled(val enabled: Boolean) : Command() - - class SetDnsOptions(val dnsOptions: DnsOptions) : Command() - } - - private val commandChannel = spawnActor() - private val dnsServers = ArrayList() - - private val daemon - get() = endpoint.intermittentDaemon - - private var enabled = false - - init { - endpoint.settingsListener.dnsOptionsNotifier.subscribe(this) { maybeDnsOptions -> - maybeDnsOptions?.let { dnsOptions -> - enabled = dnsOptions.state == DnsState.Custom - dnsServers.clear() - dnsServers.addAll(dnsOptions.customOptions.addresses) - } - } - - endpoint.dispatcher.apply { - registerHandler(Request.AddCustomDnsServer::class) { request -> - commandChannel.trySendBlocking(Command.AddDnsServer(request.address)) - } - - registerHandler(Request.RemoveCustomDnsServer::class) { request -> - commandChannel.trySendBlocking(Command.RemoveDnsServer(request.address)) - } - - registerHandler(Request.ReplaceCustomDnsServer::class) { request -> - commandChannel.trySendBlocking( - Command.ReplaceDnsServer(request.oldAddress, request.newAddress) - ) - } - - registerHandler(Request.SetEnableCustomDns::class) { request -> - commandChannel.trySendBlocking(Command.SetEnabled(request.enable)) - } - - registerHandler(Request.SetDnsOptions::class) { request -> - commandChannel.trySendBlocking(Command.SetDnsOptions(request.dnsOptions)) - } - } - } - - fun onDestroy() { - endpoint.settingsListener.dnsOptionsNotifier.unsubscribe(this) - commandChannel.close() - } - - private fun spawnActor() = - GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) { - try { - while (true) { - val command = channel.receive() - - when (command) { - is Command.AddDnsServer -> doAddDnsServer(command.server) - is Command.RemoveDnsServer -> doRemoveDnsServer(command.server) - is Command.ReplaceDnsServer -> { - doReplaceDnsServer(command.oldServer, command.newServer) - } - is Command.SetEnabled -> changeDnsOptions(command.enabled) - is Command.SetDnsOptions -> setDnsOptions(command.dnsOptions) - } - } - } catch (exception: ClosedReceiveChannelException) { - // Closed sender, so stop the actor - } - } - - private suspend fun doAddDnsServer(server: InetAddress) { - if (!dnsServers.contains(server)) { - dnsServers.add(server) - changeDnsOptions(enabled) - } - } - - private suspend fun doReplaceDnsServer(oldServer: InetAddress, newServer: InetAddress) { - if (oldServer != newServer && !dnsServers.contains(newServer)) { - val index = dnsServers.indexOf(oldServer) - - if (index >= 0) { - dnsServers.removeAt(index) - dnsServers.add(index, newServer) - changeDnsOptions(enabled) - } - } - } - - private suspend fun doRemoveDnsServer(server: InetAddress) { - if (dnsServers.remove(server)) { - changeDnsOptions(enabled) - } - } - - private suspend fun changeDnsOptions(enable: Boolean) { - val options = - DnsOptions( - state = if (enable) DnsState.Custom else DnsState.Default, - customOptions = CustomDnsOptions(dnsServers), - defaultOptions = DefaultDnsOptions() - ) - daemon.await().setDnsOptions(options) - } - - private suspend fun setDnsOptions(dnsOptions: DnsOptions) { - daemon.await().setDnsOptions(dnsOptions) - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomLists.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomLists.kt deleted file mode 100644 index 39702398c7b4..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomLists.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.CustomList - -class CustomLists( - private val endpoint: ServiceEndpoint, - dispatcher: CoroutineDispatcher = Dispatchers.IO -) { - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) - private val daemon - get() = endpoint.intermittentDaemon - - init { - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { createCustomList(it.name) } - } - - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { daemon.await().deleteCustomList(it.id) } - } - - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { updateCustomList(it.customList) } - } - } - - private suspend fun createCustomList(name: String) { - val result = daemon.await().createCustomList(name) - endpoint.sendEvent(Event.CreateCustomListResultEvent(result)) - } - - private suspend fun updateCustomList(customList: CustomList) { - val result = daemon.await().updateCustomList(customList) - endpoint.sendEvent(Event.UpdateCustomListResultEvent(result)) - } - - fun onDestroy() { - scope.cancel() - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt deleted file mode 100644 index db264ed1fee8..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt +++ /dev/null @@ -1,62 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlinx.coroutines.flow.collect -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.service.MullvadDaemon - -class DaemonDeviceDataSource(val endpoint: ServiceEndpoint) { - private val tracker = JobTracker() - - init { - endpoint.intermittentDaemon.registerListener(this) { daemon -> - if (daemon != null) { - launchDeviceEndpointJobs(daemon) - } else { - tracker.cancelAllJobs() - } - } - } - - private fun launchDeviceEndpointJobs(daemon: MullvadDaemon) { - tracker.newBackgroundJob("propagateDeviceUpdatesJob") { - daemon.deviceStateUpdates.collect { newState -> - endpoint.sendEvent(Event.DeviceStateEvent(newState)) - } - } - - tracker.newBackgroundJob("propagateDeviceListUpdatesJob") { - daemon.deviceListUpdates.collect { newState -> - endpoint.sendEvent(Event.DeviceListUpdate(newState)) - } - } - - endpoint.dispatcher.registerHandler(Request.GetDevice::class) { - tracker.newBackgroundJob("getDeviceJob") { daemon.getAndEmitDeviceState() } - } - - endpoint.dispatcher.registerHandler(Request.RefreshDeviceState::class) { - tracker.newBackgroundJob("refreshDeviceJob") { daemon.refreshDevice() } - } - - endpoint.dispatcher.registerHandler(Request.RemoveDevice::class) { request -> - tracker.newBackgroundJob("removeDeviceJob") { - daemon.removeDevice(request.accountToken, request.deviceId).also { result -> - endpoint.sendEvent(Event.DeviceRemovalEvent(request.deviceId, result)) - } - } - } - - endpoint.dispatcher.registerHandler(Request.GetDeviceList::class) { request -> - tracker.newBackgroundJob("getDeviceListJob") { - daemon.getAndEmitDeviceList(request.accountToken) - } - } - } - - fun onDestroy() { - tracker.cancelAllJobs() - endpoint.intermittentDaemon.unregisterListener(this) - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/JsonSettings.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/JsonSettings.kt deleted file mode 100644 index 65d7b6cff0b1..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/JsonSettings.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request - -class JsonSettings( - private val endpoint: ServiceEndpoint, - dispatcher: CoroutineDispatcher = Dispatchers.IO -) { - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) - private val daemon - get() = endpoint.intermittentDaemon - - init { - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { applyJsonSettings(it.json) } - } - - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { exportJsonSettings() } - } - } - - private suspend fun applyJsonSettings(json: String) { - val result = daemon.await().applyJsonSettings(json) - endpoint.sendEvent(Event.ApplyJsonSettingsResult(result)) - } - - private suspend fun exportJsonSettings() { - val json = daemon.await().exportJsonSettings() - endpoint.sendEvent(Event.ExportJsonSettingsResult(json)) - } - - fun onDestroy() { - scope.cancel() - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/PlayPurchaseHandler.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/PlayPurchaseHandler.kt deleted file mode 100644 index 9a1e34b62a76..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/PlayPurchaseHandler.kt +++ /dev/null @@ -1,49 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.PlayPurchase - -class PlayPurchaseHandler( - private val endpoint: ServiceEndpoint, - dispatcher: CoroutineDispatcher = Dispatchers.IO -) { - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) - private val daemon - get() = endpoint.intermittentDaemon - - init { - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { initializePurchase() } - } - - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { verifyPlayPurchase(it.playPurchase) } - } - } - - fun onDestroy() { - scope.cancel() - } - - private suspend fun initializePurchase() { - val result = daemon.await().initPlayPurchase() - endpoint.sendEvent(Event.PlayPurchaseInitResultEvent(result)) - } - - private suspend fun verifyPlayPurchase(playPurchase: PlayPurchase) { - val result = daemon.await().verifyPlayPurchase(playPurchase) - endpoint.sendEvent(Event.PlayPurchaseVerifyResultEvent(result)) - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt deleted file mode 100644 index 8ba6234cf664..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt +++ /dev/null @@ -1,109 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlin.properties.Delegates.observable -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.Constraint -import net.mullvad.mullvadvpn.model.RelayConstraints -import net.mullvad.mullvadvpn.model.RelayList -import net.mullvad.mullvadvpn.model.RelaySettings -import net.mullvad.mullvadvpn.model.WireguardConstraints -import net.mullvad.mullvadvpn.service.MullvadDaemon - -class RelayListListener( - endpoint: ServiceEndpoint, - dispatcher: CoroutineDispatcher = Dispatchers.IO -) { - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) - private val daemon = endpoint.intermittentDaemon - - var relayList by - observable(null) { _, _, relays -> - endpoint.sendEvent(Event.NewRelayList(relays)) - } - private set - - init { - daemon.registerListener(this) { newDaemon -> - newDaemon?.let { daemon -> - setUpListener(daemon) - fetchInitialRelayList(daemon) - } - } - - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { request -> - val update = - getCurrentRelayConstraints() - .copy(location = Constraint.Only(request.locationConstraint)) - daemon.await().setRelaySettings(RelaySettings.Normal(update)) - } - } - - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { request -> - val update = - getCurrentRelayConstraints() - .copy(wireguardConstraints = request.wireguardConstraints) - daemon.await().setRelaySettings(RelaySettings.Normal(update)) - } - } - - scope.launch { - endpoint.dispatcher.parsedMessages.filterIsInstance().collect { - relayList = daemon.await().getRelayLocations() - } - } - - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { request -> - val update = - getCurrentRelayConstraints() - .copy(ownership = request.ownership, providers = request.providers) - daemon.await().setRelaySettings(RelaySettings.Normal(update)) - } - } - } - - fun onDestroy() { - daemon.unregisterListener(this) - scope.cancel() - } - - private fun setUpListener(daemon: MullvadDaemon) { - daemon.onRelayListChange = { relayLocations -> relayList = relayLocations } - } - - private fun fetchInitialRelayList(daemon: MullvadDaemon) { - synchronized(this) { - if (relayList == null) { - relayList = daemon.getRelayLocations() - } - } - } - - private suspend fun getCurrentRelayConstraints(): RelayConstraints = - when (val relaySettings = daemon.await().getSettings()?.relaySettings) { - is RelaySettings.Normal -> relaySettings.relayConstraints - else -> - RelayConstraints( - location = Constraint.Any(), - providers = Constraint.Any(), - ownership = Constraint.Any(), - wireguardConstraints = WireguardConstraints(Constraint.Any()) - ) - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayOverrides.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayOverrides.kt deleted file mode 100644 index cda7a5b94b45..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayOverrides.kt +++ /dev/null @@ -1,37 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.lib.ipc.Request - -class RelayOverrides( - private val endpoint: ServiceEndpoint, - dispatcher: CoroutineDispatcher = Dispatchers.IO -) { - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) - private val daemon - get() = endpoint.intermittentDaemon - - init { - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { daemon.await().setRelayOverride(it.override) } - } - - scope.launch { - endpoint.dispatcher.parsedMessages - .filterIsInstance() - .collect { daemon.await().clearAllRelayOverrides() } - } - } - - fun onDestroy() { - scope.cancel() - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt deleted file mode 100644 index f8fc6aaf6452..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt +++ /dev/null @@ -1,175 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import android.content.Context -import android.os.Looper -import android.os.Messenger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking -import net.mullvad.mullvadvpn.lib.common.util.Intermittent -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.trySendEvent -import net.mullvad.mullvadvpn.service.MullvadDaemon -import net.mullvad.mullvadvpn.service.persistence.SplitTunnelingPersistence -import net.mullvad.talpid.ConnectivityListener - -const val SHOULD_LOG_DEAD_OBJECT_EXCEPTION = true - -class ServiceEndpoint( - looper: Looper, - internal val intermittentDaemon: Intermittent, - val connectivityListener: ConnectivityListener, - context: Context -) { - - private val listeners = mutableMapOf() - private val commands: SendChannel = startRegistrator() - - internal val dispatcher = DispatchingHandler(looper) { message -> Request.fromMessage(message) } - - private var listenerIdCounter = 0 - - val messenger = Messenger(dispatcher) - - val vpnPermission = VpnPermission(context, this) - - val connectionProxy = ConnectionProxy(vpnPermission, this) - val settingsListener = SettingsListener(this) - - val accountCache = AccountCache(this) - val appVersionInfoCache = AppVersionInfoCache(this) - val authTokenCache = AuthTokenCache(this) - val customDns = CustomDns(this) - val relayOverrides = RelayOverrides(this) - val jsonSettings = JsonSettings(this) - val relayListListener = RelayListListener(this) - val splitTunneling = SplitTunneling(SplitTunnelingPersistence(context), this) - val voucherRedeemer = VoucherRedeemer(this, accountCache) - - private val playPurchaseHandler = PlayPurchaseHandler(this) - private val customLists = CustomLists(this) - - private val deviceRepositoryBackend = DaemonDeviceDataSource(this) - - init { - dispatcher.apply { - registerHandler(Request.RegisterListener::class) { request -> - commands.trySendBlocking(Command.RegisterListener(request.listener)) - } - - registerHandler(Request.UnregisterListener::class) { request -> - commands.trySendBlocking(Command.UnregisterListener(request.listenerId)) - } - } - } - - fun onDestroy() { - dispatcher.onDestroy() - commands.close() - - accountCache.onDestroy() - appVersionInfoCache.onDestroy() - authTokenCache.onDestroy() - connectionProxy.onDestroy() - customDns.onDestroy() - deviceRepositoryBackend.onDestroy() - relayListListener.onDestroy() - settingsListener.onDestroy() - splitTunneling.onDestroy() - voucherRedeemer.onDestroy() - playPurchaseHandler.onDestroy() - customLists.onDestroy() - relayOverrides.onDestroy() - jsonSettings.onDestroy() - } - - internal fun sendEvent(event: Event) { - synchronized(this) { - val deadListeners = mutableSetOf() - - for ((id, listener) in listeners) { - if (!listener.trySendEvent(event, SHOULD_LOG_DEAD_OBJECT_EXCEPTION)) { - deadListeners.add(id) - } - } - deadListeners.forEach { listeners.remove(it) } - } - } - - private fun startRegistrator() = - GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) { - try { - for (command in channel) { - when (command) { - is Command.RegisterListener -> { - intermittentDaemon.await() - - registerListener(command.listener) - } - is Command.UnregisterListener -> unregisterListener(command.listenerId) - } - } - } catch (exception: ClosedReceiveChannelException) { - // Registration queue closed; stop registrator - } - } - - private fun registerListener(listener: Messenger) { - synchronized(this) { - val listenerId = newListenerId() - - listeners.put(listenerId, listener) - - val initialEvents = - mutableListOf( - Event.TunnelStateChange(connectionProxy.state), - Event.AccountHistoryEvent(accountCache.onAccountHistoryChange.latestEvent), - Event.SettingsUpdate(settingsListener.settings), - Event.SplitTunnelingUpdate(splitTunneling.onChange.latestEvent), - Event.CurrentVersion(appVersionInfoCache.currentVersion), - Event.AppVersionInfo(appVersionInfoCache.appVersionInfo), - Event.NewRelayList(relayListListener.relayList), - Event.AuthToken(authTokenCache.authToken), - Event.ListenerReady(messenger, listenerId) - ) - - if (vpnPermission.waitingForResponse) { - initialEvents.add(Event.VpnPermissionRequest) - } - - val didSuccessfullySendAllMessages = - initialEvents.all { event -> - listener.trySendEvent(event, SHOULD_LOG_DEAD_OBJECT_EXCEPTION) - } - if (didSuccessfullySendAllMessages.not()) { - listeners.remove(listenerId) - } - } - } - - private fun unregisterListener(listenerId: Int) { - synchronized(this) { listeners.remove(listenerId) } - } - - private fun newListenerId(): Int { - val listenerId = listenerIdCounter - - listenerIdCounter += 1 - - return listenerId - } - - companion object { - sealed class Command { - data class RegisterListener(val listener: Messenger) : Command() - - data class UnregisterListener(val listenerId: Int) : Command() - } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt deleted file mode 100644 index 9422b6a94e0a..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt +++ /dev/null @@ -1,147 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.DnsOptions -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.mullvadvpn.service.MullvadDaemon -import net.mullvad.talpid.util.EventNotifier - -class SettingsListener(endpoint: ServiceEndpoint) { - private sealed class Command { - class SetAllowLan(val allow: Boolean) : Command() - - class SetAutoConnect(val autoConnect: Boolean) : Command() - - class SetWireGuardMtu(val mtu: Int?) : Command() - - class SetObfuscationSettings(val settings: ObfuscationSettings?) : Command() - - class SetQuantumResistant(val quantumResistant: QuantumResistantState) : Command() - } - - private val commandChannel = spawnActor() - private val daemon = endpoint.intermittentDaemon - - val dnsOptionsNotifier = EventNotifier(null) - val relaySettingsNotifier = EventNotifier(null) - val obfuscationSettingsNotifier = EventNotifier(null) - val settingsNotifier = EventNotifier(null) - - var settings by settingsNotifier.notifiable() - private set - - init { - daemon.registerListener(this) { newDaemon -> - if (newDaemon != null) { - registerListener(newDaemon) - fetchInitialSettings(newDaemon) - } - } - - settingsNotifier.subscribe(this) { settings -> - endpoint.sendEvent(Event.SettingsUpdate(settings)) - } - - endpoint.dispatcher.apply { - registerHandler(Request.SetAllowLan::class) { request -> - commandChannel.trySendBlocking(Command.SetAllowLan(request.allow)) - } - - registerHandler(Request.SetAutoConnect::class) { request -> - commandChannel.trySendBlocking(Command.SetAutoConnect(request.autoConnect)) - } - - registerHandler(Request.SetWireGuardMtu::class) { request -> - commandChannel.trySendBlocking(Command.SetWireGuardMtu(request.mtu)) - } - - registerHandler(Request.SetObfuscationSettings::class) { request -> - commandChannel.trySendBlocking(Command.SetObfuscationSettings(request.settings)) - } - - registerHandler(Request.SetWireGuardQuantumResistant::class) { request -> - commandChannel.trySendBlocking( - Command.SetQuantumResistant(request.quantumResistant) - ) - } - } - } - - fun onDestroy() { - commandChannel.close() - daemon.unregisterListener(this) - - dnsOptionsNotifier.unsubscribeAll() - relaySettingsNotifier.unsubscribeAll() - obfuscationSettingsNotifier.unsubscribeAll() - settingsNotifier.unsubscribeAll() - } - - fun subscribe(id: Any, listener: (Settings) -> Unit) { - settingsNotifier.subscribe(id) { maybeSettings -> - maybeSettings?.let { settings -> listener(settings) } - } - } - - fun unsubscribe(id: Any) { - settingsNotifier.unsubscribe(id) - } - - private fun registerListener(daemon: MullvadDaemon) { - daemon.onSettingsChange.subscribe(this, ::handleNewSettings) - } - - private fun fetchInitialSettings(daemon: MullvadDaemon) { - synchronized(this) { handleNewSettings(daemon.getSettings()) } - } - - private fun handleNewSettings(newSettings: Settings?) { - if (newSettings != null) { - synchronized(this) { - if (settings?.tunnelOptions?.dnsOptions != newSettings.tunnelOptions.dnsOptions) { - dnsOptionsNotifier.notify(newSettings.tunnelOptions.dnsOptions) - } - - if (settings?.relaySettings != newSettings.relaySettings) { - relaySettingsNotifier.notify(newSettings.relaySettings) - } - - if (settings?.obfuscationSettings != newSettings.obfuscationSettings) { - obfuscationSettingsNotifier.notify(newSettings.obfuscationSettings) - } - - settings = newSettings - } - } - } - - private fun spawnActor() = - GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) { - try { - for (command in channel) { - when (command) { - is Command.SetAllowLan -> daemon.await().setAllowLan(command.allow) - is Command.SetAutoConnect -> - daemon.await().setAutoConnect(command.autoConnect) - is Command.SetWireGuardMtu -> daemon.await().setWireguardMtu(command.mtu) - is Command.SetObfuscationSettings -> - daemon.await().setObfuscationSettings(command.settings) - is Command.SetQuantumResistant -> - daemon.await().setQuantumResistant(command.quantumResistant) - } - } - } catch (exception: ClosedReceiveChannelException) { - // Closed sender, so stop the actor - } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt deleted file mode 100644 index 4fbe89c82b5a..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt +++ /dev/null @@ -1,66 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.service.persistence.SplitTunnelingPersistence -import net.mullvad.talpid.util.EventNotifier - -class SplitTunneling(persistence: SplitTunnelingPersistence, endpoint: ServiceEndpoint) { - private val excludedApps = persistence.excludedApps.toMutableSet() - - private var enabled by - observable(persistence.enabled) { _, wasEnabled, isEnabled -> - if (wasEnabled != isEnabled) { - persistence.enabled = isEnabled - update() - } - } - - val onChange = - EventNotifier( - if (enabled) { - excludedApps.toList() - } else { - null - } - ) - - init { - onChange.subscribe(this) { excludedApps -> - endpoint.sendEvent(Event.SplitTunnelingUpdate(excludedApps)) - } - - endpoint.dispatcher.apply { - registerHandler(Request.IncludeApp::class) { request -> - excludedApps.remove(request.packageName) - update() - } - - registerHandler(Request.ExcludeApp::class) { request -> - excludedApps.add(request.packageName) - update() - } - - registerHandler(Request.SetEnableSplitTunneling::class) { request -> - enabled = request.enable - } - - registerHandler(Request.PersistExcludedApps::class) { _ -> - persistence.excludedApps = excludedApps - } - } - } - - fun onDestroy() { - onChange.unsubscribeAll() - } - - private fun update() { - if (enabled) { - onChange.notify(excludedApps.toList()) - } else { - onChange.notify(null) - } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt deleted file mode 100644 index e7ecf5807d3f..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking -import net.mullvad.mullvadvpn.lib.common.util.parseAsDateTime -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.VoucherSubmissionResult - -class VoucherRedeemer( - private val endpoint: ServiceEndpoint, - private val accountCache: AccountCache -) { - private val daemon - get() = endpoint.intermittentDaemon - - private val voucherChannel = spawnActor() - - init { - endpoint.dispatcher.registerHandler(Request.SubmitVoucher::class) { request -> - voucherChannel.trySendBlocking(request.voucher) - } - } - - fun onDestroy() { - voucherChannel.close() - } - - private fun spawnActor() = - GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) { - try { - for (voucher in channel) { - val result = daemon.await().submitVoucher(voucher) - - // Let AccountCache know about the new expiry - if (result is VoucherSubmissionResult.Ok) { - val newExpiry = result.submission.newExpiry.parseAsDateTime() - if (newExpiry != null) { - accountCache.onAccountExpiryChange.notify( - AccountExpiry.Available(newExpiry) - ) - } - } - endpoint.sendEvent(Event.VoucherSubmissionResult(voucher, result)) - } - } catch (exception: ClosedReceiveChannelException) { - // Voucher channel was closed, stop the actor - } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt deleted file mode 100644 index 57fd6dc40c98..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt +++ /dev/null @@ -1,46 +0,0 @@ -package net.mullvad.mullvadvpn.service.endpoint - -import android.content.Context -import android.content.Intent -import android.net.VpnService -import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS -import net.mullvad.mullvadvpn.lib.common.util.Intermittent -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.Request - -class VpnPermission(private val context: Context, private val endpoint: ServiceEndpoint) { - private val isGranted = Intermittent() - - var waitingForResponse = false - private set - - init { - endpoint.dispatcher.registerHandler(Request.VpnPermissionResponse::class) { request -> - waitingForResponse = false - isGranted.spawnUpdate(request.isGranted) - } - } - - suspend fun request(): Boolean { - val intent = VpnService.prepare(context) - - if (intent == null) { - isGranted.update(true) - } else { - val activityIntent = - Intent().apply { - setClassName(context.packageName, MAIN_ACTIVITY_CLASS) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - - isGranted.update(null) - waitingForResponse = true - - context.startActivity(activityIntent) - endpoint.sendEvent(Event.VpnPermissionRequest) - } - - return isGranted.await() - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/migration/MigrateSplitTunneling.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/migration/MigrateSplitTunneling.kt new file mode 100644 index 000000000000..486d2674b41d --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/migration/MigrateSplitTunneling.kt @@ -0,0 +1,51 @@ +package net.mullvad.mullvadvpn.service.migration + +import android.content.Context +import java.io.File + +/** + * Migration for split tunneling apps, from Shared Preferences to Daemon. + * + * Previously apps where stored in Shared Preferences and injected from straight into the tunnel + * without the knowledge of the daemon. This migration happens in conjunction with the daemon. + * + * See: mullvad-daemon/src/migrations/v9.rs + */ +class MigrateSplitTunneling(private val context: Context) { + fun migrate() { + // Get old settings, if not found return + val enabled = getOldSettings(context) ?: return + + // Migrate enable settings to file so that the daemon can read it + migrateSplitTunnelingEnabled(context, enabled) + } + + private fun getOldSettings(context: Context): Boolean? { + // Get from shared preferences and appListFile + val appListFile = File(context.filesDir, SPLIT_TUNNELING_APPS_FILE) + val preferences = getSharedPreferences(context) + + return if (appListFile.exists() && preferences.contains(KEY_ENABLED)) { + preferences.getBoolean(KEY_ENABLED, false) + } else { + null + } + } + + private fun migrateSplitTunnelingEnabled(context: Context, enabled: Boolean) { + val enabledFile = File(context.filesDir, SPLIT_TUNNELING_ENABLED_FILE) + if (enabledFile.createNewFile()) { + enabledFile.writeText(enabled.toString()) + } + } + + private fun getSharedPreferences(context: Context) = + context.getSharedPreferences(SHARED_PREFERENCES, Context.MODE_PRIVATE) + + companion object { + private const val SHARED_PREFERENCES = "split_tunnelling" + private const val KEY_ENABLED = "enabled" + private const val SPLIT_TUNNELING_APPS_FILE = "split-tunnelling.txt" + private const val SPLIT_TUNNELING_ENABLED_FILE = "split-tunnelling-enabled.txt" + } +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt deleted file mode 100644 index 634051b2b1e9..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt +++ /dev/null @@ -1,147 +0,0 @@ -package net.mullvad.mullvadvpn.service.notifications - -import android.annotation.SuppressLint -import android.app.Notification -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.core.app.NotificationCompat -import kotlin.properties.Delegates.observable -import kotlinx.coroutines.delay -import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS -import net.mullvad.mullvadvpn.lib.common.util.Intermittent -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.lib.common.util.SdkUtils -import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionMissing -import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.service.MullvadDaemon -import net.mullvad.mullvadvpn.service.R -import net.mullvad.mullvadvpn.service.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.service.endpoint.AccountCache -import org.joda.time.DateTime -import org.joda.time.Duration - -class AccountExpiryNotification( - val context: Context, - val daemon: Intermittent, - val accountCache: AccountCache -) { - - private val jobTracker = JobTracker() - private val resources = context.resources - - private val buyMoreTimeUrl = resources.getString(R.string.account_url) - - private val channel = - NotificationChannel( - context, - "mullvad_account_time", - NotificationCompat.VISIBILITY_PRIVATE, - R.string.account_time_notification_channel_name, - R.string.account_time_notification_channel_description, - NotificationManager.IMPORTANCE_HIGH, - true, - true - ) - - var accountExpiry by - observable(AccountExpiry.Missing) { _, oldValue, newValue -> - if (oldValue != newValue) { - jobTracker.newUiJob("update") { update(newValue) } - } - } - - init { - accountCache.onAccountExpiryChange.subscribe(this) { expiry -> accountExpiry = expiry } - } - - fun onDestroy() { - accountCache.onAccountExpiryChange.unsubscribe(this) - } - - // Suppressing since the permission check is done by calling a common util in another module. - @SuppressLint("MissingPermission") - private suspend fun update(expiry: AccountExpiry) { - val expiryDate = expiry.date() - val durationUntilExpiry = expiryDate?.remainingTime() - - if (accountCache.isNewAccount.not() && durationUntilExpiry?.isCloseToExpiry() == true) { - if (context.isNotificationPermissionMissing().not()) { - val notification = build(expiryDate, durationUntilExpiry) - channel.notificationManager.notify(NOTIFICATION_ID, notification) - } - jobTracker.newUiJob("scheduleUpdate") { scheduleUpdate() } - } else { - channel.notificationManager.cancel(NOTIFICATION_ID) - jobTracker.cancelJob("scheduleUpdate") - } - } - - private fun DateTime.remainingTime(): Duration { - return Duration(DateTime.now(), this) - } - - private fun Duration.isCloseToExpiry(): Boolean { - return isShorterThan(REMAINING_TIME_FOR_REMINDERS) - } - - private suspend fun scheduleUpdate() { - delay(TIME_BETWEEN_CHECKS) - update(accountExpiry) - } - - private suspend fun build(expiry: DateTime, remainingTime: Duration): Notification { - val url = - jobTracker.runOnBackground { - Uri.parse("$buyMoreTimeUrl?token=${daemon.await().getWwwAuthToken()}") - } - val intent = - if (IS_PLAY_BUILD) { - Intent().apply { - setClassName(context.packageName, MAIN_ACTIVITY_CLASS) - flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - action = Intent.ACTION_MAIN - } - } else { - Intent(Intent.ACTION_VIEW, url) - } - val pendingIntent = - PendingIntent.getActivity(context, 1, intent, SdkUtils.getSupportedPendingIntentFlags()) - - return channel.buildNotification(pendingIntent, format(expiry, remainingTime)) - } - - private fun format(expiry: DateTime, remainingTime: Duration): String { - if (remainingTime.isShorterThan(Duration.ZERO)) { - return resources.getString(R.string.account_credit_has_expired) - } else { - val remainingTimeInfo = remainingTime.toPeriodTo(expiry) - - if (remainingTimeInfo.days >= 1) { - return getRemainingText( - R.plurals.account_credit_expires_in_days, - remainingTime.standardDays.toInt() - ) - } else if (remainingTimeInfo.hours >= 1) { - return getRemainingText( - R.plurals.account_credit_expires_in_hours, - remainingTime.standardHours.toInt() - ) - } else { - return resources.getString(R.string.account_credit_expires_in_a_few_minutes) - } - } - } - - private fun getRemainingText(pluralId: Int, quantity: Int): String { - return resources.getQuantityString(pluralId, quantity, quantity) - } - - companion object { - const val NOTIFICATION_ID: Int = 2 - val REMAINING_TIME_FOR_REMINDERS = Duration.standardDays(2) - const val TIME_BETWEEN_CHECKS: Long = 12 /* h */ * 60 /* min */ * 60 /* s */ * 1000 /* ms */ - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/ForegroundNotificationManager.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/ForegroundNotificationManager.kt new file mode 100644 index 000000000000..d65cb7255c08 --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/ForegroundNotificationManager.kt @@ -0,0 +1,80 @@ +package net.mullvad.mullvadvpn.service.notifications + +import android.app.Service +import android.content.pm.ServiceInfo +import android.net.VpnService +import android.os.Build +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.lib.common.constant.TAG +import net.mullvad.mullvadvpn.lib.model.Notification +import net.mullvad.mullvadvpn.lib.model.NotificationChannel +import net.mullvad.mullvadvpn.lib.model.NotificationTunnelState +import net.mullvad.mullvadvpn.lib.model.NotificationUpdate +import net.mullvad.mullvadvpn.service.MullvadVpnService +import net.mullvad.mullvadvpn.service.notifications.tunnelstate.TunnelStateNotificationProvider +import net.mullvad.mullvadvpn.service.notifications.tunnelstate.toNotification + +class ForegroundNotificationManager( + private val vpnService: MullvadVpnService, + private val tunnelStateNotificationProvider: TunnelStateNotificationProvider, + private val scope: CoroutineScope, +) { + suspend fun start(foregroundProvider: ShouldBeOnForegroundProvider) { + scope.launch { + foregroundProvider.shouldBeOnForeground.collect { + if (it) { + Log.d(TAG, "Starting foreground") + notifyForeground(getTunnelStateNotificationOrDefault()) + } else { + Log.d(TAG, "Stopping foreground") + vpnService.stopForeground(Service.STOP_FOREGROUND_DETACH) + } + } + } + } + + private fun getTunnelStateNotificationOrDefault(): Notification.Tunnel { + val current = tunnelStateNotificationProvider.notifications.value + + return if (current is NotificationUpdate.Notify) { + current.value + } else { + defaultNotification + } + } + + private fun notifyForeground(tunnelStateNotification: Notification.Tunnel) { + + val androidNotification = tunnelStateNotification.toNotification(vpnService) + if (VpnService.prepare(vpnService) != null) { + // Got connect/disconnect intent, but we don't have permission to go in foreground. + // tunnel state will return permission and we will eventually get stopped by system. + Log.d(TAG, "Did not start foreground: VPN permission not granted") + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Log.d(TAG, "Starting foreground UPSIDE_DOWN_CAKE") + vpnService.startForeground( + tunnelStateNotificationProvider.notificationId.value, + androidNotification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED + ) + } else { + vpnService.startForeground( + tunnelStateNotificationProvider.notificationId.value, + androidNotification, + ) + } + } + + private val defaultNotification = + Notification.Tunnel( + NotificationChannel.TunnelUpdates.id, + NotificationTunnelState.Disconnected(true), + emptyList(), + false + ) +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt deleted file mode 100644 index d6e904e6ca69..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt +++ /dev/null @@ -1,97 +0,0 @@ -package net.mullvad.mullvadvpn.service.notifications - -import android.app.Notification -import android.app.PendingIntent -import android.content.Context -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import net.mullvad.mullvadvpn.service.R - -class NotificationChannel( - val context: Context, - val id: String, - val visibility: Int, - name: Int, - description: Int, - importance: Int, - isVibrationEnabled: Boolean, - isBadgeEnabled: Boolean -) { - private val badgeColor by lazy { context.getColor(R.color.colorPrimary) } - - val notificationManager = NotificationManagerCompat.from(context) - - init { - val channelName = context.getString(name) - val channelDescription = context.getString(description) - - val channel = - NotificationChannelCompat.Builder(id, importance) - .setName(channelName) - .setDescription(channelDescription) - .setShowBadge(isBadgeEnabled) - .setVibrationEnabled(isVibrationEnabled) - .build() - - notificationManager.createNotificationChannel(channel) - } - - fun buildNotification( - intent: PendingIntent, - title: String, - deleteIntent: PendingIntent? = null, - isOngoing: Boolean = false - ): Notification { - return buildNotification(intent, title, emptyList(), deleteIntent, isOngoing) - } - - fun buildNotification( - intent: PendingIntent, - title: Int, - deleteIntent: PendingIntent? = null, - isOngoing: Boolean = false - ): Notification { - return buildNotification(intent, title, emptyList(), deleteIntent, isOngoing) - } - - fun buildNotification( - pendingIntent: PendingIntent, - title: Int, - actions: List, - deleteIntent: PendingIntent? = null, - isOngoing: Boolean = false - ): Notification { - return buildNotification( - pendingIntent, - context.getString(title), - actions, - deleteIntent, - isOngoing - ) - } - - private fun buildNotification( - pendingIntent: PendingIntent, - title: String, - actions: List, - deleteIntent: PendingIntent? = null, - isOngoing: Boolean = false - ): Notification { - val builder = - NotificationCompat.Builder(context, id) - .setSmallIcon(R.drawable.small_logo_black) - .setColor(badgeColor) - .setContentTitle(title) - .setContentIntent(pendingIntent) - .setVisibility(visibility) - .setOngoing(isOngoing) - for (action in actions) { - builder.addAction(action) - } - - deleteIntent?.let { intent -> builder.setDeleteIntent(intent) } - - return builder.build() - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannelFactory.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannelFactory.kt new file mode 100644 index 000000000000..c7c9a67b43cb --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannelFactory.kt @@ -0,0 +1,57 @@ +package net.mullvad.mullvadvpn.service.notifications + +import android.app.NotificationManager +import android.content.res.Resources +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import net.mullvad.mullvadvpn.lib.common.R +import net.mullvad.mullvadvpn.lib.model.NotificationChannel +import net.mullvad.mullvadvpn.lib.model.NotificationChannelId + +class NotificationChannelFactory( + private val notificationManagerCompat: NotificationManagerCompat, + private val resources: Resources, + channels: List +) { + init { + channels.forEach { create(it) } + } + + private fun create(channel: NotificationChannel): NotificationChannelId { + val androidChannel = channel.toAndroidNotificationChannel() + notificationManagerCompat.createNotificationChannel(androidChannel) + return channel.id + } + + private fun NotificationChannel.toAndroidNotificationChannel(): NotificationChannelCompat = + when (this) { + NotificationChannel.AccountUpdates -> NotificationChannel.AccountUpdates.toChannel() + NotificationChannel.TunnelUpdates -> NotificationChannel.TunnelUpdates.toChannel() + } + + private fun NotificationChannel.TunnelUpdates.toChannel(): NotificationChannelCompat = + NotificationChannelCompat.Builder( + id.value, + NotificationManager.IMPORTANCE_LOW, + ) + .setName(resources.getString(R.string.foreground_notification_channel_name)) + .setDescription( + resources.getString(R.string.foreground_notification_channel_description) + ) + .setShowBadge(false) + .setVibrationEnabled(false) + .build() + + private fun NotificationChannel.AccountUpdates.toChannel(): NotificationChannelCompat = + NotificationChannelCompat.Builder( + id.value, + NotificationManager.IMPORTANCE_HIGH, + ) + .setName(resources.getString(R.string.account_time_notification_channel_name)) + .setDescription( + resources.getString(R.string.account_time_notification_channel_description) + ) + .setShowBadge(true) + .setVibrationEnabled(true) + .build() +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationManager.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationManager.kt new file mode 100644 index 000000000000..74aff9cca0cb --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationManager.kt @@ -0,0 +1,59 @@ +package net.mullvad.mullvadvpn.service.notifications + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.lib.model.Notification +import net.mullvad.mullvadvpn.lib.model.NotificationUpdate +import net.mullvad.mullvadvpn.service.notifications.accountexpiry.toNotification +import net.mullvad.mullvadvpn.service.notifications.tunnelstate.toNotification + +class NotificationManager( + private val notificationManagerCompat: NotificationManagerCompat, + notificationProviders: List>, + context: Context, + val scope: CoroutineScope, +) { + + init { + scope.launch { + notificationProviders + .map { it.notifications } + .merge() + .collect { notificationUpdate -> + when (notificationUpdate) { + is NotificationUpdate.Cancel -> + notificationManagerCompat.cancel( + notificationUpdate.notificationId.value + ) + is NotificationUpdate.Notify -> { + val notification = notificationUpdate.value + val androidNotification = notification.toAndroidNotification(context) + if ( + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + notificationManagerCompat.notify( + notificationUpdate.notificationId.value, + androidNotification + ) + } + } + } + } + } + } + + private fun Notification.toAndroidNotification(context: Context): android.app.Notification = + when (this) { + is Notification.Tunnel -> toNotification(context) + is Notification.AccountExpiry -> toNotification(context) + } +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationProvider.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationProvider.kt new file mode 100644 index 000000000000..ecdde13d7aa3 --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationProvider.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.service.notifications + +import kotlinx.coroutines.flow.Flow +import net.mullvad.mullvadvpn.lib.model.NotificationUpdate + +interface NotificationProvider { + val notifications: Flow> +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/ShouldBeOnForegroundProvider.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/ShouldBeOnForegroundProvider.kt new file mode 100644 index 000000000000..90e533465c7f --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/ShouldBeOnForegroundProvider.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.service.notifications + +import kotlinx.coroutines.flow.StateFlow + +interface ShouldBeOnForegroundProvider { + val shouldBeOnForeground: StateFlow +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt deleted file mode 100644 index 44a34589d96f..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt +++ /dev/null @@ -1,152 +0,0 @@ -package net.mullvad.mullvadvpn.service.notifications - -import android.annotation.SuppressLint -import android.app.Notification -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import androidx.core.app.NotificationCompat -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS -import net.mullvad.mullvadvpn.lib.common.util.SdkUtils -import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionMissing -import net.mullvad.mullvadvpn.lib.common.util.getErrorNotificationResources -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.service.R -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.tunnel.ErrorStateCause - -class TunnelStateNotification(val context: Context) { - private val channel = - NotificationChannel( - context, - "vpn_tunnel_status", - NotificationCompat.VISIBILITY_SECRET, - R.string.foreground_notification_channel_name, - R.string.foreground_notification_channel_description, - NotificationManager.IMPORTANCE_MIN, - false, - false - ) - - private val notificationText: Int - get() = - when (val state = tunnelState) { - is TunnelState.Disconnected -> R.string.unsecured - is TunnelState.Connecting -> { - if (reconnecting) { - R.string.reconnecting - } else { - R.string.connecting - } - } - is TunnelState.Connected -> R.string.secured - is TunnelState.Disconnecting -> { - when (state.actionAfterDisconnect) { - ActionAfterDisconnect.Reconnect -> R.string.reconnecting - else -> R.string.disconnecting - } - } - is TunnelState.Error -> { - if (state.isDeviceOffline()) { - R.string.blocking_internet_device_offline - } else { - state.errorState.getErrorNotificationResources(context).titleResourceId - } - } - } - - private fun TunnelState.isDeviceOffline(): Boolean { - return (this as? TunnelState.Error)?.errorState?.cause is ErrorStateCause.IsOffline - } - - private val shouldDisplayOngoingNotification: Boolean - get() = - when (tunnelState) { - is TunnelState.Connected -> true - is TunnelState.Disconnected, - is TunnelState.Connecting, - is TunnelState.Disconnecting, - is TunnelState.Error -> false - } - - private var reconnecting = false - private var showingReconnecting = false - - var showAction by observable(false) { _, _, _ -> update() } - - var tunnelState by - observable(TunnelState.Disconnected()) { _, _, newState -> - val isReconnecting = newState is TunnelState.Connecting && reconnecting - val shouldBeginReconnecting = - (newState as? TunnelState.Disconnecting)?.actionAfterDisconnect == - ActionAfterDisconnect.Reconnect - reconnecting = isReconnecting || shouldBeginReconnecting - update() - } - - var visible by - observable(true) { _, _, newValue -> - if (newValue == true) { - update() - } else { - channel.notificationManager.cancel(NOTIFICATION_ID) - } - } - - // Suppressing since the permission check is done by calling a common util in another module. - @SuppressLint("MissingPermission") - private fun update() { - if ( - context.isNotificationPermissionMissing().not() && - visible && - (!reconnecting || !showingReconnecting) - ) { - channel.notificationManager.notify(NOTIFICATION_ID, build()) - } - } - - fun build(): Notification { - val intent = - Intent().apply { - setClassName(context.packageName, MAIN_ACTIVITY_CLASS) - flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - action = Intent.ACTION_MAIN - } - val pendingIntent = - PendingIntent.getActivity(context, 1, intent, SdkUtils.getSupportedPendingIntentFlags()) - val actions = - if (showAction) { - listOf(buildAction()) - } else { - emptyList() - } - - return channel.buildNotification( - pendingIntent, - notificationText, - actions, - isOngoing = shouldDisplayOngoingNotification - ) - } - - private fun buildAction(): NotificationCompat.Action { - val action = TunnelStateNotificationAction.from(tunnelState) - val label = context.getString(action.text) - val intent = Intent(action.key).setPackage(context.packageName) - val pendingIntent = - PendingIntent.getForegroundService( - context, - 1, - intent, - SdkUtils.getSupportedPendingIntentFlags() - ) - - return NotificationCompat.Action(action.icon, label, pendingIntent) - } - - companion object { - const val NOTIFICATION_ID: Int = 1 - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt deleted file mode 100644 index c836c765f6bb..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt +++ /dev/null @@ -1,59 +0,0 @@ -package net.mullvad.mullvadvpn.service.notifications - -import net.mullvad.mullvadvpn.lib.common.constant.KEY_CONNECT_ACTION -import net.mullvad.mullvadvpn.lib.common.constant.KEY_DISCONNECT_ACTION -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.service.R -import net.mullvad.talpid.tunnel.ActionAfterDisconnect - -enum class TunnelStateNotificationAction { - Connect, - Disconnect, - Cancel, - Dismiss; - - val text - get() = - when (this) { - Connect -> R.string.connect - Disconnect -> R.string.disconnect - Cancel -> R.string.cancel - Dismiss -> R.string.dismiss - } - - val key - get() = - when (this) { - Connect -> KEY_CONNECT_ACTION - else -> KEY_DISCONNECT_ACTION - } - - val icon - get() = - when (this) { - Connect -> R.drawable.icon_notification_connect - else -> R.drawable.icon_notification_disconnect - } - - companion object { - fun from(tunnelState: TunnelState) = - when (tunnelState) { - is TunnelState.Disconnected -> Connect - is TunnelState.Connecting -> Cancel - is TunnelState.Connected -> Disconnect - is TunnelState.Disconnecting -> { - when (tunnelState.actionAfterDisconnect) { - ActionAfterDisconnect.Reconnect -> Cancel - else -> Connect - } - } - is TunnelState.Error -> { - if (tunnelState.errorState.isBlocking) { - Disconnect - } else { - Dismiss - } - } - } - } -} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt new file mode 100644 index 000000000000..5b5570470db2 --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryAndroidNotification.kt @@ -0,0 +1,62 @@ +package net.mullvad.mullvadvpn.service.notifications.accountexpiry + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import androidx.core.app.NotificationCompat +import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS +import net.mullvad.mullvadvpn.lib.common.util.SdkUtils +import net.mullvad.mullvadvpn.lib.common.util.createAccountUri +import net.mullvad.mullvadvpn.lib.model.Notification +import net.mullvad.mullvadvpn.service.R +import org.joda.time.Duration + +internal fun Notification.AccountExpiry.toNotification(context: Context) = + NotificationCompat.Builder(context, channelId.value) + .setContentIntent(contentIntent(context)) + .setContentTitle(context.resources.contentTitle(durationUntilExpiry)) + .setSmallIcon(R.drawable.small_logo_white) + .setOngoing(ongoing) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .build() + +private fun Notification.AccountExpiry.contentIntent(context: Context): PendingIntent { + + val intent = + if (websiteAuthToken == null) { + Intent().apply { + setClassName(context.packageName, MAIN_ACTIVITY_CLASS) + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + action = Intent.ACTION_MAIN + } + } else { + val uri = createAccountUri(context.getString(R.string.account_url), websiteAuthToken) + Intent(Intent.ACTION_VIEW, uri) + } + return PendingIntent.getActivity(context, 1, intent, SdkUtils.getSupportedPendingIntentFlags()) +} + +private fun Resources.contentTitle(remainingTime: Duration): String = + when { + remainingTime.isShorterThan(Duration.ZERO) -> { + getString(R.string.account_credit_has_expired) + } + remainingTime.standardDays >= 1 -> { + getRemainingText( + R.plurals.account_credit_expires_in_days, + remainingTime.standardDays.toInt() + ) + } + remainingTime.standardHours >= 1 -> { + getRemainingText( + R.plurals.account_credit_expires_in_hours, + remainingTime.standardHours.toInt() + ) + } + else -> getString(R.string.account_credit_expires_in_a_few_minutes) + } + +private fun Resources.getRemainingText(pluralId: Int, quantity: Int): String { + return getQuantityString(pluralId, quantity, quantity) +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt new file mode 100644 index 000000000000..b1f16138909a --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/accountexpiry/AccountExpiryNotificationProvider.kt @@ -0,0 +1,64 @@ +package net.mullvad.mullvadvpn.service.notifications.accountexpiry + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.Notification +import net.mullvad.mullvadvpn.lib.model.NotificationChannelId +import net.mullvad.mullvadvpn.lib.model.NotificationId +import net.mullvad.mullvadvpn.lib.model.NotificationUpdate +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.service.constant.IS_PLAY_BUILD +import net.mullvad.mullvadvpn.service.notifications.NotificationProvider +import org.joda.time.DateTime +import org.joda.time.Duration + +class AccountExpiryNotificationProvider( + channelId: NotificationChannelId, + accountRepository: AccountRepository, + deviceRepository: DeviceRepository, +) : NotificationProvider { + private val notificationId = NotificationId(3) + + override val notifications: Flow> = + combine( + deviceRepository.deviceState, + accountRepository.accountData.filterNotNull(), + accountRepository.isNewAccount + ) { deviceState, accountData, isNewAccount -> + if (deviceState !is DeviceState.LoggedIn) { + return@combine NotificationUpdate.Cancel(notificationId) + } + + val durationUntilExpiry = accountData.expiryDate.remainingTime() + + val notification = + Notification.AccountExpiry( + channelId = channelId, + actions = emptyList(), + websiteAuthToken = + if (!IS_PLAY_BUILD) accountRepository.getWebsiteAuthToken() else null, + durationUntilExpiry = durationUntilExpiry, + ) + if (!isNewAccount && durationUntilExpiry.isCloseToExpiry()) { + NotificationUpdate.Notify(notificationId, notification) + } else { + NotificationUpdate.Cancel(notificationId) + } + } + .filterNotNull() + + private fun DateTime.remainingTime(): Duration { + return Duration(DateTime.now(), this) + } + + private fun Duration.isCloseToExpiry(): Boolean { + return isShorterThan(REMAINING_TIME_FOR_REMINDERS) + } + + companion object { + private val REMAINING_TIME_FOR_REMINDERS = Duration.standardDays(2) + } +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/tunnelstate/TunnelStateNotificationAction.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/tunnelstate/TunnelStateNotificationAction.kt new file mode 100644 index 000000000000..74027ac940b5 --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/tunnelstate/TunnelStateNotificationAction.kt @@ -0,0 +1,106 @@ +package net.mullvad.mullvadvpn.service.notifications.tunnelstate + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import net.mullvad.mullvadvpn.lib.common.constant.KEY_CONNECT_ACTION +import net.mullvad.mullvadvpn.lib.common.constant.KEY_DISCONNECT_ACTION +import net.mullvad.mullvadvpn.lib.common.constant.KEY_REQUEST_VPN_PERMISSION +import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS +import net.mullvad.mullvadvpn.lib.common.util.SdkUtils +import net.mullvad.mullvadvpn.lib.model.Notification +import net.mullvad.mullvadvpn.lib.model.NotificationAction +import net.mullvad.mullvadvpn.lib.model.NotificationTunnelState +import net.mullvad.mullvadvpn.service.R + +internal fun Notification.Tunnel.toNotification(context: Context) = + NotificationCompat.Builder(context, channelId.value) + .setContentIntent(contentIntent(context)) + .setContentTitle(context.getString(state.contentTitleResourceId())) + .setSmallIcon(R.drawable.small_logo_white) + .apply { actions.forEach { addAction(it.toCompatAction(context)) } } + .setOngoing(ongoing) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .build() + +private fun Notification.Tunnel.contentIntent(context: Context): PendingIntent { + val intent = + Intent().apply { + setClassName(context.packageName, MAIN_ACTIVITY_CLASS) + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + action = Intent.ACTION_MAIN + } + + return PendingIntent.getActivity(context, 1, intent, SdkUtils.getSupportedPendingIntentFlags()) +} + +private fun NotificationTunnelState.contentTitleResourceId(): Int = + when (this) { + NotificationTunnelState.Connected -> R.string.secured + NotificationTunnelState.Connecting -> R.string.connecting + is NotificationTunnelState.Disconnected -> { + if (this.hasVpnPermission) { + R.string.unsecured + } else { + R.string.unsecured_vpn_permission_error + } + } + NotificationTunnelState.Disconnecting -> R.string.disconnecting + NotificationTunnelState.Reconnecting -> R.string.reconnecting + NotificationTunnelState.Error.Blocking -> R.string.blocking_internet + is NotificationTunnelState.Error.Critical -> R.string.critical_error + NotificationTunnelState.Error.DeviceOffline -> R.string.blocking_internet_device_offline + NotificationTunnelState.Error.VpnPermissionDenied -> + R.string.vpn_permission_error_notification_title + NotificationTunnelState.Error.AlwaysOnVpn -> R.string.always_on_vpn_error_notification_title + } + +internal fun NotificationAction.Tunnel.toCompatAction(context: Context): NotificationCompat.Action { + + val pendingIntent = + if (this is NotificationAction.Tunnel.RequestPermission) { + val intent = + Intent().apply { + setClassName(context.packageName, MAIN_ACTIVITY_CLASS) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + setAction(KEY_REQUEST_VPN_PERMISSION) + } + + PendingIntent.getActivity(context, 1, intent, SdkUtils.getSupportedPendingIntentFlags()) + } else { + val intent = Intent(toKey()).setPackage(context.packageName) + PendingIntent.getService(context, 1, intent, SdkUtils.getSupportedPendingIntentFlags()) + } + + return NotificationCompat.Action( + toIconResource(), + context.getString(titleResource()), + pendingIntent + ) +} + +fun NotificationAction.Tunnel.titleResource() = + when (this) { + NotificationAction.Tunnel.Cancel -> R.string.cancel + NotificationAction.Tunnel.Connect, + NotificationAction.Tunnel.RequestPermission -> R.string.connect + NotificationAction.Tunnel.Disconnect -> R.string.disconnect + NotificationAction.Tunnel.Dismiss -> R.string.dismiss + } + +fun NotificationAction.Tunnel.toKey() = + when (this) { + NotificationAction.Tunnel.Connect -> KEY_CONNECT_ACTION + NotificationAction.Tunnel.RequestPermission -> KEY_REQUEST_VPN_PERMISSION + NotificationAction.Tunnel.Cancel, + NotificationAction.Tunnel.Disconnect, + NotificationAction.Tunnel.Dismiss -> KEY_DISCONNECT_ACTION + } + +fun NotificationAction.Tunnel.toIconResource() = + when (this) { + NotificationAction.Tunnel.Connect -> R.drawable.icon_notification_connect + else -> R.drawable.icon_notification_disconnect + } diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/tunnelstate/TunnelStateNotificationProvider.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/tunnelstate/TunnelStateNotificationProvider.kt new file mode 100644 index 000000000000..7c1ac942b336 --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/tunnelstate/TunnelStateNotificationProvider.kt @@ -0,0 +1,143 @@ +package net.mullvad.mullvadvpn.service.notifications.tunnelstate + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.ErrorStateCause +import net.mullvad.mullvadvpn.lib.model.Notification +import net.mullvad.mullvadvpn.lib.model.NotificationAction +import net.mullvad.mullvadvpn.lib.model.NotificationChannelId +import net.mullvad.mullvadvpn.lib.model.NotificationId +import net.mullvad.mullvadvpn.lib.model.NotificationTunnelState +import net.mullvad.mullvadvpn.lib.model.NotificationUpdate +import net.mullvad.mullvadvpn.lib.model.TunnelState +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.service.notifications.NotificationProvider + +class TunnelStateNotificationProvider( + connectionProxy: ConnectionProxy, + vpnPermissionRepository: VpnPermissionRepository, + deviceRepository: DeviceRepository, + channelId: NotificationChannelId, + scope: CoroutineScope +) : NotificationProvider { + internal val notificationId = NotificationId(2) + + override val notifications: StateFlow> = + combine( + connectionProxy.tunnelState, + connectionProxy.tunnelState.actionAfterDisconnect().distinctUntilChanged(), + deviceRepository.deviceState + ) { tunnelState: TunnelState, actionAfterDisconnect: ActionAfterDisconnect?, deviceState + -> + if (deviceState is DeviceState.LoggedOut) { + return@combine NotificationUpdate.Cancel(notificationId) + } + val notificationTunnelState = + tunnelState( + tunnelState, + actionAfterDisconnect, + vpnPermissionRepository.hasVpnPermission(), + vpnPermissionRepository.getAlwaysOnVpnAppName() + ) + + return@combine NotificationUpdate.Notify( + notificationId, + Notification.Tunnel( + channelId = channelId, + state = notificationTunnelState, + actions = listOfNotNull(notificationTunnelState.toAction()), + ongoing = notificationTunnelState is NotificationTunnelState.Connected + ) + ) + } + .stateIn(scope, SharingStarted.Eagerly, NotificationUpdate.Cancel(notificationId)) + + private fun tunnelState( + tunnelState: TunnelState, + actionAfterDisconnect: ActionAfterDisconnect?, + hasVpnPermission: Boolean, + alwaysOnVpnPermissionName: String? + ): NotificationTunnelState = + tunnelState.toNotificationTunnelState( + actionAfterDisconnect, + hasVpnPermission, + alwaysOnVpnPermissionName + ) + + private fun Flow.actionAfterDisconnect(): Flow = + filterIsInstance() + .map { it.actionAfterDisconnect } + .onStart { emit(null) } + + private fun TunnelState.toNotificationTunnelState( + actionAfterDisconnect: ActionAfterDisconnect?, + hasVpnPermission: Boolean, + alwaysOnVpnPermissionName: String? + ) = + when (this) { + is TunnelState.Disconnected -> NotificationTunnelState.Disconnected(hasVpnPermission) + is TunnelState.Connecting -> { + if (actionAfterDisconnect == ActionAfterDisconnect.Reconnect) { + NotificationTunnelState.Reconnecting + } else { + NotificationTunnelState.Connecting + } + } + is TunnelState.Disconnecting -> { + if (actionAfterDisconnect == ActionAfterDisconnect.Reconnect) { + NotificationTunnelState.Reconnecting + } else { + NotificationTunnelState.Disconnecting + } + } + is TunnelState.Connected -> NotificationTunnelState.Connected + is TunnelState.Error -> toNotificationTunnelState(alwaysOnVpnPermissionName) + } + + private fun TunnelState.Error.toNotificationTunnelState( + alwaysOnVpnPermissionName: String? + ): NotificationTunnelState.Error { + val cause = errorState.cause + return when { + cause is ErrorStateCause.IsOffline -> NotificationTunnelState.Error.DeviceOffline + cause is ErrorStateCause.InvalidDnsServers -> NotificationTunnelState.Error.Blocking + cause is ErrorStateCause.VpnPermissionDenied -> + alwaysOnVpnPermissionName?.let { NotificationTunnelState.Error.AlwaysOnVpn } + ?: NotificationTunnelState.Error.VpnPermissionDenied + errorState.isBlocking -> NotificationTunnelState.Error.Blocking + else -> NotificationTunnelState.Error.Critical + } + } + + private fun NotificationTunnelState.toAction(): NotificationAction.Tunnel = + when (this) { + is NotificationTunnelState.Disconnected -> { + if (this.hasVpnPermission) { + NotificationAction.Tunnel.Connect + } else { + NotificationAction.Tunnel.RequestPermission + } + } + NotificationTunnelState.Disconnecting -> NotificationAction.Tunnel.Connect + NotificationTunnelState.Connected, + NotificationTunnelState.Error.Blocking -> NotificationAction.Tunnel.Disconnect + NotificationTunnelState.Connecting -> NotificationAction.Tunnel.Cancel + NotificationTunnelState.Reconnecting -> NotificationAction.Tunnel.Cancel + is NotificationTunnelState.Error.Critical, + NotificationTunnelState.Error.DeviceOffline, + NotificationTunnelState.Error.VpnPermissionDenied, + NotificationTunnelState.Error.AlwaysOnVpn -> NotificationAction.Tunnel.Dismiss + } +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt deleted file mode 100644 index 055c9f8777cd..000000000000 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt +++ /dev/null @@ -1,34 +0,0 @@ -package net.mullvad.mullvadvpn.service.persistence - -import android.content.Context -import java.io.File -import kotlin.properties.Delegates.observable - -// The spelling of the shared preferences location can't be changed to American English without -// either having users lose their preferences on update or implementing some migration code. -private const val SHARED_PREFERENCES = "split_tunnelling" -private const val KEY_ENABLED = "enabled" - -class SplitTunnelingPersistence(context: Context) { - // The spelling of the app list file name can't be changed to American English without either - // having users lose their preferences on update or implementing some migration code. - private val appListFile = File(context.filesDir, "split-tunnelling.txt") - private val preferences = context.getSharedPreferences(SHARED_PREFERENCES, Context.MODE_PRIVATE) - - var enabled by - observable(preferences.getBoolean(KEY_ENABLED, false)) { _, _, isEnabled -> - preferences.edit().apply { - putBoolean(KEY_ENABLED, isEnabled) - apply() - } - } - - var excludedApps by - observable(loadExcludedApps()) { _, _, excludedAppsSet -> - appListFile.writeText(excludedAppsSet.joinToString(separator = "\n")) - } - - private fun loadExcludedApps(): Set { - return if (appListFile.exists()) appListFile.readLines().toSet() else emptySet() - } -} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 30f77c215935..b03fc2538a4b 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -4,17 +4,20 @@ include( ":tile" ) include( + ":lib:billing", ":lib:common", + ":lib:common-test", + ":lib:daemon-grpc", ":lib:endpoint", + ":lib:intent-provider", ":lib:ipc", + ":lib:map", ":lib:model", + ":lib:payment", ":lib:resource", + ":lib:shared", ":lib:talpid", - ":lib:theme", - ":lib:common-test", - ":lib:billing", - ":lib:payment", - ":lib:map" + ":lib:theme" ) include( ":test", diff --git a/android/test/arch/src/test/kotlin/net/mullvad/mullvadvpn/test/arch/ArchitectureTest.kt b/android/test/arch/src/test/kotlin/net/mullvad/mullvadvpn/test/arch/ArchitectureTest.kt index b6618713dda9..117fb4bebc9d 100644 --- a/android/test/arch/src/test/kotlin/net/mullvad/mullvadvpn/test/arch/ArchitectureTest.kt +++ b/android/test/arch/src/test/kotlin/net/mullvad/mullvadvpn/test/arch/ArchitectureTest.kt @@ -10,7 +10,7 @@ class ArchitectureTest { @Test fun `ensure model layer depends on nothing`() = Konsist.scopeFromProduction().assertArchitecture { - val model = Layer("Model", "net.mullvad.mullvadvpn.model..") + val model = Layer("Model", "net.mullvad.mullvadvpn.lib.model..") model.dependsOnNothing() } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt index 0e5371fcc38f..9457b7862e38 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt @@ -21,7 +21,7 @@ class ForgetAllVpnAppsInSettingsTestRule : BeforeTestExecutionCallback { ) val vpnSettingsButtons = device.findObjects(By.res(SETTINGS_PACKAGE, VPN_SETTINGS_BUTTON_ID)) - vpnSettingsButtons?.forEach { button -> + vpnSettingsButtons.forEach { button -> button.click() device.findObjectWithTimeout(By.text(FORGET_VPN_VPN_BUTTON_TEXT)).click() device.findObjectByCaseInsensitiveText(FORGET_VPN_VPN_CONFIRM_BUTTON_TEXT).click() diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt index b8bc5fa71658..f25ca9a8b505 100644 --- a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.test.e2e import androidx.test.uiautomator.By +import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.test.common.constant.CONNECTION_TIMEOUT import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout import net.mullvad.mullvadvpn.test.common.rule.ForgetAllVpnAppsInSettingsTestRule diff --git a/android/tile/build.gradle.kts b/android/tile/build.gradle.kts index 419ab58dc033..0b2aab45f5c1 100644 --- a/android/tile/build.gradle.kts +++ b/android/tile/build.gradle.kts @@ -26,11 +26,15 @@ android { dependencies { implementation(project(Dependencies.Mullvad.commonLib)) - implementation(project(Dependencies.Mullvad.ipcLib)) + implementation(project(Dependencies.Mullvad.daemonGrpc)) implementation(project(Dependencies.Mullvad.modelLib)) implementation(project(Dependencies.Mullvad.resourceLib)) + implementation(project(Dependencies.Mullvad.sharedLib)) implementation(project(Dependencies.Mullvad.talpidLib)) + implementation(Dependencies.Koin.core) + implementation(Dependencies.Koin.android) + implementation(Dependencies.AndroidX.appcompat) implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.KotlinX.coroutinesAndroid) diff --git a/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/MullvadTileService.kt b/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/MullvadTileService.kt index ae80bedef8cf..f796690f189b 100644 --- a/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/MullvadTileService.kt +++ b/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/MullvadTileService.kt @@ -9,32 +9,40 @@ import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import net.mullvad.mullvadvpn.lib.common.constant.KEY_CONNECT_ACTION import net.mullvad.mullvadvpn.lib.common.constant.KEY_DISCONNECT_ACTION import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS +import net.mullvad.mullvadvpn.lib.common.constant.TAG import net.mullvad.mullvadvpn.lib.common.constant.VPN_SERVICE_CLASS import net.mullvad.mullvadvpn.lib.common.util.SdkUtils import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.setSubtitleIfSupported -import net.mullvad.mullvadvpn.model.ServiceResult -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.daemon.grpc.GrpcConnectivityState +import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService +import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import org.koin.android.ext.android.get class MullvadTileService : TileService() { - private var scope: CoroutineScope? = null + private var job: Job? = null private lateinit var securedIcon: Icon private lateinit var unsecuredIcon: Icon + private val connectionProxy = get() + private val managementService = get() + override fun onCreate() { securedIcon = Icon.createWithResource(this, R.drawable.small_logo_white) unsecuredIcon = Icon.createWithResource(this, R.drawable.small_logo_black) @@ -72,11 +80,11 @@ class MullvadTileService : TileService() { } override fun onStartListening() { - scope = MainScope().apply { launchListenToTunnelState() } + job = MainScope().launch { launchListenToTunnelState() } } override fun onStopListening() { - scope?.cancel() + job?.cancel() } @SuppressLint("StartActivityAndCollapseDeprecated") @@ -84,7 +92,7 @@ class MullvadTileService : TileService() { val isSetup = VpnService.prepare(applicationContext) == null // TODO This logic should be more advanced, we should ensure user has an account setup etc. if (!isSetup) { - Log.d("MullvadTileService", "VPN service not setup, starting main activity") + Log.d(TAG, "TileService: VPN service not setup, starting main activity") val intent = Intent().apply { @@ -98,7 +106,7 @@ class MullvadTileService : TileService() { startActivityAndCollapseCompat(intent) return } else { - Log.d("MullvadTileService", "VPN service is setup") + Log.d(TAG, "TileService: VPN service is setup") } val intent = Intent().apply { @@ -132,19 +140,23 @@ class MullvadTileService : TileService() { } @OptIn(FlowPreview::class) - private fun CoroutineScope.launchListenToTunnelState() = launch { - ServiceConnection(this@MullvadTileService, this) - .tunnelState - .debounce(300L) + private suspend fun launchListenToTunnelState() { + combine( + connectionProxy.tunnelState.onStart { emit(TunnelState.Disconnected(null)) }, + managementService.connectionState + ) { tunnelState, connectionState -> + tunnelState to connectionState + } + .debounce(TUNNEL_STATE_DEBOUNCE_MS) .map { (tunnelState, connectionState) -> mapToTileState(tunnelState, connectionState) } .collect { updateTileState(it) } } private fun mapToTileState( tunnelState: TunnelState, - connectionState: ServiceResult.ConnectionState + connectionState: GrpcConnectivityState ): Int { - return if (connectionState == ServiceResult.ConnectionState.CONNECTED) { + return if (connectionState == GrpcConnectivityState.Ready) { when (tunnelState) { is TunnelState.Disconnected -> Tile.STATE_INACTIVE is TunnelState.Connecting -> Tile.STATE_ACTIVE @@ -183,4 +195,8 @@ class MullvadTileService : TileService() { updateTile() } } + + companion object { + private const val TUNNEL_STATE_DEBOUNCE_MS = 300L + } } diff --git a/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/ServiceConnection.kt b/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/ServiceConnection.kt deleted file mode 100644 index 93218c66dcdf..000000000000 --- a/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/ServiceConnection.kt +++ /dev/null @@ -1,132 +0,0 @@ -package net.mullvad.mullvadvpn.tile - -import android.content.Context -import android.content.Intent -import android.os.IBinder -import android.os.Looper -import android.os.Messenger -import kotlin.reflect.KClass -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.lib.common.constant.VPN_SERVICE_CLASS -import net.mullvad.mullvadvpn.lib.common.util.DispatchingFlow -import net.mullvad.mullvadvpn.lib.common.util.bindServiceFlow -import net.mullvad.mullvadvpn.lib.common.util.dispatchTo -import net.mullvad.mullvadvpn.lib.ipc.Event -import net.mullvad.mullvadvpn.lib.ipc.HandlerFlow -import net.mullvad.mullvadvpn.lib.ipc.Request -import net.mullvad.mullvadvpn.model.ServiceResult -import net.mullvad.mullvadvpn.model.TunnelState - -@FlowPreview -class ServiceConnection(context: Context, scope: CoroutineScope) { - private val activeListeners = MutableStateFlow?>(null) - private val handler = HandlerFlow(Looper.getMainLooper(), Event.Companion::fromMessage) - private val listener = Messenger(handler) - private val listenerId = MutableStateFlow(null) - - private lateinit var listenerRegistrations: StateFlow?> - - lateinit var tunnelState: Flow> - private set - - private val serviceConnectionStateChannel = - Channel(Channel.RENDEZVOUS) - - init { - val dispatcher = - handler.filterNotNull().dispatchTo { - listenerRegistrations = - subscribeToState(Event.ListenerReady::class, scope) { - Pair(connection, listenerId) - } - - val tunnelStateEvents = - subscribeToState( - Event.TunnelStateChange::class, - scope, - TunnelState.Disconnected() - ) { - tunnelState - } - - tunnelState = - tunnelStateEvents.combine(serviceConnectionStateChannel.consumeAsFlow()) { - tunnelState, - serviceConnectionState -> - tunnelState to serviceConnectionState - } - } - - scope.launch { connect(context) } - scope.launch { dispatcher.collect() } - scope.launch { unregisterOldListeners() } - scope.launch { listenerRegistrations.collect { activeListeners.value = it } } - } - - private suspend fun connect(context: Context) { - val intent = Intent().apply { setClassName(context.packageName, VPN_SERVICE_CLASS) } - - context - .bindServiceFlow(intent) - .onStart { emit(ServiceResult.NOT_CONNECTED) } - .onEach { result -> serviceConnectionStateChannel.send(result.connectionState) } - .collect { result -> - activeListeners.value = null - result.binder?.let(::registerListener) - } - } - - private fun registerListener(binder: IBinder) { - val request = Request.RegisterListener(listener) - val messenger = Messenger(binder) - - messenger.send(request.message) - } - - private suspend fun unregisterOldListeners() { - var oldListener: Pair? = null - - activeListeners - .onCompletion { oldListener?.let(::unregisterListener) } - .collect { newListener -> - oldListener?.let(::unregisterListener) - oldListener = newListener - } - } - - private fun unregisterListener(registration: Pair) { - val (messenger, listenerId) = registration - val request = Request.UnregisterListener(listenerId) - - messenger.send(request.message) - } - - private fun DispatchingFlow.subscribeToState( - event: KClass, - scope: CoroutineScope, - dataExtractor: suspend V.() -> D - ) = subscribe(event).map(dataExtractor).stateIn(scope, SharingStarted.Lazily, null) - - private fun DispatchingFlow.subscribeToState( - event: KClass, - scope: CoroutineScope, - initialValue: D, - dataExtractor: suspend V.() -> D - ) = subscribe(event).map(dataExtractor).stateIn(scope, SharingStarted.Lazily, initialValue) -} diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 65267fd5bd40..a2685f87077d 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -2434,6 +2434,9 @@ msgstr "" msgid "Unsecured" msgstr "" +msgid "Unsecured (No VPN permission)" +msgstr "" + msgid "Update DNS server" msgstr "" diff --git a/mullvad-daemon/Cargo.toml b/mullvad-daemon/Cargo.toml index 86767ff18d3e..ca7c46d8ca31 100644 --- a/mullvad-daemon/Cargo.toml +++ b/mullvad-daemon/Cargo.toml @@ -32,6 +32,7 @@ mullvad-relay-selector = { path = "../mullvad-relay-selector" } mullvad-types = { path = "../mullvad-types" } mullvad-api = { path = "../mullvad-api" } mullvad-fs = { path = "../mullvad-fs" } +mullvad-paths = { path = "../mullvad-paths" } mullvad-version = { path = "../mullvad-version" } talpid-core = { path = "../talpid-core" } talpid-future = { path = "../talpid-future" } @@ -39,11 +40,9 @@ talpid-platform-metadata = { path = "../talpid-platform-metadata" } talpid-time = { path = "../talpid-time" } talpid-types = { path = "../talpid-types" } -[target.'cfg(not(target_os="android"))'.dependencies] clap = { workspace = true } log-panics = "2.0.0" mullvad-management-interface = { path = "../mullvad-management-interface" } -mullvad-paths = { path = "../mullvad-paths" } [dev-dependencies] talpid-time = { path = "../talpid-time", features = ["test"] } diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs index c586c4c316ef..cabd6d205e89 100644 --- a/mullvad-daemon/src/lib.rs +++ b/mullvad-daemon/src/lib.rs @@ -15,7 +15,6 @@ mod geoip; pub mod logging; #[cfg(target_os = "macos")] mod macos; -#[cfg(not(target_os = "android"))] pub mod management_interface; mod migrations; mod relay_list; @@ -43,6 +42,8 @@ use mullvad_relay_selector::{ }; #[cfg(target_os = "android")] use mullvad_types::account::{PlayPurchase, PlayPurchasePaymentToken}; +#[cfg(any(windows, target_os = "android", target_os = "macos"))] +use mullvad_types::settings::SplitApp; #[cfg(target_os = "windows")] use mullvad_types::wireguard::DaitaSettings; use mullvad_types::{ @@ -63,10 +64,10 @@ use mullvad_types::{ }; use relay_list::{RelayListUpdater, RelayListUpdaterHandle, RELAYS_FILENAME}; use settings::SettingsPersister; +#[cfg(any(windows, target_os = "android", target_os = "macos"))] +use std::collections::HashSet; #[cfg(target_os = "android")] use std::os::unix::io::RawFd; -#[cfg(any(target_os = "windows", target_os = "macos"))] -use std::{collections::HashSet, ffi::OsString}; use std::{ marker::PhantomData, mem, @@ -75,7 +76,6 @@ use std::{ sync::{Arc, Weak}, time::Duration, }; -#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] use talpid_core::split_tunnel; use talpid_core::{ mpsc::Sender, @@ -147,7 +147,7 @@ pub enum Error { #[error("Unable to initialize split tunneling")] InitSplitTunneling(#[source] split_tunnel::Error), - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os = "macos"))] #[error("Split tunneling error")] SplitTunnelError(#[source] split_tunnel::Error), @@ -331,16 +331,16 @@ pub enum DaemonCommand { #[cfg(target_os = "linux")] ClearSplitTunnelProcesses(ResponseTx<(), split_tunnel::Error>), /// Exclude traffic of an application from the tunnel - #[cfg(any(target_os = "windows", target_os = "macos"))] - AddSplitTunnelApp(ResponseTx<(), Error>, PathBuf), + #[cfg(any(windows, target_os = "android", target_os = "macos"))] + AddSplitTunnelApp(ResponseTx<(), Error>, SplitApp), /// Remove application from list of apps to exclude from the tunnel - #[cfg(any(target_os = "windows", target_os = "macos"))] - RemoveSplitTunnelApp(ResponseTx<(), Error>, PathBuf), + #[cfg(any(windows, target_os = "android", target_os = "macos"))] + RemoveSplitTunnelApp(ResponseTx<(), Error>, SplitApp), /// Clear list of apps to exclude from the tunnel - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os = "macos"))] ClearSplitTunnelApps(ResponseTx<(), Error>), /// Enable or disable split tunneling - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os = "macos"))] SetSplitTunnelState(ResponseTx<(), Error>, bool), /// Returns all processes currently being excluded from the tunnel #[cfg(windows)] @@ -392,14 +392,14 @@ pub(crate) enum InternalDaemonEvent { /// A geographical location has has been received from am.i.mullvad.net LocationEvent(LocationEventData), /// The split tunnel paths or state were updated. - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] ExcludedPathsEvent(ExcludedPathsUpdate, oneshot::Sender>), } -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(any(windows, target_os = "android", target_os="macos"))] pub(crate) enum ExcludedPathsUpdate { SetState(bool), - SetPaths(HashSet), + SetPaths(HashSet), } impl From for InternalDaemonEvent { @@ -767,13 +767,14 @@ where PersistentTargetState::new(&cache_dir).await }; - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] let exclude_paths = if settings.split_tunnel.enable_exclusions { settings .split_tunnel .apps .iter() - .map(OsString::from) + .cloned() + .map(SplitApp::to_tunnel_command_repr) .collect() } else { vec![] @@ -810,7 +811,7 @@ where .map_err(Error::ApiConnectionModeError)? .endpoint, reset_firewall: *target_state != TargetState::Secured, - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] exclude_paths, }, parameters_generator.clone(), @@ -994,7 +995,7 @@ where } => self.handle_access_method_event(event, endpoint_active_tx), DeviceMigrationEvent(event) => self.handle_device_migration_event(event), LocationEvent(location_data) => self.handle_location_event(location_data), - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] ExcludedPathsEvent(update, tx) => self.handle_new_excluded_paths(update, tx).await, } } @@ -1273,13 +1274,13 @@ where RemoveSplitTunnelProcess(tx, pid) => self.on_remove_split_tunnel_process(tx, pid), #[cfg(target_os = "linux")] ClearSplitTunnelProcesses(tx) => self.on_clear_split_tunnel_processes(tx), - #[cfg(any(target_os = "windows", target_os = "macos"))] - AddSplitTunnelApp(tx, path) => self.on_add_split_tunnel_app(tx, path), - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] + AddSplitTunnelApp(tx, app) => self.on_add_split_tunnel_app(tx, app), + #[cfg(any(windows, target_os = "android", target_os="macos"))] RemoveSplitTunnelApp(tx, path) => self.on_remove_split_tunnel_app(tx, path), - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] ClearSplitTunnelApps(tx) => self.on_clear_split_tunnel_apps(tx), - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] SetSplitTunnelState(tx, enabled) => self.on_set_split_tunnel_state(tx, enabled), #[cfg(windows)] GetSplitTunnelProcesses(tx) => self.on_get_split_tunnel_processes(tx), @@ -1435,7 +1436,7 @@ where }); } - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] async fn handle_new_excluded_paths( &mut self, update: ExcludedPathsUpdate, @@ -1808,7 +1809,7 @@ where } /// Update the split app paths in both the settings and tunnel - #[cfg(target_os = "windows")] + #[cfg(any(windows, target_os = "android"))] fn set_split_tunnel_paths( &mut self, tx: ResponseTx<(), Error>, @@ -1837,9 +1838,13 @@ where } }; + // Update the tunnel state if new_state || new_state != settings.split_tunnel.enable_exclusions { let tunnel_list = if new_state { - new_list.map(OsString::from).collect() + new_list + .cloned() + .map(SplitApp::to_tunnel_command_repr) + .collect() } else { vec![] }; @@ -1885,13 +1890,14 @@ where ) { let tunnel_list = match update { ExcludedPathsUpdate::SetPaths(ref paths) if settings.split_tunnel.enable_exclusions => { - paths.iter().map(OsString::from).collect() + paths.iter().cloned().map(SplitApp::to_tunnel_command_repr).collect() } ExcludedPathsUpdate::SetState(true) => settings .split_tunnel .apps .iter() - .map(OsString::from) + .cloned() + .map(SplitApp::to_tunnel_command_repr) .collect(), _ => vec![], }; @@ -1919,37 +1925,43 @@ where }); } - #[cfg(any(target_os = "windows", target_os = "macos"))] - fn on_add_split_tunnel_app(&mut self, tx: ResponseTx<(), Error>, path: PathBuf) { + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "android"))] + fn on_add_split_tunnel_app(&mut self, tx: ResponseTx<(), Error>, app: SplitApp) { let settings = self.settings.to_settings(); - let mut new_list = settings.split_tunnel.apps.clone(); - new_list.insert(path); + let excluded_apps = { + let mut apps = settings.split_tunnel.apps.clone(); + apps.insert(app); + apps + }; self.set_split_tunnel_paths( tx, "add_split_tunnel_app response", settings, - ExcludedPathsUpdate::SetPaths(new_list), + ExcludedPathsUpdate::SetPaths(excluded_apps), ); } - #[cfg(any(target_os = "windows", target_os = "macos"))] - fn on_remove_split_tunnel_app(&mut self, tx: ResponseTx<(), Error>, path: PathBuf) { + #[cfg(any(windows, target_os = "android", target_os = "macos"))] + fn on_remove_split_tunnel_app(&mut self, tx: ResponseTx<(), Error>, app: impl Into) { let settings = self.settings.to_settings(); - let mut new_list = settings.split_tunnel.apps.clone(); - new_list.remove(&path); + let excluded_apps = { + let mut apps = settings.split_tunnel.apps.clone(); + apps.remove(&app.into()); + apps + }; self.set_split_tunnel_paths( tx, "remove_split_tunnel_app response", settings, - ExcludedPathsUpdate::SetPaths(new_list), + ExcludedPathsUpdate::SetPaths(excluded_apps), ); } - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os = "macos"))] fn on_clear_split_tunnel_apps(&mut self, tx: ResponseTx<(), Error>) { let settings = self.settings.to_settings(); let new_list = HashSet::new(); @@ -1961,7 +1973,7 @@ where ); } - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] fn on_set_split_tunnel_state(&mut self, tx: ResponseTx<(), Error>, state: bool) { let settings = self.settings.to_settings(); self.set_split_tunnel_paths( @@ -2875,3 +2887,13 @@ fn oneshot_map( }); new_tx } + +/// Remove any old RPC socket (if it exists). +#[cfg(not(windows))] +pub async fn cleanup_old_rpc_socket() { + if let Err(err) = tokio::fs::remove_file(mullvad_paths::get_rpc_socket_path()).await { + if err.kind() != std::io::ErrorKind::NotFound { + log::error!("Failed to remove old RPC socket: {}", err); + } + } +} diff --git a/mullvad-daemon/src/main.rs b/mullvad-daemon/src/main.rs index 4de3beb26a6a..510127fd05fa 100644 --- a/mullvad-daemon/src/main.rs +++ b/mullvad-daemon/src/main.rs @@ -1,3 +1,5 @@ +#[cfg(not(windows))] +use mullvad_daemon::cleanup_old_rpc_socket; use mullvad_daemon::{ logging, management_interface::{ManagementInterfaceEventBroadcaster, ManagementInterfaceServer}, @@ -160,12 +162,8 @@ fn get_log_dir(config: &cli::Config) -> Result, String> { } async fn run_standalone(log_dir: Option) -> Result<(), String> { - #[cfg(any(target_os = "macos", target_os = "linux"))] - if let Err(err) = tokio::fs::remove_file(mullvad_paths::get_rpc_socket_path()).await { - if err.kind() != std::io::ErrorKind::NotFound { - log::error!("Failed to remove old RPC socket: {}", err); - } - } + #[cfg(not(windows))] + cleanup_old_rpc_socket().await; if !running_as_admin() { log::warn!("Running daemon as a non-administrator user, clients might refuse to connect"); diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs index c94b5f35063d..6d9e776e126f 100644 --- a/mullvad-daemon/src/management_interface.rs +++ b/mullvad-daemon/src/management_interface.rs @@ -8,7 +8,6 @@ use mullvad_management_interface::{ types::{self, daemon_event, management_service_server::ManagementService}, Code, Request, Response, Status, }; -#[cfg(not(target_os = "android"))] use mullvad_types::settings::DnsOptions; use mullvad_types::{ account::AccountToken, @@ -21,8 +20,6 @@ use mullvad_types::{ version, wireguard::{RotationInterval, RotationIntervalError}, }; -#[cfg(any(target_os = "windows", target_os = "macos"))] -use std::path::PathBuf; use std::{ str::FromStr, sync::{Arc, Mutex}, @@ -344,7 +341,6 @@ impl ManagementService for ManagementServiceImpl { Ok(Response::new(())) } - #[cfg(not(target_os = "android"))] async fn set_dns_options(&self, request: Request) -> ServiceResult<()> { let options = DnsOptions::try_from(request.into_inner()).map_err(map_protobuf_type_err)?; log::debug!("set_dns_options({:?})", options); @@ -355,11 +351,6 @@ impl ManagementService for ManagementServiceImpl { Ok(Response::new(())) } - #[cfg(target_os = "android")] - async fn set_dns_options(&self, _: Request) -> ServiceResult<()> { - Ok(Response::new(())) - } - async fn set_relay_override( &self, request: Request, @@ -831,10 +822,11 @@ impl ManagementService for ManagementServiceImpl { } } - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os = "macos"))] async fn add_split_tunnel_app(&self, request: Request) -> ServiceResult<()> { + use mullvad_types::settings::SplitApp; log::debug!("add_split_tunnel_app"); - let path = PathBuf::from(request.into_inner()); + let path = SplitApp::from(request.into_inner()); let (tx, rx) = oneshot::channel(); self.send_command_to_daemon(DaemonCommand::AddSplitTunnelApp(tx, path))?; self.wait_for_result(rx) @@ -842,15 +834,17 @@ impl ManagementService for ManagementServiceImpl { .map_err(map_daemon_error) .map(Response::new) } - #[cfg(not(any(target_os = "windows", target_os = "macos")))] + + #[cfg(target_os = "linux")] async fn add_split_tunnel_app(&self, _: Request) -> ServiceResult<()> { Ok(Response::new(())) } - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os="macos"))] async fn remove_split_tunnel_app(&self, request: Request) -> ServiceResult<()> { + use mullvad_types::settings::SplitApp; log::debug!("remove_split_tunnel_app"); - let path = PathBuf::from(request.into_inner()); + let path = SplitApp::from(request.into_inner()); let (tx, rx) = oneshot::channel(); self.send_command_to_daemon(DaemonCommand::RemoveSplitTunnelApp(tx, path))?; self.wait_for_result(rx) @@ -858,12 +852,12 @@ impl ManagementService for ManagementServiceImpl { .map_err(map_daemon_error) .map(Response::new) } - #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[cfg(target_os = "linux")] async fn remove_split_tunnel_app(&self, _: Request) -> ServiceResult<()> { Ok(Response::new(())) } - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os = "macos"))] async fn clear_split_tunnel_apps(&self, _: Request<()>) -> ServiceResult<()> { log::debug!("clear_split_tunnel_apps"); let (tx, rx) = oneshot::channel(); @@ -873,12 +867,12 @@ impl ManagementService for ManagementServiceImpl { .map_err(map_daemon_error) .map(Response::new) } - #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[cfg(target_os = "linux")] async fn clear_split_tunnel_apps(&self, _: Request<()>) -> ServiceResult<()> { Ok(Response::new(())) } - #[cfg(any(target_os = "windows", target_os = "macos"))] + #[cfg(any(windows, target_os = "android", target_os = "macos"))] async fn set_split_tunnel_state(&self, request: Request) -> ServiceResult<()> { log::debug!("set_split_tunnel_state"); let enabled = request.into_inner(); @@ -889,7 +883,7 @@ impl ManagementService for ManagementServiceImpl { .map_err(map_daemon_error) .map(Response::new) } - #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[cfg(target_os = "linux")] async fn set_split_tunnel_state(&self, _: Request) -> ServiceResult<()> { Ok(Response::new(())) } @@ -956,6 +950,60 @@ impl ManagementService for ManagementServiceImpl { let blob = self.wait_for_result(rx).await??; Ok(Response::new(blob)) } + + #[cfg(target_os = "android")] + async fn init_play_purchase( + &self, + _request: Request<()>, + ) -> ServiceResult { + log::debug!("init_play_purchase"); + + let (tx, rx) = oneshot::channel(); + self.send_command_to_daemon(DaemonCommand::InitPlayPurchase(tx))?; + + let payment_token = self + .wait_for_result(rx) + .await? + .map(types::PlayPurchasePaymentToken::from) + .map_err(map_daemon_error)?; + + Ok(Response::new(payment_token)) + } + + /// On non-Android platforms, the return value will be useless. + #[cfg(not(target_os = "android"))] + async fn init_play_purchase( + &self, + _: Request<()>, + ) -> ServiceResult { + log::error!("Called `init_play_purchase` on non-Android platform"); + Ok(Response::new(types::PlayPurchasePaymentToken { + token: String::default(), + })) + } + + #[cfg(target_os = "android")] + async fn verify_play_purchase( + &self, + request: Request, + ) -> ServiceResult<()> { + log::debug!("verify_play_purchase"); + + let (tx, rx) = oneshot::channel(); + let play_purchase = mullvad_types::account::PlayPurchase::try_from(request.into_inner())?; + + self.send_command_to_daemon(DaemonCommand::VerifyPlayPurchase(tx, play_purchase))?; + + self.wait_for_result(rx).await?.map_err(map_daemon_error)?; + + Ok(Response::new(())) + } + + #[cfg(not(target_os = "android"))] + async fn verify_play_purchase(&self, _: Request) -> ServiceResult<()> { + log::error!("Called `verify_play_purchase` on non-Android platform"); + Ok(Response::new(())) + } } impl ManagementServiceImpl { diff --git a/mullvad-daemon/src/migrations/mod.rs b/mullvad-daemon/src/migrations/mod.rs index 9455b97597d6..635c2f6ebe14 100644 --- a/mullvad-daemon/src/migrations/mod.rs +++ b/mullvad-daemon/src/migrations/mod.rs @@ -53,6 +53,7 @@ mod v5; mod v6; mod v7; mod v8; +mod v9; const SETTINGS_FILE: &str = "settings.json"; @@ -116,6 +117,12 @@ impl MigrationComplete { /// Contains discarded data that may be useful for later work. pub type MigrationData = v5::MigrationData; +/// Directories that may be passed to the migration logic. +pub struct Directories<'path> { + cache_dir: &'path Path, + settings_dir: &'path Path, +} + pub async fn migrate_all(cache_dir: &Path, settings_dir: &Path) -> Result> { #[cfg(windows)] windows::migrate_after_windows_update(settings_dir) @@ -134,8 +141,12 @@ pub async fn migrate_all(cache_dir: &Path, settings_dir: &Path) -> Result