summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/Actions.kt15
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt51
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialogTest.kt144
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialogTest.kt76
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialogTest.kt144
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt246
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreenTest.kt105
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreenTest.kt170
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreenTest.kt226
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt31
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt61
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt54
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt436
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt50
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListAction.kt33
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListResult.kt34
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt72
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt60
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt69
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt28
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt132
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt96
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt38
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt107
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt219
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt193
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt222
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt681
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListsUiState.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListState.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/UpdateCustomListUiState.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt78
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemExtensions.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListExtensions.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepository.kt68
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt117
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsException.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt85
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt216
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModel.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt33
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt76
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModel.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt54
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepositoryTest.kt268
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt220
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt114
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt294
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModelTest.kt54
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt43
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt90
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModelTest.kt66
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModelTest.kt94
-rw-r--r--android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt6
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CreateCustomListResult.kt10
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsError.kt6
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/UpdateCustomListResult.kt10
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_add.xml9
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_delete.xml9
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_edit.xml9
-rw-r--r--android/lib/resource/src/main/res/drawable/icon_more_vert.xml9
-rw-r--r--android/lib/resource/src/main/res/values-da/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-de/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-es/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-fi/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-fr/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-it/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-ja/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-ko/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-my/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-nb/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-nl/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-pl/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-pt/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-ru/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-sv/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-th/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-tr/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-zh-rCN/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values-zh-rTW/strings.xml6
-rw-r--r--android/lib/resource/src/main/res/values/plurals.xml4
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml35
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt12
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt1
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt7
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt6
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt6
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt20
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomLists.kt10
120 files changed, 5963 insertions, 513 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/Actions.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/Actions.kt
new file mode 100644
index 0000000000..ebd570252a
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/Actions.kt
@@ -0,0 +1,15 @@
+package net.mullvad.mullvadvpn
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.invokeGlobalAssertions
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.performTouchInput
+
+fun SemanticsNodeInteraction.performLongClick(): SemanticsNodeInteraction {
+ @OptIn(ExperimentalTestApi::class) return this.invokeGlobalAssertions().performLongClickImpl()
+}
+
+private fun SemanticsNodeInteraction.performLongClickImpl(): SemanticsNodeInteraction {
+ return performTouchInput { longClick() }
+}
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
new file mode 100644
index 0000000000..5a20438c23
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/data/DummyRelayItems.kt
@@ -0,0 +1,51 @@
+package net.mullvad.mullvadvpn.compose.data
+
+import net.mullvad.mullvadvpn.model.Constraint
+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
+
+private val DUMMY_RELAY_1 =
+ net.mullvad.mullvadvpn.model.Relay(
+ hostname = "Relay host 1",
+ active = true,
+ endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData),
+ owned = true,
+ provider = "PROVIDER"
+ )
+private val DUMMY_RELAY_2 =
+ net.mullvad.mullvadvpn.model.Relay(
+ hostname = "Relay host 2",
+ active = true,
+ endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData),
+ owned = true,
+ provider = "PROVIDER"
+ )
+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))
+private val DUMMY_RELAY_COUNTRY_2 =
+ RelayListCountry("Relay Country 2", "RCo2", arrayListOf(DUMMY_RELAY_CITY_2))
+
+private val DUMMY_WIREGUARD_PORT_RANGES = ArrayList<PortRange>()
+private val DUMMY_WIREGUARD_ENDPOINT_DATA = WireguardEndpointData(DUMMY_WIREGUARD_PORT_RANGES)
+
+val DUMMY_RELAY_COUNTRIES =
+ RelayList(
+ arrayListOf(DUMMY_RELAY_COUNTRY_1, DUMMY_RELAY_COUNTRY_2),
+ DUMMY_WIREGUARD_ENDPOINT_DATA,
+ )
+ .toRelayCountries(ownership = Constraint.Any(), providers = Constraint.Any())
+
+val DUMMY_CUSTOM_LISTS =
+ listOf(
+ RelayItem.CustomList("First list", false, "1", locations = DUMMY_RELAY_COUNTRIES),
+ RelayItem.CustomList("Empty list", expanded = false, "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
new file mode 100644
index 0000000000..baeb5902d7
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialogTest.kt
@@ -0,0 +1,144 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+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.compose.state.CreateCustomListUiState
+import net.mullvad.mullvadvpn.compose.test.CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG
+import net.mullvad.mullvadvpn.model.CustomListsError
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+class CreateCustomListDialogTest {
+ @OptIn(ExperimentalTestApi::class)
+ @JvmField
+ @RegisterExtension
+ val composeExtension = createEdgeToEdgeComposeExtension()
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun givenNoErrorShouldShowNoErrorMessage() =
+ composeExtension.use {
+ // Arrange
+ val state = CreateCustomListUiState(error = null)
+ setContentWithTheme { CreateCustomListDialog(state = state) }
+
+ // Assert
+ onNodeWithText(NAME_EXIST_ERROR_TEXT).assertDoesNotExist()
+ onNodeWithText(OTHER_ERROR_TEXT).assertDoesNotExist()
+ }
+
+ @Test
+ fun givenCustomListExistsShouldShowCustomListExitsErrorText() =
+ composeExtension.use {
+ // Arrange
+ val state = CreateCustomListUiState(error = CustomListsError.CustomListExists)
+ setContentWithTheme { CreateCustomListDialog(state = state) }
+
+ // Assert
+ onNodeWithText(NAME_EXIST_ERROR_TEXT).assertExists()
+ onNodeWithText(OTHER_ERROR_TEXT).assertDoesNotExist()
+ }
+
+ @Test
+ fun givenOtherCustomListErrorShouldShowAnErrorOccurredErrorText() =
+ composeExtension.use {
+ // Arrange
+ val state = CreateCustomListUiState(error = CustomListsError.OtherError)
+ setContentWithTheme { CreateCustomListDialog(state = state) }
+
+ // Assert
+ onNodeWithText(NAME_EXIST_ERROR_TEXT).assertDoesNotExist()
+ onNodeWithText(OTHER_ERROR_TEXT).assertExists()
+ }
+
+ @Test
+ fun whenCancelIsClickedShouldDismissDialog() =
+ composeExtension.use {
+ // Arrange
+ val mockedOnDismiss: () -> Unit = mockk(relaxed = true)
+ val state = CreateCustomListUiState()
+ setContentWithTheme {
+ CreateCustomListDialog(state = state, onDismiss = mockedOnDismiss)
+ }
+
+ // Act
+ onNodeWithText(CANCEL_BUTTON_TEXT).performClick()
+
+ // Assert
+ verify { mockedOnDismiss.invoke() }
+ }
+
+ @Test
+ fun givenEmptyTextInputWhenSubmitIsClickedThenShouldNotCallOnCreate() =
+ composeExtension.use {
+ // Arrange
+ val mockedCreateCustomList: (String) -> Unit = mockk(relaxed = true)
+ val state = CreateCustomListUiState()
+ setContentWithTheme {
+ CreateCustomListDialog(state = state, createCustomList = mockedCreateCustomList)
+ }
+
+ // Act
+ onNodeWithText(CREATE_BUTTON_TEXT).performClick()
+
+ // Assert
+ verify(exactly = 0) { mockedCreateCustomList.invoke(any()) }
+ }
+
+ @Test
+ fun givenValidTextInputWhenSubmitIsClickedThenShouldCallOnCreate() =
+ composeExtension.use {
+ // Arrange
+ val mockedCreateCustomList: (String) -> Unit = mockk(relaxed = true)
+ val inputText = "NEW LIST"
+ val state = CreateCustomListUiState()
+ setContentWithTheme {
+ CreateCustomListDialog(state = state, createCustomList = mockedCreateCustomList)
+ }
+
+ // Act
+ onNodeWithTag(CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG).performTextInput(inputText)
+ onNodeWithText(CREATE_BUTTON_TEXT).performClick()
+
+ // Assert
+ verify { mockedCreateCustomList.invoke(inputText) }
+ }
+
+ @Test
+ fun whenInputIsChangedShouldCallOnInputChanged() =
+ composeExtension.use {
+ // Arrange
+ val mockedOnInputChanged: () -> Unit = mockk(relaxed = true)
+ val inputText = "NEW LIST"
+ val state = CreateCustomListUiState()
+ setContentWithTheme {
+ CreateCustomListDialog(state = state, onInputChanged = mockedOnInputChanged)
+ }
+
+ // Act
+ onNodeWithTag(CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG).performTextInput(inputText)
+
+ // Assert
+ verify { mockedOnInputChanged.invoke() }
+ }
+
+ companion object {
+ private const val NAME_EXIST_ERROR_TEXT = "Name is already taken."
+ private const val OTHER_ERROR_TEXT = "An error occurred."
+ private const val CANCEL_BUTTON_TEXT = "Cancel"
+ private const val CREATE_BUTTON_TEXT = "Create"
+ }
+}
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
new file mode 100644
index 0000000000..e79c5a2fe7
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialogTest.kt
@@ -0,0 +1,76 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import io.mockk.MockKAnnotations
+import io.mockk.mockk
+import io.mockk.verify
+import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension
+import net.mullvad.mullvadvpn.compose.setContentWithTheme
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+class DeleteCustomListConfirmationDialogTest {
+ @OptIn(ExperimentalTestApi::class)
+ @JvmField
+ @RegisterExtension
+ val composeExtension = createEdgeToEdgeComposeExtension()
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun givenNameShouldShowDeleteNameTitle() =
+ composeExtension.use {
+ // Arrange
+ val name = "List should be deleted"
+ setContentWithTheme { DeleteCustomListConfirmationDialog(name = name) }
+
+ // Assert
+ onNodeWithText(DELETE_TITLE.format(name)).assertExists()
+ }
+
+ @Test
+ fun whenDeleteIsClickedShouldCallOnDelete() =
+ composeExtension.use {
+ // Arrange
+ val name = "List should be deleted"
+ val mockedOnDelete: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ DeleteCustomListConfirmationDialog(name = name, onDelete = mockedOnDelete)
+ }
+
+ // Act
+ onNodeWithText(DELETE_BUTTON_TEXT).performClick()
+
+ // Assert
+ verify { mockedOnDelete.invoke() }
+ }
+
+ @Test
+ fun whenCancelIsClickedShouldCallOnBack() =
+ composeExtension.use {
+ // Arrange
+ val name = "List should be deleted"
+ val mockedOnBack: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ DeleteCustomListConfirmationDialog(name = name, onBack = mockedOnBack)
+ }
+
+ // Act
+ onNodeWithText(CANCEL_BUTTON_TEXT).performClick()
+
+ // Assert
+ verify { mockedOnBack.invoke() }
+ }
+
+ companion object {
+ private const val DELETE_TITLE = "Delete \"%s\"?"
+ private const val CANCEL_BUTTON_TEXT = "Cancel"
+ private const val DELETE_BUTTON_TEXT = "Delete"
+ }
+}
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
new file mode 100644
index 0000000000..cbd6ae09d7
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialogTest.kt
@@ -0,0 +1,144 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+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.compose.state.UpdateCustomListUiState
+import net.mullvad.mullvadvpn.compose.test.EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG
+import net.mullvad.mullvadvpn.model.CustomListsError
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+class EditCustomListNameDialogTest {
+ @OptIn(ExperimentalTestApi::class)
+ @JvmField
+ @RegisterExtension
+ val composeExtension = createEdgeToEdgeComposeExtension()
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun givenNoErrorShouldShowNoErrorMessage() =
+ composeExtension.use {
+ // Arrange
+ val state = UpdateCustomListUiState(error = null)
+ setContentWithTheme { EditCustomListNameDialog(state = state) }
+
+ // Assert
+ onNodeWithText(NAME_EXIST_ERROR_TEXT).assertDoesNotExist()
+ onNodeWithText(OTHER_ERROR_TEXT).assertDoesNotExist()
+ }
+
+ @Test
+ fun givenCustomListExistsShouldShowCustomListExitsErrorText() =
+ composeExtension.use {
+ // Arrange
+ val state = UpdateCustomListUiState(error = CustomListsError.CustomListExists)
+ setContentWithTheme { EditCustomListNameDialog(state = state) }
+
+ // Assert
+ onNodeWithText(NAME_EXIST_ERROR_TEXT).assertExists()
+ onNodeWithText(OTHER_ERROR_TEXT).assertDoesNotExist()
+ }
+
+ @Test
+ fun givenOtherCustomListErrorShouldShowAnErrorOccurredErrorText() =
+ composeExtension.use {
+ // Arrange
+ val state = UpdateCustomListUiState(error = CustomListsError.OtherError)
+ setContentWithTheme { EditCustomListNameDialog(state = state) }
+
+ // Assert
+ onNodeWithText(NAME_EXIST_ERROR_TEXT).assertDoesNotExist()
+ onNodeWithText(OTHER_ERROR_TEXT).assertExists()
+ }
+
+ @Test
+ fun whenCancelIsClickedShouldDismissDialog() =
+ composeExtension.use {
+ // Arrange
+ val mockedOnDismiss: () -> Unit = mockk(relaxed = true)
+ val state = UpdateCustomListUiState()
+ setContentWithTheme {
+ EditCustomListNameDialog(state = state, onDismiss = mockedOnDismiss)
+ }
+
+ // Act
+ onNodeWithText(CANCEL_BUTTON_TEXT).performClick()
+
+ // Assert
+ verify { mockedOnDismiss.invoke() }
+ }
+
+ @Test
+ fun givenEmptyTextInputWhenSaveIsClickedThenShouldNotCallUpdateName() =
+ composeExtension.use {
+ // Arrange
+ val mockedUpdateName: (String) -> Unit = mockk(relaxed = true)
+ val state = UpdateCustomListUiState()
+ setContentWithTheme {
+ EditCustomListNameDialog(state = state, updateName = mockedUpdateName)
+ }
+
+ // Act
+ onNodeWithText(SAVE_BUTTON_TEXT).performClick()
+
+ // Assert
+ verify(exactly = 0) { mockedUpdateName.invoke(any()) }
+ }
+
+ @Test
+ fun givenValidTextInputWhenSaveIsClickedThenShouldCallUpdateName() =
+ composeExtension.use {
+ // Arrange
+ val mockedUpdateName: (String) -> Unit = mockk(relaxed = true)
+ val inputText = "NEW NAME"
+ val state = UpdateCustomListUiState()
+ setContentWithTheme {
+ EditCustomListNameDialog(state = state, updateName = mockedUpdateName)
+ }
+
+ // Act
+ onNodeWithTag(EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG).performTextInput(inputText)
+ onNodeWithText(SAVE_BUTTON_TEXT).performClick()
+
+ // Assert
+ verify { mockedUpdateName.invoke(inputText) }
+ }
+
+ @Test
+ fun whenInputIsChangedShouldCallOnInputChanged() =
+ composeExtension.use {
+ // Arrange
+ val mockedOnInputChanged: () -> Unit = mockk(relaxed = true)
+ val inputText = "NEW NAME"
+ val state = UpdateCustomListUiState()
+ setContentWithTheme {
+ EditCustomListNameDialog(state = state, onInputChanged = mockedOnInputChanged)
+ }
+
+ // Act
+ onNodeWithTag(EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG).performTextInput(inputText)
+
+ // Assert
+ verify { mockedOnInputChanged.invoke() }
+ }
+
+ companion object {
+ private const val NAME_EXIST_ERROR_TEXT = "Name is already taken."
+ private const val OTHER_ERROR_TEXT = "An error occurred."
+ private const val CANCEL_BUTTON_TEXT = "Cancel"
+ private const val SAVE_BUTTON_TEXT = "Save"
+ }
+}
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
new file mode 100644
index 0000000000..5951550550
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreenTest.kt
@@ -0,0 +1,246 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+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.data.DUMMY_RELAY_COUNTRIES
+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 org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@OptIn(ExperimentalTestApi::class)
+class CustomListLocationsScreenTest {
+ @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension()
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun givenLoadingStateShouldShowLoadingSpinner() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state = CustomListLocationsUiState.Loading(newList = false)
+ )
+ }
+
+ // Assert
+ onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists()
+ }
+
+ @Test
+ fun givenNewListTrueShouldShowAddLocations() =
+ composeExtension.use {
+ // Arrange
+ val newList = true
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state = CustomListLocationsUiState.Loading(newList = newList)
+ )
+ }
+
+ // Assert
+ onNodeWithText(ADD_LOCATIONS_TEXT).assertExists()
+ }
+
+ @Test
+ fun givenNewListFalseShouldShowEditLocations() =
+ composeExtension.use {
+ // Arrange
+ val newList = false
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state = CustomListLocationsUiState.Loading(newList = newList)
+ )
+ }
+
+ // Assert
+ onNodeWithText(EDIT_LOCATIONS_TEXT).assertExists()
+ }
+
+ @Test
+ fun givenListOfAvailableLocationsShouldShowThem() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state =
+ CustomListLocationsUiState.Content.Data(
+ availableLocations = DUMMY_RELAY_COUNTRIES,
+ selectedLocations = emptySet(),
+ searchTerm = ""
+ ),
+ )
+ }
+
+ // Assert
+ onNodeWithText("Relay Country 1").assertExists()
+ onNodeWithText("Relay City 1").assertDoesNotExist()
+ onNodeWithText("Relay host 1").assertDoesNotExist()
+ onNodeWithText("Relay Country 2").assertExists()
+ onNodeWithText("Relay City 2").assertDoesNotExist()
+ onNodeWithText("Relay host 2").assertDoesNotExist()
+ }
+
+ @Test
+ fun whenClickingOnRelayShouldCallOnSelectForThatRelay() =
+ composeExtension.use {
+ // Arrange
+ val selectedCountry = DUMMY_RELAY_COUNTRIES[0]
+ val mockedOnRelaySelectionClicked: (RelayItem, Boolean) -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state =
+ CustomListLocationsUiState.Content.Data(
+ newList = false,
+ availableLocations = DUMMY_RELAY_COUNTRIES,
+ selectedLocations = setOf(selectedCountry)
+ ),
+ onRelaySelectionClick = mockedOnRelaySelectionClicked
+ )
+ }
+
+ // Act
+ onNodeWithText(selectedCountry.name).performClick()
+
+ // Assert
+ verify { mockedOnRelaySelectionClicked(selectedCountry, false) }
+ }
+
+ @Test
+ fun whenSearchInputIsUpdatedShouldCallOnSearchTermInput() =
+ composeExtension.use {
+ // Arrange
+ val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state =
+ CustomListLocationsUiState.Content.Data(
+ newList = false,
+ availableLocations = DUMMY_RELAY_COUNTRIES,
+ ),
+ onSearchTermInput = mockedSearchTermInput
+ )
+ }
+ val mockSearchString = "SEARCH"
+
+ // Act
+ onNodeWithText(SEARCH_PLACEHOLDER).performTextInput(mockSearchString)
+
+ // Assert
+ verify { mockedSearchTermInput.invoke(mockSearchString) }
+ }
+
+ @Test
+ fun whenSearchResultNotFoundShouldShowSearchNotFoundText() =
+ composeExtension.use {
+ // Arrange
+ val mockedSearchTermInput: (String) -> Unit = mockk(relaxed = true)
+ val mockSearchString = "SEARCH"
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state =
+ CustomListLocationsUiState.Content.Empty(
+ newList = false,
+ searchTerm = mockSearchString
+ ),
+ onSearchTermInput = mockedSearchTermInput
+ )
+ }
+
+ // Assert
+ onNodeWithText(EMPTY_SEARCH_FIRST_ROW.format(mockSearchString), substring = true)
+ .assertExists()
+ onNodeWithText(EMPTY_SEARCH_SECOND_ROW, substring = true).assertExists()
+ }
+
+ @Test
+ fun whenRelayListIsEmptyShouldShowNoRelaysText() =
+ composeExtension.use {
+ // Arrange
+ val emptySearchString = ""
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state =
+ CustomListLocationsUiState.Content.Empty(
+ newList = false,
+ searchTerm = emptySearchString
+ )
+ )
+ }
+
+ // Assert
+ onNodeWithText(NO_LOCATIONS_FOUND_TEXT).assertExists()
+ }
+
+ @Test
+ fun givenSaveIsEnabledWhenSaveClickedShouldCallOnSaveClick() =
+ composeExtension.use {
+ // Arrange
+ val mockOnSaveClick: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state =
+ CustomListLocationsUiState.Content.Data(
+ newList = false,
+ availableLocations = DUMMY_RELAY_COUNTRIES,
+ saveEnabled = true,
+ ),
+ onSaveClick = mockOnSaveClick
+ )
+ }
+
+ // Act
+ onNodeWithTag(SAVE_BUTTON_TEST_TAG).performClick()
+
+ // Assert
+ verify { mockOnSaveClick() }
+ }
+
+ @Test
+ fun givenSaveIsDisabledWhenSaveClickedShouldNotCallOnSaveClick() =
+ composeExtension.use {
+ // Arrange
+ val mockOnSaveClick: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ CustomListLocationsScreen(
+ state =
+ CustomListLocationsUiState.Content.Data(
+ newList = false,
+ availableLocations = DUMMY_RELAY_COUNTRIES,
+ saveEnabled = false,
+ ),
+ onSaveClick = mockOnSaveClick
+ )
+ }
+
+ // Act
+ onNodeWithTag(SAVE_BUTTON_TEST_TAG).performClick()
+
+ // Assert
+ verify(exactly = 0) { mockOnSaveClick() }
+ }
+
+ companion object {
+ const val ADD_LOCATIONS_TEXT = "Add locations"
+ const val EDIT_LOCATIONS_TEXT = "Edit locations"
+ const val SEARCH_PLACEHOLDER = "Search for..."
+ const val EMPTY_SEARCH_FIRST_ROW = "No result for %s."
+ const val EMPTY_SEARCH_SECOND_ROW = "Try a different search"
+ const val NO_LOCATIONS_FOUND_TEXT = "No locations found"
+ }
+}
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
new file mode 100644
index 0000000000..da9ed60997
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreenTest.kt
@@ -0,0 +1,105 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+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.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 org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@OptIn(ExperimentalTestApi::class)
+class CustomListsScreenTest {
+ @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension()
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun givenLoadingStateShouldShowLoadingSpinner() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme {
+ CustomListsScreen(
+ state = CustomListsUiState.Loading,
+ snackbarHostState = SnackbarHostState()
+ )
+ }
+
+ // Assert
+ onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists()
+ }
+
+ @Test
+ fun givenCustomListsShouldShowTheirNames() =
+ composeExtension.use {
+ // Arrange
+ val customLists = DUMMY_CUSTOM_LISTS
+ setContentWithTheme {
+ CustomListsScreen(
+ state = CustomListsUiState.Content(customLists = customLists),
+ snackbarHostState = SnackbarHostState()
+ )
+ }
+
+ // Assert
+ onNodeWithText(customLists[0].name).assertExists()
+ onNodeWithText(customLists[1].name).assertExists()
+ }
+
+ @Test
+ fun whenNewListButtonIsClickedShouldCallAddCustomList() =
+ composeExtension.use {
+ // Arrange
+ val customLists = DUMMY_CUSTOM_LISTS
+ val mockedAddCustomList: () -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ CustomListsScreen(
+ state = CustomListsUiState.Content(customLists = customLists),
+ snackbarHostState = SnackbarHostState(),
+ addCustomList = mockedAddCustomList
+ )
+ }
+
+ // Act
+ onNodeWithTag(NEW_LIST_BUTTON_TEST_TAG).performClick()
+
+ // Assert
+ verify { mockedAddCustomList() }
+ }
+
+ @Test
+ fun whenACustomListIsClickedShouldCallOpenCustomList() =
+ composeExtension.use {
+ // Arrange
+ val customLists = DUMMY_CUSTOM_LISTS
+ val clickedList = DUMMY_CUSTOM_LISTS[0]
+ val mockedOpenCustomList: (RelayItem.CustomList) -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ CustomListsScreen(
+ state = CustomListsUiState.Content(customLists = customLists),
+ snackbarHostState = SnackbarHostState(),
+ openCustomList = mockedOpenCustomList
+ )
+ }
+
+ // Act
+ onNodeWithText(clickedList.name).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
new file mode 100644
index 0000000000..f44441b536
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreenTest.kt
@@ -0,0 +1,170 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+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.setContentWithTheme
+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 org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@OptIn(ExperimentalTestApi::class)
+class EditCustomListScreenTest {
+ @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension()
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun givenLoadingStateShouldShowLoadingSpinner() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme { EditCustomListScreen(state = EditCustomListState.Loading) }
+
+ // Assert
+ onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertExists()
+ }
+
+ @Test
+ fun givenNotFoundStateShouldShowNotFound() =
+ composeExtension.use {
+ // Arrange
+ setContentWithTheme { EditCustomListScreen(state = EditCustomListState.NotFound) }
+
+ // Assert
+ onNodeWithText(NOT_FOUND_TEXT).assertExists()
+ }
+
+ @Test
+ fun givenContentStateShouldShowNameFromState() =
+ composeExtension.use {
+ // Arrange
+ val customList = DUMMY_CUSTOM_LISTS[0]
+ setContentWithTheme {
+ EditCustomListScreen(
+ state =
+ EditCustomListState.Content(
+ id = customList.id,
+ name = customList.name,
+ locations = customList.locations
+ )
+ )
+ }
+
+ // Assert
+ onNodeWithText(customList.name)
+ }
+
+ @Test
+ fun givenContentStateShouldShowNumberOfLocationsFromState() =
+ composeExtension.use {
+ // Arrange
+ val customList = DUMMY_CUSTOM_LISTS[0]
+ setContentWithTheme {
+ EditCustomListScreen(
+ state =
+ EditCustomListState.Content(
+ id = customList.id,
+ name = customList.name,
+ locations = customList.locations
+ )
+ )
+ }
+
+ // Assert
+ onNodeWithText(LOCATIONS_TEXT.format(customList.locations.size))
+ }
+
+ @Test
+ fun whenClickingOnDeleteDropdownShouldCallOnDeleteList() =
+ composeExtension.use {
+ // Arrange
+ val mockedOnDelete: (String) -> Unit = mockk(relaxed = true)
+ val customList = DUMMY_CUSTOM_LISTS[0]
+ setContentWithTheme {
+ EditCustomListScreen(
+ state =
+ EditCustomListState.Content(
+ id = customList.id,
+ name = customList.name,
+ locations = customList.locations
+ ),
+ onDeleteList = mockedOnDelete
+ )
+ }
+
+ // Act
+ onNodeWithTag(TOP_BAR_DROPDOWN_BUTTON_TEST_TAG).performClick()
+ onNodeWithTag(DELETE_DROPDOWN_MENU_ITEM_TEST_TAG).performClick()
+
+ // Assert
+ verify { mockedOnDelete(customList.name) }
+ }
+
+ @Test
+ fun whenClickingOnNameCellShouldCallOnNameClicked() =
+ composeExtension.use {
+ // Arrange
+ val mockedOnNameClicked: (String, String) -> Unit = mockk(relaxed = true)
+ val customList = DUMMY_CUSTOM_LISTS[0]
+ setContentWithTheme {
+ EditCustomListScreen(
+ state =
+ EditCustomListState.Content(
+ id = customList.id,
+ name = customList.name,
+ locations = customList.locations
+ ),
+ onNameClicked = mockedOnNameClicked
+ )
+ }
+
+ // Act
+ onNodeWithText(customList.name).performClick()
+
+ // Assert
+ verify { mockedOnNameClicked(customList.id, customList.name) }
+ }
+
+ @Test
+ fun whenClickingOnLocationCellShouldCallOnLocationsClicked() =
+ composeExtension.use {
+ // Arrange
+ val mockedOnLocationsClicked: (String) -> Unit = mockk(relaxed = true)
+ val customList = DUMMY_CUSTOM_LISTS[0]
+ setContentWithTheme {
+ EditCustomListScreen(
+ state =
+ EditCustomListState.Content(
+ id = customList.id,
+ name = customList.name,
+ locations = customList.locations
+ ),
+ onLocationsClicked = mockedOnLocationsClicked
+ )
+ }
+
+ // Act
+ onNodeWithText(LOCATIONS_TEXT.format(customList.locations.size)).performClick()
+
+ // Assert
+ verify { mockedOnLocationsClicked(customList.id) }
+ }
+
+ companion object {
+ const val NOT_FOUND_TEXT = "Not found"
+ const val LOCATIONS_TEXT = "%d locations"
+ }
+}
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 fe28357048..28651c3852 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
@@ -3,24 +3,22 @@ package net.mullvad.mullvadvpn.compose.screen
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag
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.data.DUMMY_CUSTOM_LISTS
+import net.mullvad.mullvadvpn.compose.data.DUMMY_RELAY_COUNTRIES
import net.mullvad.mullvadvpn.compose.setContentWithTheme
-import net.mullvad.mullvadvpn.compose.state.RelayListState
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
-import net.mullvad.mullvadvpn.model.Constraint
-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.toRelayCountries
+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.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
@@ -55,12 +53,11 @@ class SelectLocationScreenTest {
setContentWithTheme {
SelectLocationScreen(
state =
- SelectLocationUiState.Data(
- relayListState =
- RelayListState.RelayList(
- countries = DUMMY_RELAY_COUNTRIES,
- selectedItem = null
- ),
+ SelectLocationUiState.Content(
+ customLists = emptyList(),
+ filteredCustomLists = emptyList(),
+ countries = DUMMY_RELAY_COUNTRIES,
+ selectedItem = null,
selectedOwnership = null,
selectedProvidersCount = 0,
searchTerm = ""
@@ -95,12 +92,11 @@ class SelectLocationScreenTest {
setContentWithTheme {
SelectLocationScreen(
state =
- SelectLocationUiState.Data(
- relayListState =
- RelayListState.RelayList(
- countries = updatedDummyList,
- selectedItem = updatedDummyList[0].cities[0].relays[0]
- ),
+ SelectLocationUiState.Content(
+ customLists = emptyList(),
+ filteredCustomLists = emptyList(),
+ countries = updatedDummyList,
+ selectedItem = updatedDummyList[0].cities[0].relays[0],
selectedOwnership = null,
selectedProvidersCount = 0,
searchTerm = ""
@@ -125,12 +121,11 @@ class SelectLocationScreenTest {
setContentWithTheme {
SelectLocationScreen(
state =
- SelectLocationUiState.Data(
- relayListState =
- RelayListState.RelayList(
- countries = emptyList(),
- selectedItem = null
- ),
+ SelectLocationUiState.Content(
+ customLists = emptyList(),
+ filteredCustomLists = emptyList(),
+ countries = emptyList(),
+ selectedItem = null,
selectedOwnership = null,
selectedProvidersCount = 0,
searchTerm = ""
@@ -156,8 +151,11 @@ class SelectLocationScreenTest {
setContentWithTheme {
SelectLocationScreen(
state =
- SelectLocationUiState.Data(
- relayListState = RelayListState.Empty,
+ SelectLocationUiState.Content(
+ customLists = emptyList(),
+ filteredCustomLists = emptyList(),
+ countries = emptyList(),
+ selectedItem = null,
selectedOwnership = null,
selectedProvidersCount = 0,
searchTerm = mockSearchString
@@ -171,41 +169,143 @@ class SelectLocationScreenTest {
onNodeWithText("Try a different search", substring = true).assertExists()
}
- companion object {
- private val DUMMY_RELAY_1 =
- net.mullvad.mullvadvpn.model.Relay(
- hostname = "Relay host 1",
- active = true,
- endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData),
- owned = true,
- provider = "PROVIDER"
- )
- private val DUMMY_RELAY_2 =
- net.mullvad.mullvadvpn.model.Relay(
- hostname = "Relay host 2",
- active = true,
- endpointData = RelayEndpointData.Wireguard(WireguardRelayEndpointData),
- owned = true,
- provider = "PROVIDER"
- )
- 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))
- private val DUMMY_RELAY_COUNTRY_2 =
- RelayListCountry("Relay Country 2", "RCo2", arrayListOf(DUMMY_RELAY_CITY_2))
+ @Test
+ fun givenNoCustomListsAndSearchIsTermIsEmptyShouldShowCustomListsEmptyText() =
+ composeExtension.use {
+ // Arrange
+ val mockSearchString = ""
+ setContentWithTheme {
+ SelectLocationScreen(
+ state =
+ SelectLocationUiState.Content(
+ customLists = emptyList(),
+ filteredCustomLists = emptyList(),
+ countries = emptyList(),
+ selectedItem = null,
+ selectedOwnership = null,
+ selectedProvidersCount = 0,
+ searchTerm = mockSearchString
+ ),
+ )
+ }
+
+ // Assert
+ onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertExists()
+ }
+
+ @Test
+ fun givenNoCustomListsAndSearchIsActiveShouldNotShowCustomListHeader() =
+ composeExtension.use {
+ // Arrange
+ val mockSearchString = "SEARCH"
+ setContentWithTheme {
+ SelectLocationScreen(
+ state =
+ SelectLocationUiState.Content(
+ customLists = DUMMY_CUSTOM_LISTS,
+ filteredCustomLists = emptyList(),
+ countries = emptyList(),
+ selectedItem = null,
+ selectedOwnership = null,
+ selectedProvidersCount = 0,
+ searchTerm = mockSearchString
+ ),
+ )
+ }
+
+ // Assert
+ onNodeWithText(CUSTOM_LISTS_EMPTY_TEXT).assertDoesNotExist()
+ onNodeWithTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun whenCustomListIsClickedShouldCallOnSelectRelay() =
+ composeExtension.use {
+ // Arrange
+ val customList = DUMMY_CUSTOM_LISTS[0]
+ val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ SelectLocationScreen(
+ state =
+ SelectLocationUiState.Content(
+ customLists = DUMMY_CUSTOM_LISTS,
+ filteredCustomLists = DUMMY_CUSTOM_LISTS,
+ countries = emptyList(),
+ selectedItem = null,
+ selectedOwnership = null,
+ selectedProvidersCount = 0,
+ searchTerm = ""
+ ),
+ onSelectRelay = mockedOnSelectRelay
+ )
+ }
- private val DUMMY_WIREGUARD_PORT_RANGES = ArrayList<PortRange>()
- private val DUMMY_WIREGUARD_ENDPOINT_DATA =
- WireguardEndpointData(DUMMY_WIREGUARD_PORT_RANGES)
+ // Act
+ onNodeWithText(customList.name).performClick()
- private val DUMMY_RELAY_COUNTRIES =
- RelayList(
- arrayListOf(DUMMY_RELAY_COUNTRY_1, DUMMY_RELAY_COUNTRY_2),
- DUMMY_WIREGUARD_ENDPOINT_DATA,
+ // Assert
+ verify { mockedOnSelectRelay(customList) }
+ }
+
+ @Test
+ fun whenCustomListIsLongClickedShouldShowBottomSheet() =
+ composeExtension.use {
+ // Arrange
+ val customList = DUMMY_CUSTOM_LISTS[0]
+ val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ SelectLocationScreen(
+ state =
+ SelectLocationUiState.Content(
+ customLists = DUMMY_CUSTOM_LISTS,
+ filteredCustomLists = DUMMY_CUSTOM_LISTS,
+ countries = emptyList(),
+ selectedItem = null,
+ selectedOwnership = null,
+ selectedProvidersCount = 0,
+ searchTerm = ""
+ ),
+ onSelectRelay = mockedOnSelectRelay
)
- .toRelayCountries(ownership = Constraint.Any(), providers = Constraint.Any())
+ }
+
+ // Act
+ onNodeWithText(customList.name).performLongClick()
+
+ // Assert
+ onNodeWithTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG)
+ }
+
+ @Test
+ fun whenLocationIsLongClickedShouldShowBottomSheet() =
+ composeExtension.use {
+ // Arrange
+ val relayItem = DUMMY_RELAY_COUNTRIES[0]
+ val mockedOnSelectRelay: (RelayItem) -> Unit = mockk(relaxed = true)
+ setContentWithTheme {
+ SelectLocationScreen(
+ state =
+ SelectLocationUiState.Content(
+ customLists = emptyList(),
+ filteredCustomLists = emptyList(),
+ countries = DUMMY_RELAY_COUNTRIES,
+ selectedItem = null,
+ selectedOwnership = null,
+ selectedProvidersCount = 0,
+ searchTerm = ""
+ ),
+ onSelectRelay = mockedOnSelectRelay
+ )
+ }
+
+ // Act
+ onNodeWithText(relayItem.name).performLongClick()
+
+ // Assert
+ onNodeWithTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG)
+ }
+
+ companion object {
+ private const val CUSTOM_LISTS_EMPTY_TEXT = "To create a custom list press the \"︙\""
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
index 15f6e4d111..19049613bd 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
@@ -35,7 +35,7 @@ private fun PreviewBaseCell() {
AppTheme {
SpacedColumn {
BaseCell(
- title = {
+ headlineContent = {
BaseCellTitle(
title = "Header title",
style = MaterialTheme.typography.titleMedium
@@ -43,7 +43,7 @@ private fun PreviewBaseCell() {
}
)
BaseCell(
- title = {
+ headlineContent = {
BaseCellTitle(
title = "Normal title",
style = MaterialTheme.typography.labelLarge
@@ -58,7 +58,7 @@ private fun PreviewBaseCell() {
internal fun BaseCell(
modifier: Modifier = Modifier,
iconView: @Composable RowScope.() -> Unit = {},
- title: @Composable RowScope.() -> Unit,
+ headlineContent: @Composable RowScope.() -> Unit,
bodyView: @Composable ColumnScope.() -> Unit = {},
isRowEnabled: Boolean = true,
onCellClicked: () -> Unit = {},
@@ -83,7 +83,7 @@ internal fun BaseCell(
) {
iconView()
- title()
+ headlineContent()
Column(modifier = Modifier.wrapContentWidth().wrapContentHeight()) { bodyView() }
}
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 5190a3a959..fe270ba445 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
@@ -2,16 +2,12 @@ package net.mullvad.mullvadvpn.compose.cell
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Checkbox
-import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -20,9 +16,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
+import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
-import net.mullvad.mullvadvpn.lib.theme.color.selected
@Preview
@Composable
@@ -37,7 +33,7 @@ internal fun CheckboxCell(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
background: Color = MaterialTheme.colorScheme.secondaryContainer,
- startPadding: Dp = Dimens.cellStartPadding,
+ startPadding: Dp = Dimens.mediumPadding,
endPadding: Dp = Dimens.cellEndPadding,
minHeight: Dp = Dimens.cellHeight
) {
@@ -51,23 +47,7 @@ internal fun CheckboxCell(
.background(background)
.padding(start = startPadding, end = endPadding)
) {
- Box(
- modifier =
- Modifier.size(Dimens.checkBoxSize)
- .background(Color.White, MaterialTheme.shapes.small)
- ) {
- Checkbox(
- modifier = Modifier.fillMaxSize(),
- checked = checked,
- onCheckedChange = onCheckedChange,
- colors =
- CheckboxDefaults.colors(
- checkedColor = Color.Transparent,
- uncheckedColor = Color.Transparent,
- checkmarkColor = MaterialTheme.colorScheme.selected
- ),
- )
- }
+ MullvadCheckbox(checked = checked, onCheckedChange = onCheckedChange)
Spacer(modifier = Modifier.size(Dimens.mediumPadding))
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
new file mode 100644
index 0000000000..1029cfada0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt
@@ -0,0 +1,31 @@
+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/DnsCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt
index 2a0043842a..21d5558f9e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt
@@ -34,7 +34,7 @@ fun DnsCell(
val startPadding = 54.dp
BaseCell(
- title = { DnsTitle(address = address, modifier = titleModifier) },
+ headlineContent = { DnsTitle(address = address, modifier = titleModifier) },
bodyView = {
if (isUnreachableLocalDnsWarningVisible) {
Icon(
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
new file mode 100644
index 0000000000..3d52aca80c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt
@@ -0,0 +1,61 @@
+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
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewThreeDotCell() {
+ AppTheme {
+ ThreeDotCell(
+ text = "Three dots",
+ )
+ }
+}
+
+@Composable
+fun ThreeDotCell(
+ text: String,
+ modifier: Modifier = Modifier,
+ onClickDots: () -> Unit = {},
+ textStyle: TextStyle = MaterialTheme.typography.titleMedium,
+ textColor: Color = MaterialTheme.colorScheme.onPrimary,
+ background: Color = MaterialTheme.colorScheme.primary
+) {
+ BaseCell(
+ headlineContent = {
+ BaseCellTitle(
+ title = text,
+ style = textStyle,
+ color = textColor,
+ modifier = Modifier.weight(1f, true)
+ )
+ },
+ modifier = modifier,
+ background = background,
+ bodyView = {
+ IconButton(onClick = onClickDots) {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_more_vert),
+ contentDescription = null,
+ tint = textColor
+ )
+ }
+ },
+ isRowEnabled = false,
+ endPadding = Dimens.smallPadding
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
index 9ea26d6e01..73a6a5283d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt
@@ -54,7 +54,7 @@ fun ExpandableComposeCell(
BaseCell(
modifier = Modifier.focusProperties { canFocus = false },
- title = {
+ headlineContent = {
BaseCellTitle(
title = title,
style = MaterialTheme.typography.titleMedium,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt
index 8807430989..3260e9099a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt
@@ -15,7 +15,7 @@ fun HeaderCell(
background: Color = MaterialTheme.colorScheme.primary,
) {
BaseCell(
- title = {
+ headlineContent = {
BaseCellTitle(
title = text,
style = textStyle,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
new file mode 100644
index 0000000000..faf537fb7f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt
@@ -0,0 +1,54 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewIconCell() {
+ AppTheme { IconCell(iconId = R.drawable.icon_add, title = "Add") }
+}
+
+@Composable
+fun IconCell(
+ iconId: Int?,
+ contentDescription: String? = null,
+ title: String,
+ titleStyle: TextStyle = MaterialTheme.typography.labelLarge,
+ titleColor: Color = MaterialTheme.colorScheme.onPrimary,
+ onClick: () -> Unit = {},
+ background: Color = MaterialTheme.colorScheme.primary,
+ enabled: Boolean = true,
+) {
+ BaseCell(
+ headlineContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ iconId?.let {
+ Icon(
+ painter = painterResource(id = iconId),
+ contentDescription = contentDescription,
+ tint = titleColor
+ )
+ Spacer(modifier = Modifier.width(Dimens.mediumPadding))
+ }
+ BaseCellTitle(title = title, style = titleStyle, color = titleColor)
+ }
+ },
+ onCellClicked = onClick,
+ background = background,
+ isRowEnabled = enabled
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt
index ef898aac6d..f490294491 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt
@@ -47,7 +47,7 @@ fun InformationComposeCell(
BaseCell(
modifier = Modifier.focusProperties { canFocus = false },
- title = {
+ headlineContent = {
BaseCellTitle(
title = title,
style = MaterialTheme.typography.titleMedium,
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 7cd45ddb2d..d949f2a708 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
@@ -28,7 +28,7 @@ fun MtuComposeCell(
val titleModifier = Modifier
BaseCell(
- title = { MtuTitle(modifier = titleModifier.weight(1f, true)) },
+ headlineContent = { MtuTitle(modifier = titleModifier.weight(1f, true)) },
bodyView = { MtuBodyView(mtuValue = mtuValue, modifier = titleModifier) },
onCellClicked = { onEditMtu.invoke() }
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
index 272e599d8d..27b74227ca 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
@@ -70,7 +70,7 @@ fun NavigationComposeCell(
) {
BaseCell(
onCellClicked = onClick,
- title = {
+ headlineContent = {
NavigationTitleView(
title = title,
modifier = modifier.weight(1f, true),
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 68899f7f77..032695be88 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
@@ -1,13 +1,17 @@
package net.mullvad.mullvadvpn.compose.cell
import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -25,163 +29,210 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
+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.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
-import net.mullvad.mullvadvpn.lib.theme.color.Alpha40
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.model.GeographicLocationConstraint
import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.relaylist.children
@Composable
@Preview
-private fun PreviewRelayLocationCell() {
+private fun PreviewStatusRelayLocationCell() {
AppTheme {
Column(Modifier.background(color = MaterialTheme.colorScheme.background)) {
val countryActive =
- RelayItem.Country(
+ generateRelayItemCountry(
name = "Relay country Active",
- code = "RC1",
- expanded = false,
- cities =
- listOf(
- RelayItem.City(
- name = "Relay city 1",
- code = "RI1",
- expanded = false,
- location = GeographicLocationConstraint.City("RC1", "RI1"),
- relays =
- listOf(
- RelayItem.Relay(
- name = "Relay 1",
- active = true,
- locationName = "",
- location =
- GeographicLocationConstraint.Hostname(
- "RC1",
- "RI1",
- "NER"
- )
- )
- )
- ),
- RelayItem.City(
- name = "Relay city 2",
- code = "RI2",
- expanded = true,
- location = GeographicLocationConstraint.City("RC1", "RI2"),
- relays =
- listOf(
- RelayItem.Relay(
- name = "Relay 2",
- active = true,
- locationName = "",
- location =
- GeographicLocationConstraint.Hostname(
- "RC1",
- "RI2",
- "NER"
- )
- ),
- RelayItem.Relay(
- name = "Relay 3",
- active = true,
- locationName = "",
- location =
- GeographicLocationConstraint.Hostname(
- "RC1",
- "RI1",
- "NER"
- )
- )
- )
- )
- )
+ cityNames = listOf("Relay city 1", "Relay city 2"),
+ relaysPerCity = 2
)
val countryNotActive =
- RelayItem.Country(
+ generateRelayItemCountry(
name = "Not Enabled Relay country",
- code = "RC3",
+ 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,
- cities =
- listOf(
- RelayItem.City(
- name = "Not Enabled city",
- code = "RI3",
- expanded = true,
- location = GeographicLocationConstraint.City("RC3", "RI3"),
- relays =
- listOf(
- RelayItem.Relay(
- name = "Not Enabled Relay",
- active = false,
- locationName = "",
- location =
- GeographicLocationConstraint.Hostname(
- "RC3",
- "RI3",
- "NER"
- )
- )
- )
- )
- )
+ expandChildren = true
)
// Active relay list not expanded
- RelayLocationCell(countryActive)
+ StatusRelayLocationCell(countryActive)
// Not Active Relay
- RelayLocationCell(countryNotActive)
- // Relay expanded country and city
- RelayLocationCell(countryActive.copy(expanded = true))
+ StatusRelayLocationCell(countryNotActive)
+ // Relay expanded country
+ StatusRelayLocationCell(countryExpanded)
+ // Relay expanded country and cities
+ StatusRelayLocationCell(countryAndCityExpanded)
+ }
+ }
+}
+
+@Composable
+@Preview
+private fun PreviewCheckableRelayLocationCell() {
+ 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)
}
}
}
@Composable
-fun RelayLocationCell(
+fun StatusRelayLocationCell(
relay: RelayItem,
modifier: Modifier = Modifier,
activeColor: Color = MaterialTheme.colorScheme.selected,
inactiveColor: Color = MaterialTheme.colorScheme.error,
+ disabledColor: Color = MaterialTheme.colorScheme.onSecondary,
selectedItem: RelayItem? = null,
- onSelectRelay: (item: RelayItem) -> Unit = {}
+ onSelectRelay: (item: RelayItem) -> Unit = {},
+ onLongClick: (item: RelayItem) -> Unit = {}
) {
- val startPadding =
- when (relay) {
- is RelayItem.Country,
- is RelayItem.CustomList -> Dimens.countryRowPadding
- is RelayItem.City -> Dimens.cityRowPadding
- is RelayItem.Relay -> Dimens.relayRowPadding
- }
- val selected = selectedItem?.code == relay.code
+ RelayLocationCell(
+ relay = relay,
+ leadingContent = { relayItem ->
+ val selected = selectedItem?.code == relayItem.code
+ Box(
+ modifier =
+ Modifier.align(Alignment.CenterStart)
+ .size(Dimens.relayCircleSize)
+ .background(
+ color =
+ when {
+ selected -> Color.Unspecified
+ relayItem is RelayItem.CustomList && !relayItem.hasChildren ->
+ disabledColor
+ relayItem.active -> activeColor
+ else -> inactiveColor
+ },
+ shape = CircleShape
+ )
+ )
+ Image(
+ painter = painterResource(id = R.drawable.icon_tick),
+ modifier =
+ Modifier.align(Alignment.CenterStart)
+ .alpha(
+ if (selected) {
+ AlphaVisible
+ } else {
+ AlphaInvisible
+ }
+ ),
+ contentDescription = null
+ )
+ },
+ modifier = modifier,
+ specialBackgroundColor = { relayItem ->
+ when {
+ selectedItem?.code == relayItem.code -> MaterialTheme.colorScheme.selected
+ relayItem is RelayItem.CustomList && !relayItem.active ->
+ MaterialTheme.colorScheme.surfaceTint
+ else -> null
+ }
+ },
+ onClick = onSelectRelay,
+ onLongClick = onLongClick,
+ depth = 0
+ )
+}
+
+@Composable
+fun CheckableRelayLocationCell(
+ relay: RelayItem,
+ modifier: Modifier = Modifier,
+ onRelayCheckedChange: (item: RelayItem, isChecked: Boolean) -> Unit = { _, _ -> },
+ selectedRelays: Set<RelayItem> = emptySet(),
+) {
+ RelayLocationCell(
+ relay = relay,
+ leadingContent = { relayItem ->
+ val checked = selectedRelays.contains(relayItem)
+ MullvadCheckbox(
+ checked = checked,
+ onCheckedChange = { isChecked -> onRelayCheckedChange(relayItem, isChecked) }
+ )
+ },
+ leadingContentStartPadding = Dimens.cellStartPaddingInteractive,
+ modifier = modifier,
+ onClick = { onRelayCheckedChange(it, !selectedRelays.contains(it)) },
+ onLongClick = null,
+ depth = 0
+ )
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun RelayLocationCell(
+ relay: RelayItem,
+ leadingContent: @Composable BoxScope.(relay: RelayItem) -> Unit,
+ modifier: Modifier = Modifier,
+ leadingContentStartPadding: Dp = Dimens.cellStartPadding,
+ leadingContentStarPaddingModifier: Dp = Dimens.mediumPadding,
+ specialBackgroundColor: @Composable (relayItem: RelayItem) -> Color? = { null },
+ onClick: (item: RelayItem) -> Unit,
+ onLongClick: ((item: RelayItem) -> Unit)?,
+ depth: Int
+) {
+ val startPadding = leadingContentStartPadding + leadingContentStarPaddingModifier * depth
val expanded =
rememberSaveable(key = relay.expanded.toString()) { mutableStateOf(relay.expanded) }
- val backgroundColor =
- when {
- selected -> MaterialTheme.colorScheme.inversePrimary
- relay is RelayItem.Country -> MaterialTheme.colorScheme.primary
- relay is RelayItem.City ->
- MaterialTheme.colorScheme.primary
- .copy(alpha = Alpha40)
- .compositeOver(MaterialTheme.colorScheme.background)
- relay is RelayItem.Relay -> MaterialTheme.colorScheme.secondaryContainer
- else -> MaterialTheme.colorScheme.primary
- }
Column(
modifier =
- modifier.then(
- Modifier.fillMaxWidth()
- .padding(top = Dimens.listItemDivider)
- .wrapContentHeight()
- .fillMaxWidth()
- )
+ modifier
+ .fillMaxWidth()
+ .padding(top = Dimens.listItemDivider)
+ .wrapContentHeight()
+ .fillMaxWidth()
) {
Row(
modifier =
@@ -189,110 +240,89 @@ fun RelayLocationCell(
.wrapContentHeight()
.height(IntrinsicSize.Min)
.fillMaxWidth()
- .background(backgroundColor)
+ .background(specialBackgroundColor.invoke(relay) ?: depth.toBackgroundColor())
) {
Row(
modifier =
Modifier.weight(1f)
- .then(
- if (relay.active) {
- Modifier.clickable { onSelectRelay(relay) }
- } else {
- Modifier
- }
+ .combinedClickable(
+ enabled = relay.active,
+ onClick = { onClick(relay) },
+ onLongClick = { onLongClick?.invoke(relay) },
)
) {
Box(
modifier =
Modifier.align(Alignment.CenterVertically).padding(start = startPadding)
) {
- Box(
- modifier =
- Modifier.align(Alignment.CenterStart)
- .size(Dimens.relayCircleSize)
- .background(
- color =
- when {
- selected -> Color.Transparent
- relay.active -> activeColor
- else -> inactiveColor
- },
- shape = CircleShape
- )
- )
- Image(
- painter = painterResource(id = R.drawable.icon_tick),
- modifier =
- Modifier.align(Alignment.CenterStart)
- .alpha(
- if (selected) {
- AlphaVisible
- } else {
- AlphaInvisible
- }
- ),
- contentDescription = null
- )
+ leadingContent(relay)
}
- Text(
- text = relay.name,
- color = MaterialTheme.colorScheme.onPrimary,
- modifier =
- Modifier.weight(1f)
- .align(Alignment.CenterVertically)
- .alpha(
- if (relay.active) {
- AlphaVisible
- } else {
- AlphaInactive
- }
- )
- .padding(
- horizontal = Dimens.smallPadding,
- vertical = Dimens.mediumPadding
- )
+ Name(
+ modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
+ relay = relay
)
}
if (relay.hasChildren) {
- VerticalDivider(
- color = MaterialTheme.colorScheme.background,
- modifier = Modifier.padding(vertical = Dimens.verticalDividerPadding)
- )
- Chevron(
- isExpanded = expanded.value,
- modifier =
- Modifier.fillMaxHeight()
- .clickable { expanded.value = !expanded.value }
- .padding(horizontal = Dimens.largePadding)
- .align(Alignment.CenterVertically)
- )
+ ExpandButton(isExpanded = expanded.value) { expand -> expanded.value = expand }
}
}
if (expanded.value) {
- when (relay) {
- is RelayItem.Country -> {
- relay.cities.forEach { relayCity ->
- RelayLocationCell(
- relay = relayCity,
- selectedItem = selectedItem,
- onSelectRelay = onSelectRelay,
- modifier = Modifier.animateContentSize()
- )
- }
- }
- is RelayItem.City -> {
- relay.relays.forEach { relay ->
- RelayLocationCell(
- relay = relay,
- selectedItem = selectedItem,
- onSelectRelay = onSelectRelay,
- modifier = Modifier.animateContentSize()
- )
- }
- }
- is RelayItem.Relay,
- is RelayItem.CustomList -> {}
+ relay.children().forEach {
+ RelayLocationCell(
+ relay = it,
+ onClick = onClick,
+ modifier = Modifier.animateContentSize(),
+ leadingContent = leadingContent,
+ specialBackgroundColor = specialBackgroundColor,
+ onLongClick = onLongClick,
+ depth = depth + 1,
+ )
}
}
}
}
+
+@Composable
+private fun Name(modifier: Modifier = Modifier, relay: RelayItem) {
+ Text(
+ text = relay.name,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier =
+ modifier
+ .alpha(
+ if (relay.active) {
+ AlphaVisible
+ } else {
+ AlphaInactive
+ }
+ )
+ .padding(horizontal = Dimens.smallPadding, vertical = Dimens.mediumPadding)
+ )
+}
+
+@Composable
+private fun RowScope.ExpandButton(isExpanded: Boolean, onClick: (expand: Boolean) -> Unit) {
+ VerticalDivider(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier.padding(vertical = Dimens.verticalDividerPadding)
+ )
+ Chevron(
+ isExpanded = isExpanded,
+ modifier =
+ Modifier.fillMaxHeight()
+ .clickable { onClick(!isExpanded) }
+ .padding(horizontal = Dimens.largePadding)
+ .align(Alignment.CenterVertically)
+ )
+}
+
+@Suppress("MagicNumber")
+@Composable
+private fun Int.toBackgroundColor(): Color =
+ when (this) {
+ 0 -> MaterialTheme.colorScheme.surfaceContainerHighest
+ 1 -> MaterialTheme.colorScheme.surfaceContainerHigh
+ 2 -> MaterialTheme.colorScheme.surfaceContainerLow
+ 3 -> MaterialTheme.colorScheme.surfaceContainerLowest
+ else -> MaterialTheme.colorScheme.surfaceContainerLowest
+ }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt
index 686ba8846d..d69194490e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt
@@ -56,7 +56,7 @@ fun SelectableCell(
) {
BaseCell(
onCellClicked = onCellClicked,
- title = { BaseCellTitle(title = title, style = titleStyle) },
+ headlineContent = { BaseCellTitle(title = title, style = titleStyle) },
background =
if (isSelected) {
selectedColor
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt
index 25b6f71445..98c3767393 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt
@@ -89,7 +89,7 @@ fun SplitTunnelingCell(
Modifier.align(Alignment.CenterVertically).size(size = Dimens.listIconSize)
)
},
- title = {
+ headlineContent = {
Text(
text = title,
style = MaterialTheme.typography.listItemText,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt
index 6aeea8897d..f74349113c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt
@@ -121,7 +121,7 @@ private fun SwitchComposeCell(
) {
BaseCell(
modifier = modifier.focusProperties { canFocus = false },
- title = titleView,
+ headlineContent = titleView,
isRowEnabled = isEnabled,
bodyView = {
SwitchCellView(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
new file mode 100644
index 0000000000..0b1f36d21d
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt
@@ -0,0 +1,50 @@
+package net.mullvad.mullvadvpn.compose.cell
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewTwoRowCell() {
+ AppTheme { TwoRowCell(titleText = "Title", subtitleText = "Subtitle") }
+}
+
+@Composable
+fun TwoRowCell(
+ titleText: String,
+ subtitleText: String,
+ onCellClicked: () -> Unit = {},
+ titleColor: Color = MaterialTheme.colorScheme.onPrimary,
+ subtitleColor: Color = MaterialTheme.colorScheme.onPrimary,
+ background: Color = MaterialTheme.colorScheme.primary
+) {
+ BaseCell(
+ headlineContent = {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = titleText,
+ style = MaterialTheme.typography.labelLarge,
+ color = titleColor
+ )
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = subtitleText,
+ style = MaterialTheme.typography.labelLarge,
+ color = subtitleColor
+ )
+ }
+ },
+ onCellClicked = onCellClicked,
+ background = background,
+ minHeight = Dimens.cellHeightTwoRows
+ )
+}
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
new file mode 100644
index 0000000000..0b478f5272
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListAction.kt
@@ -0,0 +1,33 @@
+package net.mullvad.mullvadvpn.compose.communication
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+sealed interface CustomListAction : Parcelable {
+
+ @Parcelize
+ data class Rename(val customListId: String, val name: String, val newName: String) :
+ CustomListAction {
+ fun not() = this.copy(name = newName, newName = name)
+ }
+
+ @Parcelize
+ data class Delete(val customListId: String) : CustomListAction {
+ fun not(name: String, locations: List<String>) = Create(name, locations)
+ }
+
+ @Parcelize
+ data class Create(val name: String = "", val locations: List<String> = emptyList()) :
+ CustomListAction, Parcelable {
+ fun not(customListId: String) = Delete(customListId)
+ }
+
+ @Parcelize
+ data class UpdateLocations(
+ val customListId: String,
+ val locations: List<String> = emptyList()
+ ) : CustomListAction {
+ fun not(locations: List<String>): UpdateLocations =
+ UpdateLocations(customListId = customListId, 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
new file mode 100644
index 0000000000..32fa077a7f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/communication/CustomListResult.kt
@@ -0,0 +1,34 @@
+package net.mullvad.mullvadvpn.compose.communication
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+sealed interface CustomListResult : Parcelable {
+ val undo: CustomListAction
+
+ @Parcelize
+ data class Created(
+ val id: String,
+ val name: String,
+ val locationName: String?,
+ override val undo: CustomListAction.Delete
+ ) : CustomListResult
+
+ @Parcelize
+ data class Deleted(override val undo: CustomListAction.Create) : CustomListResult {
+ val name
+ get() = undo.name
+ }
+
+ @Parcelize
+ data class Renamed(override val undo: CustomListAction.Rename) : CustomListResult {
+ val name: String
+ get() = undo.name
+ }
+
+ @Parcelize
+ data class LocationsChanged(
+ val name: String,
+ override val undo: CustomListAction.UpdateLocations
+ ) : CustomListResult
+}
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
new file mode 100644
index 0000000000..675f6f8f14
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CustomListNameTextField.kt
@@ -0,0 +1,72 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+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.CustomListsError
+
+@Composable
+fun CustomListNameTextField(
+ modifier: Modifier = Modifier,
+ name: String,
+ isValidName: Boolean,
+ error: CustomListsError?,
+ onValueChanged: (String) -> Unit,
+ onSubmit: (String) -> Unit
+) {
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+ CustomTextField(
+ value = name,
+ onValueChanged = onValueChanged,
+ onSubmit = {
+ if (isValidName) {
+ onSubmit(it)
+ }
+ },
+ // This can not be set to KeyboardType.Text because it will show the
+ // suggestions, this will cause an infinite loop on Android TV with Gboard
+ keyboardType = KeyboardType.Password,
+ placeholderText = null,
+ isValidValue = error == null,
+ isDigitsOnlyAllowed = false,
+ supportingText =
+ error?.let {
+ {
+ Text(
+ text = it.errorString(),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ },
+ modifier =
+ modifier.focusRequester(focusRequester).onFocusChanged { focusState ->
+ if (focusState.hasFocus) {
+ keyboardController?.show()
+ }
+ }
+ )
+
+ 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/LocationsEmptyText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt
new file mode 100644
index 0000000000..7c12443647
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt
@@ -0,0 +1,60 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+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.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.core.text.HtmlCompat
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH
+
+@Composable
+fun LocationsEmptyText(searchTerm: String) {
+ if (searchTerm.length >= MIN_SEARCH_LENGTH) {
+ val firstRow =
+ HtmlCompat.fromHtml(
+ textResource(
+ id = R.string.select_location_empty_text_first_row,
+ searchTerm,
+ ),
+ HtmlCompat.FROM_HTML_MODE_COMPACT,
+ )
+ .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold)
+ val secondRow = textResource(id = R.string.select_location_empty_text_second_row)
+ Column(
+ modifier = Modifier.padding(horizontal = Dimens.selectLocationTitlePadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = firstRow,
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSecondary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = secondRow,
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSecondary,
+ )
+ }
+ } else {
+ Text(
+ text = stringResource(R.string.no_locations_found),
+ modifier = Modifier.padding(Dimens.screenVerticalMargin),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt
new file mode 100644
index 0000000000..741fcf960f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt
@@ -0,0 +1,21 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import net.mullvad.mullvadvpn.lib.theme.color.selected
+
+@Composable
+fun MullvadCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ colors =
+ CheckboxDefaults.colors(
+ checkedColor = MaterialTheme.colorScheme.onPrimary,
+ uncheckedColor = MaterialTheme.colorScheme.onPrimary,
+ checkmarkColor = MaterialTheme.colorScheme.selected
+ )
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
new file mode 100644
index 0000000000..1f8fb46cd7
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt
@@ -0,0 +1,69 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.compose.cell.HeaderCell
+import net.mullvad.mullvadvpn.compose.cell.IconCell
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview
+@Composable
+private fun PreviewMullvadModalBottomSheet() {
+ AppTheme {
+ MullvadModalBottomSheet(
+ sheetContent = {
+ HeaderCell(
+ text = "Title",
+ )
+ HorizontalDivider()
+ IconCell(
+ iconId = null,
+ title = "Select",
+ )
+ },
+ closeBottomSheet = {}
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MullvadModalBottomSheet(
+ modifier: Modifier = Modifier,
+ sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer,
+ onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface,
+ closeBottomSheet: () -> Unit,
+ sheetContent: @Composable ColumnScope.() -> Unit
+) {
+ // This is to avoid weird colors in the status bar and the navigation bar
+ val paddingValues = BottomSheetDefaults.windowInsets.asPaddingValues()
+ ModalBottomSheet(
+ onDismissRequest = closeBottomSheet,
+ sheetState = sheetState,
+ containerColor = backgroundColor,
+ modifier = modifier,
+ windowInsets = WindowInsets(0, 0, 0, 0), // No insets
+ dragHandle = { BottomSheetDefaults.DragHandle(color = onBackgroundColor) }
+ ) {
+ sheetContent()
+ Spacer(modifier = Modifier.height(Dimens.smallPadding))
+ Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding()))
+ }
+}
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 7ca9af3b17..b9a6306413 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
@@ -108,7 +108,12 @@ fun ScaffoldWithTopBarAndDeviceName(
@Composable
fun MullvadSnackbar(snackbarData: SnackbarData) {
- Snackbar(snackbarData = snackbarData, contentColor = MaterialTheme.colorScheme.secondary)
+ Snackbar(
+ snackbarData = snackbarData,
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ actionColor = MaterialTheme.colorScheme.onSurface
+ )
}
@Composable
@@ -257,3 +262,24 @@ fun ScaffoldWithLargeTopBarAndButton(
}
)
}
+
+@Composable
+fun ScaffoldWithSmallTopBar(
+ appBarTitle: String,
+ modifier: Modifier = Modifier,
+ navigationIcon: @Composable () -> Unit = {},
+ actions: @Composable RowScope.() -> Unit = {},
+ content: @Composable (modifier: Modifier) -> Unit
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ MullvadSmallTopBar(
+ title = appBarTitle,
+ navigationIcon = navigationIcon,
+ actions = actions
+ )
+ },
+ content = { content(Modifier.fillMaxSize().padding(it)) }
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt
index 52e6e03efb..a9db7e0e62 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt
@@ -189,6 +189,25 @@ fun MullvadTopBar(
)
}
+@Composable
+fun MullvadSmallTopBar(
+ title: String,
+ navigationIcon: @Composable () -> Unit = {},
+ actions: @Composable RowScope.() -> Unit = {}
+) {
+ TopAppBar(
+ title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = navigationIcon,
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ scrolledContainerColor = MaterialTheme.colorScheme.background,
+ actionIconContentColor = MaterialTheme.colorScheme.onPrimary.copy(AlphaTopBar),
+ ),
+ actions = actions
+ )
+}
+
@Preview
@Composable
private fun PreviewMediumTopBar() {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt
index 41dbcadaa1..899a5b8fee 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/constant/CommonContentKey.kt
@@ -2,6 +2,6 @@ package net.mullvad.mullvadvpn.compose.constant
object CommonContentKey {
const val DESCRIPTION = "description"
- const val SPACER = "spacer"
const val PROGRESS = "progress"
+ const val EMPTY = "empty"
}
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
new file mode 100644
index 0000000000..98f2007bc0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateCustomListDialog.kt
@@ -0,0 +1,132 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+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.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.theme.AppTheme
+import net.mullvad.mullvadvpn.model.CustomListsError
+import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogSideEffect
+import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Preview
+@Composable
+private fun PreviewCreateCustomListDialog() {
+ AppTheme { CreateCustomListDialog(state = CreateCustomListUiState()) }
+}
+
+@Preview
+@Composable
+private fun PreviewCreateCustomListDialogError() {
+ AppTheme {
+ CreateCustomListDialog(
+ state = CreateCustomListUiState(error = CustomListsError.CustomListExists)
+ )
+ }
+}
+
+@Composable
+@Destination(style = DestinationStyle.Dialog::class)
+fun CreateCustomList(
+ navigator: DestinationsNavigator,
+ backNavigator: ResultBackNavigator<CustomListResult.Created>,
+ locationCode: String = ""
+) {
+ val vm: CreateCustomListDialogViewModel =
+ koinViewModel(parameters = { parametersOf(locationCode) })
+ LaunchedEffect(key1 = Unit) {
+ vm.uiSideEffect.collect { sideEffect ->
+ when (sideEffect) {
+ is CreateCustomListDialogSideEffect.NavigateToCustomListLocationsScreen -> {
+ navigator.navigate(
+ CustomListLocationsDestination(
+ customListId = sideEffect.customListId,
+ newList = true
+ )
+ ) {
+ launchSingleTop = true
+ }
+ }
+ is CreateCustomListDialogSideEffect.ReturnWithResult -> {
+ backNavigator.navigateBack(result = sideEffect.result)
+ }
+ }
+ }
+ }
+ val state by vm.uiState.collectAsStateWithLifecycle()
+ CreateCustomListDialog(
+ state = state,
+ createCustomList = vm::createCustomList,
+ onInputChanged = vm::clearError,
+ onDismiss = backNavigator::navigateBack
+ )
+}
+
+@Composable
+fun CreateCustomListDialog(
+ state: CreateCustomListUiState,
+ createCustomList: (String) -> Unit = {},
+ onInputChanged: () -> Unit = {},
+ onDismiss: () -> Unit = {}
+) {
+
+ val name = remember { mutableStateOf("") }
+ val isValidName by remember { derivedStateOf { name.value.isNotBlank() } }
+
+ AlertDialog(
+ title = {
+ Text(
+ text = stringResource(id = R.string.create_new_list),
+ )
+ },
+ text = {
+ CustomListNameTextField(
+ name = name.value,
+ isValidName = isValidName,
+ error = state.error,
+ onSubmit = createCustomList,
+ onValueChanged = {
+ name.value = it
+ onInputChanged()
+ },
+ modifier = Modifier.testTag(CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG)
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ PrimaryButton(
+ text = stringResource(id = R.string.create),
+ onClick = { createCustomList(name.value) },
+ isEnabled = isValidName
+ )
+ },
+ dismissButton = {
+ PrimaryButton(text = stringResource(id = R.string.cancel), onClick = onDismiss)
+ }
+ )
+}
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
new file mode 100644
index 0000000000..236dedec6a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeleteCustomListConfirmationDialog.kt
@@ -0,0 +1,96 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+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.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.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.util.LaunchedEffectCollect
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationSideEffect
+import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationViewModel
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Preview
+@Composable
+private fun PreviewRemoveDeviceConfirmationDialog() {
+ AppTheme { DeleteCustomListConfirmationDialog("My Custom List") }
+}
+
+@Composable
+@Destination(style = DestinationStyle.Dialog::class)
+fun DeleteCustomList(
+ navigator: ResultBackNavigator<CustomListResult.Deleted>,
+ customListId: String,
+ name: String
+) {
+ val viewModel: DeleteCustomListConfirmationViewModel =
+ koinViewModel(parameters = { parametersOf(customListId) })
+
+ LaunchedEffectCollect(viewModel.uiSideEffect) {
+ when (it) {
+ is DeleteCustomListConfirmationSideEffect.ReturnWithResult ->
+ navigator.navigateBack(result = it.result)
+ }
+ }
+
+ DeleteCustomListConfirmationDialog(
+ name = name,
+ onDelete = viewModel::deleteCustomList,
+ onBack = navigator::navigateBack
+ )
+}
+
+@Composable
+fun DeleteCustomListConfirmationDialog(
+ name: String,
+ onDelete: () -> Unit = {},
+ onBack: () -> Unit = {}
+) {
+ AlertDialog(
+ onDismissRequest = onBack,
+ icon = {
+ Icon(
+ modifier = Modifier.fillMaxWidth().height(Dimens.dialogIconHeight),
+ painter = painterResource(id = R.drawable.icon_alert),
+ contentDescription = stringResource(id = R.string.remove_button),
+ tint = Color.Unspecified
+ )
+ },
+ title = {
+ Text(
+ text =
+ stringResource(id = R.string.delete_custom_list_confirmation_description, name)
+ )
+ },
+ dismissButton = {
+ PrimaryButton(
+ modifier = Modifier.focusRequester(FocusRequester()),
+ onClick = onBack,
+ text = stringResource(id = R.string.cancel)
+ )
+ },
+ confirmButton = {
+ NegativeButton(onClick = onDelete, text = stringResource(id = R.string.delete))
+ },
+ containerColor = MaterialTheme.colorScheme.background
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt
new file mode 100644
index 0000000000..06f2df0003
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt
@@ -0,0 +1,38 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+
+@Destination(style = DestinationStyle.Dialog::class)
+@Composable
+fun DiscardChangesDialog(resultBackNavigator: ResultBackNavigator<Boolean>) {
+ AlertDialog(
+ onDismissRequest = resultBackNavigator::navigateBack,
+ title = { Text(text = stringResource(id = R.string.discard_changes)) },
+ dismissButton = {
+ PrimaryButton(
+ modifier = Modifier.focusRequester(FocusRequester()),
+ onClick = resultBackNavigator::navigateBack,
+ text = stringResource(id = R.string.cancel)
+ )
+ },
+ confirmButton = {
+ PrimaryButton(
+ onClick = { resultBackNavigator.navigateBack(result = true) },
+ text = stringResource(id = R.string.discard)
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.background
+ )
+}
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
new file mode 100644
index 0000000000..9f46ee1d5a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/EditCustomListNameDialog.kt
@@ -0,0 +1,107 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import 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.PrimaryButton
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
+import net.mullvad.mullvadvpn.compose.component.CustomListNameTextField
+import net.mullvad.mullvadvpn.compose.state.UpdateCustomListUiState
+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.theme.AppTheme
+import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogSideEffect
+import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Preview
+@Composable
+private fun PreviewEditCustomListNameDialog() {
+ AppTheme { EditCustomListNameDialog(UpdateCustomListUiState()) }
+}
+
+@Composable
+@Destination(style = DestinationStyle.Dialog::class)
+fun EditCustomListName(
+ backNavigator: ResultBackNavigator<CustomListResult.Renamed>,
+ customListId: String,
+ initialName: String
+) {
+ val vm: EditCustomListNameDialogViewModel =
+ koinViewModel(parameters = { parametersOf(customListId, initialName) })
+ LaunchedEffectCollect(vm.uiSideEffect) { sideEffect ->
+ when (sideEffect) {
+ is EditCustomListNameDialogSideEffect.ReturnWithResult -> {
+ backNavigator.navigateBack(result = sideEffect.result)
+ }
+ }
+ }
+
+ val state by vm.uiState.collectAsStateWithLifecycle()
+ EditCustomListNameDialog(
+ state = state,
+ updateName = vm::updateCustomListName,
+ onInputChanged = vm::clearError,
+ onDismiss = backNavigator::navigateBack
+ )
+}
+
+@Composable
+fun EditCustomListNameDialog(
+ state: UpdateCustomListUiState,
+ updateName: (String) -> Unit = {},
+ onInputChanged: () -> Unit = {},
+ onDismiss: () -> Unit = {}
+) {
+ val name = remember { mutableStateOf(state.name) }
+ val isValidName by remember { derivedStateOf { name.value.isNotBlank() } }
+
+ AlertDialog(
+ title = {
+ Text(
+ text = stringResource(id = R.string.update_list_name),
+ )
+ },
+ text = {
+ CustomListNameTextField(
+ name = name.value,
+ isValidName = isValidName,
+ error = state.error,
+ onSubmit = updateName,
+ onValueChanged = {
+ name.value = it
+ onInputChanged()
+ },
+ modifier = Modifier.testTag(EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG)
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ PrimaryButton(
+ text = stringResource(id = R.string.save),
+ onClick = { updateName(name.value) },
+ isEnabled = isValidName
+ )
+ },
+ dismissButton = {
+ PrimaryButton(text = stringResource(id = R.string.cancel), onClick = onDismiss)
+ }
+ )
+}
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 9c48c0a1e8..a0270989cf 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
@@ -1,7 +1,7 @@
package net.mullvad.mullvadvpn.compose.dialog
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -13,7 +13,6 @@ 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.unit.dp
import androidx.compose.ui.unit.sp
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
@@ -25,6 +24,7 @@ import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.component.HtmlText
import net.mullvad.mullvadvpn.compose.component.textResource
import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.model.Device
@Preview
@@ -42,12 +42,12 @@ private fun PreviewRemoveDeviceConfirmationDialog() {
@Composable
fun RemoveDeviceConfirmationDialog(navigator: ResultBackNavigator<String>, device: Device) {
AlertDialog(
- onDismissRequest = { navigator.navigateBack() },
+ onDismissRequest = navigator::navigateBack,
icon = {
Icon(
+ modifier = Modifier.fillMaxWidth().height(Dimens.dialogIconHeight),
painter = painterResource(id = R.drawable.icon_alert),
- contentDescription = "Remove",
- modifier = Modifier.width(50.dp).height(50.dp),
+ contentDescription = stringResource(id = R.string.remove_button),
tint = Color.Unspecified
)
},
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
new file mode 100644
index 0000000000..8a418c17aa
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt
@@ -0,0 +1,18 @@
+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/screen/CustomListLocationsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt
new file mode 100644
index 0000000000..3bd924a189
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt
@@ -0,0 +1,219 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+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
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+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.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import 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 net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.cell.CheckableRelayLocationCell
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
+import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
+import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar
+import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
+import net.mullvad.mullvadvpn.compose.constant.CommonContentKey
+import net.mullvad.mullvadvpn.compose.constant.ContentType
+import net.mullvad.mullvadvpn.compose.destinations.DiscardChangesDialogDestination
+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.compose.textfield.SearchTextField
+import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
+import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect
+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
+import org.koin.core.parameter.parametersOf
+
+@Composable
+@Preview
+private fun PreviewCustomListLocationScreen() {
+ AppTheme { CustomListLocationsScreen(state = CustomListLocationsUiState.Content.Data()) }
+}
+
+@Composable
+@Destination(style = SlideInFromRightTransition::class)
+fun CustomListLocations(
+ navigator: DestinationsNavigator,
+ backNavigator: ResultBackNavigator<CustomListResult.LocationsChanged>,
+ discardChangesResultRecipient: ResultRecipient<DiscardChangesDialogDestination, Boolean>,
+ customListId: String,
+ newList: Boolean,
+) {
+ val customListsViewModel =
+ koinViewModel<CustomListLocationsViewModel>(
+ parameters = { parametersOf(customListId, newList) }
+ )
+
+ discardChangesResultRecipient.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {}
+ is NavResult.Value -> {
+ if (it.value) {
+ backNavigator.navigateBack()
+ }
+ }
+ }
+ }
+
+ LaunchedEffectCollect(customListsViewModel.uiSideEffect) { sideEffect ->
+ when (sideEffect) {
+ is CustomListLocationsSideEffect.ReturnWithResult ->
+ backNavigator.navigateBack(result = sideEffect.result)
+ CustomListLocationsSideEffect.CloseScreen -> backNavigator.navigateBack()
+ }
+ }
+
+ val state by customListsViewModel.uiState.collectAsStateWithLifecycle()
+ CustomListLocationsScreen(
+ state = state,
+ onSearchTermInput = customListsViewModel::onSearchTermInput,
+ onSaveClick = customListsViewModel::save,
+ onRelaySelectionClick = customListsViewModel::onRelaySelectionClick,
+ onBackClick = {
+ if (state.hasUnsavedChanges) {
+ navigator.navigate(DiscardChangesDialogDestination) { launchSingleTop = true }
+ } else {
+ backNavigator.navigateBack()
+ }
+ }
+ )
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun CustomListLocationsScreen(
+ state: CustomListLocationsUiState,
+ onSearchTermInput: (String) -> Unit = {},
+ onSaveClick: () -> Unit = {},
+ onRelaySelectionClick: (RelayItem, selected: Boolean) -> Unit = { _, _ -> },
+ onBackClick: () -> Unit = {}
+) {
+ ScaffoldWithSmallTopBar(
+ appBarTitle =
+ stringResource(
+ if (state.newList) {
+ R.string.add_locations
+ } else {
+ R.string.edit_locations
+ }
+ ),
+ navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
+ actions = { Actions(isSaveEnabled = state.saveEnabled, onSaveClick = onSaveClick) }
+ ) { modifier ->
+ Column(modifier = modifier) {
+ SearchTextField(
+ modifier =
+ Modifier.fillMaxWidth()
+ .height(Dimens.searchFieldHeight)
+ .padding(horizontal = Dimens.searchFieldHorizontalPadding),
+ backgroundColor = MaterialTheme.colorScheme.tertiaryContainer,
+ textColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ ) { searchString ->
+ onSearchTermInput.invoke(searchString)
+ }
+ Spacer(modifier = Modifier.height(Dimens.verticalSpace))
+ val lazyListState = rememberLazyListState()
+ LazyColumn(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier =
+ Modifier.drawVerticalScrollbar(
+ state = lazyListState,
+ color =
+ MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar)
+ )
+ .fillMaxWidth(),
+ state = lazyListState,
+ ) {
+ when (state) {
+ is CustomListLocationsUiState.Loading -> {
+ loading()
+ }
+ is CustomListLocationsUiState.Content.Empty -> {
+ empty(searchTerm = state.searchTerm)
+ }
+ is CustomListLocationsUiState.Content.Data -> {
+ content(uiState = state, onRelaySelectedChanged = onRelaySelectionClick)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Actions(isSaveEnabled: Boolean, onSaveClick: () -> Unit) {
+ TextButton(
+ onClick = onSaveClick,
+ enabled = isSaveEnabled,
+ colors =
+ ButtonDefaults.textButtonColors()
+ .copy(contentColor = MaterialTheme.colorScheme.onPrimary),
+ modifier = Modifier.testTag(SAVE_BUTTON_TEST_TAG)
+ ) {
+ Text(
+ text = stringResource(R.string.save),
+ )
+ }
+}
+
+private fun LazyListScope.loading() {
+ item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) {
+ MullvadCircularProgressIndicatorLarge(
+ modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)
+ )
+ }
+}
+
+private fun LazyListScope.empty(searchTerm: String) {
+ item(key = CommonContentKey.EMPTY, contentType = ContentType.EMPTY_TEXT) {
+ LocationsEmptyText(searchTerm = searchTerm)
+ }
+}
+
+private fun LazyListScope.content(
+ uiState: CustomListLocationsUiState.Content.Data,
+ onRelaySelectedChanged: (RelayItem, selected: Boolean) -> Unit,
+) {
+ items(
+ count = uiState.availableLocations.size,
+ key = { index -> uiState.availableLocations[index].hashCode() },
+ contentType = { ContentType.ITEM },
+ ) { index ->
+ val country = uiState.availableLocations[index]
+ CheckableRelayLocationCell(
+ relay = country,
+ modifier = Modifier.animateContentSize(),
+ onRelayCheckedChange = onRelaySelectedChanged,
+ 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
new file mode 100644
index 0000000000..20a92132f1
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt
@@ -0,0 +1,193 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarDuration
+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.runtime.rememberCoroutineScope
+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.platform.testTag
+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.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.result.NavResult
+import com.ramcosta.composedestinations.result.ResultRecipient
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
+import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.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.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
+
+@Preview
+@Composable
+private fun PreviewCustomListsScreen() {
+ AppTheme { CustomListsScreen(CustomListsUiState.Content(), SnackbarHostState()) }
+}
+
+@Composable
+@Destination(style = SlideInFromRightTransition::class)
+fun CustomLists(
+ navigator: DestinationsNavigator,
+ editCustomListResultRecipient:
+ ResultRecipient<EditCustomListDestination, CustomListResult.Deleted>
+) {
+ val viewModel = koinViewModel<CustomListsViewModel>()
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ editCustomListResultRecipient.onNavResult { result ->
+ when (result) {
+ NavResult.Canceled -> {
+ /* Do nothing */
+ }
+ is NavResult.Value -> {
+ scope.launch {
+ snackbarHostState.currentSnackbarData?.dismiss()
+ snackbarHostState.showSnackbar(
+ message =
+ context.getString(
+ R.string.delete_custom_list_message,
+ result.value.name
+ ),
+ actionLabel = context.getString(R.string.undo),
+ duration = SnackbarDuration.Long,
+ onAction = { viewModel.undoDeleteCustomList(result.value.undo) }
+ )
+ }
+ }
+ }
+ }
+
+ CustomListsScreen(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ addCustomList = {
+ navigator.navigate(
+ CreateCustomListDestination(),
+ ) {
+ launchSingleTop = true
+ }
+ },
+ openCustomList = { customList ->
+ navigator.navigate(EditCustomListDestination(customListId = customList.id)) {
+ launchSingleTop = true
+ }
+ },
+ onBackClick = navigator::navigateUp
+ )
+}
+
+@Composable
+fun CustomListsScreen(
+ state: CustomListsUiState,
+ snackbarHostState: SnackbarHostState,
+ addCustomList: () -> Unit = {},
+ openCustomList: (RelayItem.CustomList) -> Unit = {},
+ onBackClick: () -> Unit = {}
+) {
+ ScaffoldWithMediumTopBar(
+ appBarTitle = stringResource(id = R.string.edit_custom_lists),
+ navigationIcon = { NavigateBackIconButton(onBackClick) },
+ actions = {
+ IconButton(
+ onClick = addCustomList,
+ modifier = Modifier.testTag(NEW_LIST_BUTTON_TEST_TAG)
+ ) {
+ Icon(
+ painterResource(id = R.drawable.ic_icons_add),
+ tint =
+ MaterialTheme.colorScheme.onBackground
+ .copy(alpha = Alpha60)
+ .compositeOver(MaterialTheme.colorScheme.background),
+ contentDescription = stringResource(id = R.string.new_list)
+ )
+ }
+ },
+ snackbarHostState = snackbarHostState
+ ) { modifier: Modifier, lazyListState: LazyListState ->
+ LazyColumn(
+ modifier = modifier,
+ state = lazyListState,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ when (state) {
+ is CustomListsUiState.Content -> {
+ if (state.customLists.isNotEmpty()) {
+ content(customLists = state.customLists, openCustomList = openCustomList)
+ } else {
+ empty()
+ }
+ }
+ is CustomListsUiState.Loading -> {
+ loading()
+ }
+ }
+ }
+ }
+}
+
+private fun LazyListScope.loading() {
+ item(contentType = ContentType.PROGRESS) {
+ MullvadCircularProgressIndicatorLarge(
+ modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)
+ )
+ }
+}
+
+private fun LazyListScope.content(
+ customLists: List<RelayItem.CustomList>,
+ openCustomList: (RelayItem.CustomList) -> Unit
+) {
+ itemsWithDivider(
+ items = customLists,
+ key = { item: RelayItem.CustomList -> item.id },
+ contentType = { ContentType.ITEM }
+ ) { customList ->
+ NavigationComposeCell(title = customList.name, onClick = { openCustomList(customList) })
+ }
+}
+
+private fun LazyListScope.empty() {
+ item(contentType = ContentType.EMPTY_TEXT) {
+ Text(
+ text = stringResource(R.string.no_custom_lists_available),
+ modifier = Modifier.padding(Dimens.screenVerticalMargin),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ }
+}
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 6e762bbf43..e6402fc8bd 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
@@ -350,7 +350,7 @@ private fun DeviceListItem(
) {
BaseCell(
isRowEnabled = false,
- title = {
+ headlineContent = {
Column(modifier = Modifier.weight(1f)) {
Text(
modifier = Modifier.fillMaxWidth(),
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
new file mode 100644
index 0000000000..87ed88d263
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
@@ -0,0 +1,222 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+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.result.NavResult
+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.component.MullvadCircularProgressIndicatorLarge
+import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.component.SpacedColumn
+import net.mullvad.mullvadvpn.compose.destinations.CustomListLocationsDestination
+import net.mullvad.mullvadvpn.compose.destinations.DeleteCustomListDestination
+import net.mullvad.mullvadvpn.compose.destinations.EditCustomListNameDestination
+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.compose.transitions.SlideInFromRightTransition
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Preview
+@Composable
+private fun PreviewEditCustomListScreen() {
+ AppTheme {
+ EditCustomListScreen(
+ state =
+ EditCustomListState.Content(
+ id = "id",
+ name = "Custom list",
+ locations =
+ listOf(
+ RelayItem.Relay(
+ "Relay",
+ "Relay",
+ true,
+ GeographicLocationConstraint.Hostname(
+ "hostname",
+ "hostname",
+ "hostname"
+ )
+ )
+ )
+ )
+ )
+ }
+}
+
+@Composable
+@Destination(style = SlideInFromRightTransition::class)
+fun EditCustomList(
+ navigator: DestinationsNavigator,
+ backNavigator: ResultBackNavigator<CustomListResult.Deleted>,
+ customListId: String,
+ confirmDeleteListResultRecipient:
+ ResultRecipient<DeleteCustomListDestination, CustomListResult.Deleted>
+) {
+ val viewModel =
+ koinViewModel<EditCustomListViewModel>(parameters = { parametersOf(customListId) })
+
+ confirmDeleteListResultRecipient.onNavResult {
+ when (it) {
+ NavResult.Canceled -> {
+ // Do nothing
+ }
+ is NavResult.Value -> backNavigator.navigateBack(result = it.value)
+ }
+ }
+
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ EditCustomListScreen(
+ state = state,
+ onDeleteList = { name ->
+ navigator.navigate(
+ DeleteCustomListDestination(customListId = customListId, name = name)
+ ) {
+ launchSingleTop = true
+ }
+ },
+ onNameClicked = { id, name ->
+ navigator.navigate(
+ EditCustomListNameDestination(customListId = id, initialName = name)
+ ) {
+ launchSingleTop = true
+ }
+ },
+ onLocationsClicked = {
+ navigator.navigate(CustomListLocationsDestination(customListId = it, newList = false)) {
+ launchSingleTop = true
+ }
+ },
+ onBackClick = backNavigator::navigateBack
+ )
+}
+
+@Composable
+fun EditCustomListScreen(
+ state: EditCustomListState,
+ onDeleteList: (name: String) -> Unit = {},
+ onNameClicked: (id: String, name: String) -> Unit = { _, _ -> },
+ onLocationsClicked: (String) -> Unit = {},
+ onBackClick: () -> Unit = {}
+) {
+ val title =
+ when (state) {
+ EditCustomListState.Loading,
+ EditCustomListState.NotFound -> ""
+ is EditCustomListState.Content -> state.name
+ }
+ ScaffoldWithMediumTopBar(
+ appBarTitle = stringResource(id = R.string.edit_list),
+ navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
+ actions = { Actions(onDeleteList = { onDeleteList(title) }) },
+ ) { modifier: Modifier ->
+ SpacedColumn(modifier = modifier, alignment = Alignment.Top) {
+ when (state) {
+ EditCustomListState.Loading -> {
+ MullvadCircularProgressIndicatorLarge(
+ modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)
+ )
+ }
+ EditCustomListState.NotFound -> {
+ Text(
+ text = stringResource(id = R.string.not_found),
+ modifier = Modifier.padding(Dimens.screenVerticalMargin),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ }
+ is EditCustomListState.Content -> {
+ // Name cell
+ TwoRowCell(
+ titleText = stringResource(id = R.string.list_name),
+ subtitleText = state.name,
+ onCellClicked = { onNameClicked(state.id, state.name) }
+ )
+ // Locations cell
+ TwoRowCell(
+ titleText = stringResource(id = R.string.locations),
+ subtitleText =
+ pluralStringResource(
+ id = R.plurals.number_of_locations,
+ state.locations.size,
+ state.locations.size
+ ),
+ onCellClicked = { onLocationsClicked(state.id) }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Actions(onDeleteList: () -> Unit) {
+ var showMenu by remember { mutableStateOf(false) }
+ IconButton(
+ onClick = { showMenu = true },
+ modifier = Modifier.testTag(TOP_BAR_DROPDOWN_BUTTON_TEST_TAG)
+ ) {
+ Icon(painter = painterResource(id = R.drawable.icon_more_vert), contentDescription = null)
+ if (showMenu) {
+ DropdownMenu(
+ expanded = true,
+ onDismissRequest = { showMenu = false },
+ modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
+ ) {
+ DropdownMenuItem(
+ text = { Text(text = stringResource(id = R.string.delete_list)) },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_delete),
+ contentDescription = null,
+ )
+ },
+ colors =
+ MenuDefaults.itemColors()
+ .copy(
+ leadingIconColor = MaterialTheme.colorScheme.onSurface,
+ textColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ onClick = {
+ onDeleteList()
+ showMenu = false
+ },
+ modifier = Modifier.testTag(DELETE_DROPDOWN_MENU_ITEM_TEST_TAG)
+ )
+ }
+ }
+ }
+}
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 1f17da8bc5..594c657cdb 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt
@@ -1,6 +1,8 @@
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
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Column
@@ -13,49 +15,81 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
-import androidx.core.text.HtmlCompat
import 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 com.ramcosta.composedestinations.spec.DestinationSpec
+import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.cell.FilterCell
-import net.mullvad.mullvadvpn.compose.cell.RelayLocationCell
+import net.mullvad.mullvadvpn.compose.cell.HeaderCell
+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.CustomListAction
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
+import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
+import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet
+import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
-import net.mullvad.mullvadvpn.compose.component.textResource
import net.mullvad.mullvadvpn.compose.constant.ContentType
+import net.mullvad.mullvadvpn.compose.destinations.CreateCustomListDestination
+import net.mullvad.mullvadvpn.compose.destinations.CustomListLocationsDestination
+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.toAnnotatedString
-import net.mullvad.mullvadvpn.compose.state.RelayListState
+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
+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.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.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
import org.koin.androidx.compose.koinViewModel
@@ -64,16 +98,14 @@ import org.koin.androidx.compose.koinViewModel
@Composable
private fun PreviewSelectLocationScreen() {
val state =
- SelectLocationUiState.Data(
+ SelectLocationUiState.Content(
searchTerm = "",
selectedOwnership = null,
selectedProvidersCount = 0,
- relayListState =
- RelayListState.RelayList(
- countries =
- listOf(RelayItem.Country("Country 1", "Code 1", false, emptyList())),
- selectedItem = null,
- )
+ countries = listOf(RelayItem.Country("Country 1", "Code 1", false, emptyList())),
+ selectedItem = null,
+ customLists = emptyList(),
+ filteredCustomLists = emptyList()
)
AppTheme {
SelectLocationScreen(
@@ -84,23 +116,84 @@ private fun PreviewSelectLocationScreen() {
@Destination(style = SelectLocationTransition::class)
@Composable
-fun SelectLocation(navigator: DestinationsNavigator) {
+fun SelectLocation(
+ navigator: DestinationsNavigator,
+ createCustomListDialogResultRecipient:
+ ResultRecipient<CreateCustomListDestination, CustomListResult.Created>,
+ editCustomListNameDialogResultRecipient:
+ ResultRecipient<EditCustomListNameDestination, CustomListResult.Renamed>,
+ deleteCustomListDialogResultRecipient:
+ ResultRecipient<DeleteCustomListDestination, CustomListResult.Deleted>,
+ updateCustomListResultRecipient:
+ ResultRecipient<CustomListLocationsDestination, CustomListResult.LocationsChanged>
+) {
val vm = koinViewModel<SelectLocationViewModel>()
- val state by vm.uiState.collectAsStateWithLifecycle()
+ val state = vm.uiState.collectAsStateWithLifecycle().value
+
+ val snackbarHostState = remember { SnackbarHostState() }
+ val context = LocalContext.current
+
LaunchedEffectCollect(vm.uiSideEffect) {
when (it) {
SelectLocationSideEffect.CloseScreen -> navigator.navigateUp()
+ is SelectLocationSideEffect.LocationAddedToCustomList -> {
+ launch {
+ snackbarHostState.showResultSnackbar(
+ context = context,
+ result = it.result,
+ onUndo = vm::performAction
+ )
+ }
+ }
}
}
+ createCustomListDialogResultRecipient.OnCustomListNavResult(
+ snackbarHostState,
+ vm::performAction
+ )
+
+ editCustomListNameDialogResultRecipient.OnCustomListNavResult(
+ snackbarHostState,
+ vm::performAction
+ )
+
+ deleteCustomListDialogResultRecipient.OnCustomListNavResult(
+ snackbarHostState,
+ vm::performAction
+ )
+
+ updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction)
+
SelectLocationScreen(
state = state,
+ snackbarHostState = snackbarHostState,
onSelectRelay = vm::selectRelay,
onSearchTermInput = vm::onSearchTermInput,
onBackClick = navigator::navigateUp,
onFilterClick = { navigator.navigate(FilterScreenDestination, true) },
+ onCreateCustomList = { relayItem ->
+ navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.code ?: "")) {
+ launchSingleTop = true
+ }
+ },
+ onEditCustomLists = { navigator.navigate(CustomListsDestination()) },
removeOwnershipFilter = vm::removeOwnerFilter,
- removeProviderFilter = vm::removeProviderFilter
+ removeProviderFilter = vm::removeProviderFilter,
+ onAddLocationToList = vm::addLocationToList,
+ onEditCustomListName = {
+ navigator.navigate(
+ EditCustomListNameDestination(customListId = it.id, initialName = it.name)
+ )
+ },
+ onEditLocationsCustomList = {
+ navigator.navigate(
+ CustomListLocationsDestination(customListId = it.id, newList = false)
+ )
+ },
+ onDeleteCustomList = {
+ navigator.navigate(DeleteCustomListDestination(customListId = it.id, name = it.name))
+ }
)
}
@@ -108,16 +201,43 @@ fun SelectLocation(navigator: DestinationsNavigator) {
@Composable
fun SelectLocationScreen(
state: SelectLocationUiState,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onSelectRelay: (item: RelayItem) -> Unit = {},
onSearchTermInput: (searchTerm: String) -> Unit = {},
onBackClick: () -> Unit = {},
onFilterClick: () -> Unit = {},
+ onCreateCustomList: (location: RelayItem?) -> Unit = {},
+ onEditCustomLists: () -> Unit = {},
removeOwnershipFilter: () -> Unit = {},
- removeProviderFilter: () -> Unit = {}
+ removeProviderFilter: () -> Unit = {},
+ onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit = { _, _ ->
+ },
+ onEditCustomListName: (RelayItem.CustomList) -> Unit = {},
+ onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {},
+ onDeleteCustomList: (RelayItem.CustomList) -> Unit = {}
) {
val backgroundColor = MaterialTheme.colorScheme.background
- Scaffold {
+ Scaffold(
+ snackbarHost = {
+ SnackbarHost(
+ snackbarHostState,
+ snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) }
+ )
+ }
+ ) {
+ var bottomSheetState by remember { mutableStateOf<BottomSheetState?>(null) }
+ BottomSheets(
+ bottomSheetState = bottomSheetState,
+ onCreateCustomList = onCreateCustomList,
+ onEditCustomLists = onEditCustomLists,
+ onAddLocationToList = onAddLocationToList,
+ onEditCustomListName = onEditCustomListName,
+ onEditLocationsCustomList = onEditLocationsCustomList,
+ onDeleteCustomList = onDeleteCustomList,
+ onHideBottomSheet = { bottomSheetState = null }
+ )
+
Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = onBackClick) {
@@ -133,7 +253,7 @@ fun SelectLocationScreen(
modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onPrimary
+ color = MaterialTheme.colorScheme.onPrimary,
)
IconButton(onClick = onFilterClick) {
Icon(
@@ -146,13 +266,13 @@ fun SelectLocationScreen(
when (state) {
SelectLocationUiState.Loading -> {}
- is SelectLocationUiState.Data -> {
+ is SelectLocationUiState.Content -> {
if (state.hasFilter) {
FilterCell(
ownershipFilter = state.selectedOwnership,
selectedProviderFilter = state.selectedProvidersCount,
removeOwnershipFilter = removeOwnershipFilter,
- removeProviderFilter = removeProviderFilter
+ removeProviderFilter = removeProviderFilter,
)
}
}
@@ -164,24 +284,20 @@ fun SelectLocationScreen(
.height(Dimens.searchFieldHeight)
.padding(horizontal = Dimens.searchFieldHorizontalPadding),
backgroundColor = MaterialTheme.colorScheme.tertiaryContainer,
- textColor = MaterialTheme.colorScheme.onTertiaryContainer
+ textColor = MaterialTheme.colorScheme.onTertiaryContainer,
) { searchString ->
onSearchTermInput.invoke(searchString)
}
Spacer(modifier = Modifier.height(height = Dimens.verticalSpace))
val lazyListState = rememberLazyListState()
- if (
- state is SelectLocationUiState.Data &&
- state.relayListState is RelayListState.RelayList &&
- state.relayListState.selectedItem != null
- ) {
- LaunchedEffect(state.relayListState.selectedItem) {
- val index = state.relayListState.indexOfSelectedRelayItem()
+ val selectedItemCode =
+ (state as? SelectLocationUiState.Content)?.selectedItem?.code ?: ""
+ RunOnKeyChange(key = selectedItemCode) {
+ val index = state.indexOfSelectedRelayItem()
- if (index >= 0) {
- lazyListState.scrollToItem(index)
- lazyListState.animateScrollAndCentralizeItem(index)
- }
+ if (index >= 0) {
+ lazyListState.scrollToItem(index)
+ lazyListState.animateScrollAndCentralizeItem(index)
}
}
LazyColumn(
@@ -189,7 +305,7 @@ fun SelectLocationScreen(
Modifier.fillMaxSize()
.drawVerticalScrollbar(
lazyListState,
- MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar)
+ MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar),
),
state = lazyListState,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -198,12 +314,42 @@ fun SelectLocationScreen(
SelectLocationUiState.Loading -> {
loading()
}
- is SelectLocationUiState.Data -> {
- relayList(
- relayListState = state.relayListState,
- searchTerm = state.searchTerm,
- onSelectRelay = onSelectRelay
- )
+ is SelectLocationUiState.Content -> {
+ if (state.showCustomLists) {
+ customLists(
+ customLists = state.filteredCustomLists,
+ selectedItem = state.selectedItem,
+ onSelectRelay = onSelectRelay,
+ onShowCustomListBottomSheet = {
+ bottomSheetState =
+ BottomSheetState.ShowCustomListsBottomSheet(
+ state.customLists.isNotEmpty()
+ )
+ },
+ onShowEditBottomSheet = { customList ->
+ bottomSheetState =
+ BottomSheetState.ShowEditCustomListBottomSheet(customList)
+ }
+ )
+ item { Spacer(modifier = Modifier.height(Dimens.mediumPadding)) }
+ }
+ if (state.countries.isNotEmpty()) {
+ relayList(
+ countries = state.countries,
+ selectedItem = state.selectedItem,
+ onSelectRelay = onSelectRelay,
+ onShowLocationBottomSheet = { location ->
+ bottomSheetState =
+ BottomSheetState.ShowLocationBottomSheet(
+ customLists = state.customLists,
+ item = location
+ )
+ }
+ )
+ }
+ if (state.showEmpty) {
+ item { LocationsEmptyText(searchTerm = state.searchTerm) }
+ }
}
}
}
@@ -217,78 +363,337 @@ private fun LazyListScope.loading() {
}
}
-private fun LazyListScope.relayList(
- relayListState: RelayListState,
- searchTerm: String,
- onSelectRelay: (item: RelayItem) -> Unit
+@OptIn(ExperimentalFoundationApi::class)
+private fun LazyListScope.customLists(
+ customLists: List<RelayItem.CustomList>,
+ selectedItem: RelayItem?,
+ onSelectRelay: (item: RelayItem) -> Unit,
+ onShowCustomListBottomSheet: () -> Unit,
+ onShowEditBottomSheet: (RelayItem.CustomList) -> Unit
) {
- when (relayListState) {
- is RelayListState.RelayList -> {
- items(
- count = relayListState.countries.size,
- key = { index -> relayListState.countries[index].hashCode() },
- contentType = { ContentType.ITEM }
- ) { index ->
- val country = relayListState.countries[index]
- RelayLocationCell(
- relay = country,
- selectedItem = relayListState.selectedItem,
- onSelectRelay = onSelectRelay,
- modifier = Modifier.animateContentSize()
- )
- }
+ item(
+ contentType = { ContentType.HEADER },
+ ) {
+ ThreeDotCell(
+ text = stringResource(R.string.custom_lists),
+ onClickDots = onShowCustomListBottomSheet,
+ modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG)
+ )
+ }
+ if (customLists.isNotEmpty()) {
+ items(
+ items = customLists,
+ key = { item -> item.code },
+ contentType = { ContentType.ITEM },
+ ) { customList ->
+ StatusRelayLocationCell(
+ relay = customList,
+ // Do not show selection for locations in custom lists
+ selectedItem = selectedItem as? RelayItem.CustomList,
+ onSelectRelay = onSelectRelay,
+ onLongClick = {
+ if (it is RelayItem.CustomList) {
+ onShowEditBottomSheet(it)
+ }
+ },
+ modifier = Modifier.animateContentSize().animateItemPlacement(),
+ )
}
- RelayListState.Empty -> {
- if (searchTerm.isNotEmpty())
- item(contentType = ContentType.EMPTY_TEXT) {
- val firstRow =
- HtmlCompat.fromHtml(
- textResource(
- id = R.string.select_location_empty_text_first_row,
- searchTerm
- ),
- HtmlCompat.FROM_HTML_MODE_COMPACT
- )
- .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold)
- val secondRow =
- textResource(id = R.string.select_location_empty_text_second_row)
- Column(
- modifier = Modifier.padding(horizontal = Dimens.selectLocationTitlePadding),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = firstRow,
- style = MaterialTheme.typography.labelMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSecondary,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = secondRow,
- style = MaterialTheme.typography.labelMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSecondary
- )
+ item {
+ SwitchComposeSubtitleCell(text = stringResource(R.string.to_add_locations_to_a_list))
+ }
+ } else {
+ item(contentType = ContentType.EMPTY_TEXT) {
+ SwitchComposeSubtitleCell(text = stringResource(R.string.to_create_a_custom_list))
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+private fun LazyListScope.relayList(
+ countries: List<RelayItem.Country>,
+ selectedItem: RelayItem?,
+ onSelectRelay: (item: RelayItem) -> Unit,
+ onShowLocationBottomSheet: (item: RelayItem) -> Unit,
+) {
+ item(
+ contentType = ContentType.HEADER,
+ ) {
+ HeaderCell(
+ text = stringResource(R.string.all_locations),
+ )
+ }
+ items(
+ items = countries,
+ key = { item -> item.code },
+ contentType = { ContentType.ITEM },
+ ) { country ->
+ StatusRelayLocationCell(
+ relay = country,
+ selectedItem = selectedItem,
+ onSelectRelay = onSelectRelay,
+ onLongClick = onShowLocationBottomSheet,
+ modifier = Modifier.animateContentSize().animateItemPlacement(),
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun BottomSheets(
+ bottomSheetState: BottomSheetState?,
+ onCreateCustomList: (RelayItem?) -> Unit,
+ onEditCustomLists: () -> Unit,
+ onAddLocationToList: (RelayItem, RelayItem.CustomList) -> Unit,
+ onEditCustomListName: (RelayItem.CustomList) -> Unit,
+ onEditLocationsCustomList: (RelayItem.CustomList) -> Unit,
+ onDeleteCustomList: (RelayItem.CustomList) -> Unit,
+ onHideBottomSheet: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val scope = rememberCoroutineScope()
+ val onCloseBottomSheet: (animate: Boolean) -> Unit = { animate ->
+ if (animate) {
+ scope
+ .launch { sheetState.hide() }
+ .invokeOnCompletion {
+ if (!sheetState.isVisible) {
+ onHideBottomSheet()
}
}
+ } else {
+ onHideBottomSheet()
+ }
+ }
+ val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface
+
+ when (bottomSheetState) {
+ is BottomSheetState.ShowCustomListsBottomSheet -> {
+ CustomListsBottomSheet(
+ sheetState = sheetState,
+ onBackgroundColor = onBackgroundColor,
+ bottomSheetState = bottomSheetState,
+ onCreateCustomList = { onCreateCustomList(null) },
+ onEditCustomLists = onEditCustomLists,
+ closeBottomSheet = onCloseBottomSheet
+ )
+ }
+ is BottomSheetState.ShowLocationBottomSheet -> {
+ LocationBottomSheet(
+ sheetState = sheetState,
+ onBackgroundColor = onBackgroundColor,
+ customLists = bottomSheetState.customLists,
+ item = bottomSheetState.item,
+ onCreateCustomList = onCreateCustomList,
+ onAddLocationToList = onAddLocationToList,
+ closeBottomSheet = onCloseBottomSheet
+ )
+ }
+ is BottomSheetState.ShowEditCustomListBottomSheet -> {
+ EditCustomListBottomSheet(
+ sheetState = sheetState,
+ onBackgroundColor = onBackgroundColor,
+ customList = bottomSheetState.customList,
+ onEditName = onEditCustomListName,
+ onEditLocations = onEditLocationsCustomList,
+ onDeleteCustomList = onDeleteCustomList,
+ closeBottomSheet = onCloseBottomSheet
+ )
+ }
+ null -> {
+ /* Do nothing */
}
}
}
-private fun RelayListState.RelayList.indexOfSelectedRelayItem(): Int =
- countries.indexOfFirst { relayCountry ->
- relayCountry.location.location.country ==
- when (selectedItem) {
- is RelayItem.Country -> selectedItem.code
- is RelayItem.City -> selectedItem.location.countryCode
- is RelayItem.Relay -> selectedItem.location.countryCode
- is RelayItem.CustomList,
- null -> null
- }
+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() } +
+ 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")
}
-suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) {
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CustomListsBottomSheet(
+ onBackgroundColor: Color,
+ sheetState: SheetState,
+ bottomSheetState: BottomSheetState.ShowCustomListsBottomSheet,
+ onCreateCustomList: () -> Unit,
+ onEditCustomLists: () -> Unit,
+ closeBottomSheet: (animate: Boolean) -> Unit
+) {
+ MullvadModalBottomSheet(
+ sheetState = sheetState,
+ closeBottomSheet = { 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
+ )
+ HorizontalDivider(color = onBackgroundColor)
+ IconCell(
+ iconId = R.drawable.icon_add,
+ title = stringResource(id = R.string.new_list),
+ onClick = {
+ onCreateCustomList()
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ IconCell(
+ iconId = R.drawable.icon_edit,
+ title = stringResource(id = R.string.edit_lists),
+ onClick = {
+ onEditCustomLists()
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor =
+ onBackgroundColor.copy(
+ alpha =
+ if (bottomSheetState.editListEnabled) {
+ AlphaVisible
+ } else {
+ AlphaInactive
+ }
+ ),
+ enabled = bottomSheetState.editListEnabled
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun LocationBottomSheet(
+ onBackgroundColor: Color,
+ sheetState: SheetState,
+ customLists: List<RelayItem.CustomList>,
+ item: RelayItem,
+ onCreateCustomList: (relayItem: RelayItem) -> Unit,
+ onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit,
+ closeBottomSheet: (animate: Boolean) -> Unit
+) {
+ MullvadModalBottomSheet(
+ sheetState = sheetState,
+ closeBottomSheet = { closeBottomSheet(false) },
+ modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG)
+ ) { ->
+ HeaderCell(
+ text = stringResource(id = R.string.add_location_to_list, item.name),
+ background = Color.Unspecified
+ )
+ HorizontalDivider(color = onBackgroundColor)
+ customLists.forEach {
+ val enabled = it.canAddLocation(item)
+ IconCell(
+ background = Color.Unspecified,
+ titleColor =
+ if (enabled) {
+ onBackgroundColor
+ } else {
+ MaterialTheme.colorScheme.onSecondary
+ },
+ iconId = null,
+ title =
+ if (enabled) {
+ it.name
+ } else {
+ stringResource(id = R.string.location_added, it.name)
+ },
+ onClick = {
+ onAddLocationToList(item, it)
+ closeBottomSheet(true)
+ },
+ enabled = enabled
+ )
+ }
+ IconCell(
+ iconId = R.drawable.icon_add,
+ title = stringResource(id = R.string.new_list),
+ onClick = {
+ onCreateCustomList(item)
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun EditCustomListBottomSheet(
+ onBackgroundColor: Color,
+ sheetState: SheetState,
+ customList: RelayItem.CustomList,
+ onEditName: (item: RelayItem.CustomList) -> Unit,
+ onEditLocations: (item: RelayItem.CustomList) -> Unit,
+ onDeleteCustomList: (item: RelayItem.CustomList) -> Unit,
+ closeBottomSheet: (animate: Boolean) -> Unit
+) {
+ MullvadModalBottomSheet(
+ sheetState = sheetState,
+ closeBottomSheet = { closeBottomSheet(false) }
+ ) {
+ HeaderCell(text = customList.name, background = Color.Unspecified)
+ IconCell(
+ iconId = R.drawable.icon_edit,
+ title = stringResource(id = R.string.edit_name),
+ onClick = {
+ onEditName(customList)
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ IconCell(
+ iconId = R.drawable.icon_add,
+ title = stringResource(id = R.string.edit_locations),
+ onClick = {
+ onEditLocations(customList)
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ HorizontalDivider(color = onBackgroundColor)
+ IconCell(
+ iconId = R.drawable.icon_delete,
+ title = stringResource(id = R.string.delete),
+ onClick = {
+ onDeleteCustomList(customList)
+ closeBottomSheet(true)
+ },
+ background = Color.Unspecified,
+ titleColor = onBackgroundColor
+ )
+ }
+}
+
+private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) {
val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
if (itemInfo != null) {
val center = layoutInfo.viewportEndOffset / 2
@@ -298,3 +703,71 @@ suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) {
animateScrollToItem(index)
}
}
+
+private suspend fun SnackbarHostState.showResultSnackbar(
+ context: Context,
+ result: CustomListResult,
+ onUndo: (CustomListAction) -> Unit
+) {
+ currentSnackbarData?.dismiss()
+ showSnackbar(
+ message = result.message(context),
+ actionLabel = context.getString(R.string.undo),
+ duration = SnackbarDuration.Long,
+ onAction = { onUndo(result.undo) }
+ )
+}
+
+private fun CustomListResult.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)
+ }
+
+@Composable
+private fun <D : DestinationSpec<*>, R : CustomListResult> ResultRecipient<D, R>
+ .OnCustomListNavResult(
+ snackbarHostState: SnackbarHostState,
+ performAction: (action: CustomListAction) -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ this.onNavResult { result ->
+ when (result) {
+ NavResult.Canceled -> {
+ /* Do nothing */
+ }
+ is NavResult.Value -> {
+ // Handle result
+ scope.launch {
+ snackbarHostState.showResultSnackbar(
+ context = context,
+ result = result.value,
+ onUndo = performAction
+ )
+ }
+ }
+ }
+ }
+}
+
+private const val EXTRA_ITEMS_LOCATION =
+ 4 // Custom lists header, custom lists description, spacer, all locations header
+private const val EXTRA_ITEM_CUSTOM_LIST = 1 // Custom lists header
+
+sealed interface BottomSheetState {
+
+ data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : BottomSheetState
+
+ data class ShowLocationBottomSheet(
+ val customLists: List<RelayItem.CustomList>,
+ val item: RelayItem
+ ) : BottomSheetState
+
+ data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) :
+ BottomSheetState
+}
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 403b1bb57e..bd8809b00f 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
@@ -468,7 +468,7 @@ fun VpnSettingsScreen(
itemWithDivider {
BaseCell(
onCellClicked = { navigateToDns(null, null) },
- title = {
+ headlineContent = {
Text(
text = stringResource(id = R.string.add_a_server),
color = Color.White,
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
new file mode 100644
index 0000000000..43052702bd
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CreateCustomListUiState.kt
@@ -0,0 +1,5 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.model.CustomListsError
+
+data class CreateCustomListUiState(val error: CustomListsError? = 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
new file mode 100644
index 0000000000..7c9c5aedec
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListLocationsUiState.kt
@@ -0,0 +1,32 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+
+sealed interface CustomListLocationsUiState {
+ val newList: Boolean
+ val saveEnabled: Boolean
+ val hasUnsavedChanges: Boolean
+
+ data class Loading(override val newList: Boolean = false) : CustomListLocationsUiState {
+ override val saveEnabled: Boolean = false
+ override val hasUnsavedChanges: Boolean = false
+ }
+
+ sealed interface Content : CustomListLocationsUiState {
+ val searchTerm: String
+
+ data class Empty(override val newList: Boolean, override val searchTerm: String) : Content {
+ override val saveEnabled: Boolean = false
+ override val hasUnsavedChanges: Boolean = false
+ }
+
+ data class Data(
+ override val newList: Boolean = false,
+ val availableLocations: List<RelayItem.Country> = emptyList(),
+ val selectedLocations: Set<RelayItem> = emptySet(),
+ override val searchTerm: String = "",
+ override val saveEnabled: Boolean = false,
+ override val hasUnsavedChanges: Boolean = false
+ ) : Content
+ }
+}
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
new file mode 100644
index 0000000000..f055bf95d2
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/CustomListsUiState.kt
@@ -0,0 +1,10 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+
+interface CustomListsUiState {
+ object Loading : CustomListsUiState
+
+ data class Content(val customLists: List<RelayItem.CustomList> = emptyList()) :
+ CustomListsUiState
+}
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
new file mode 100644
index 0000000000..9b564bb407
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/EditCustomListState.kt
@@ -0,0 +1,12 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+
+sealed interface EditCustomListState {
+ data object Loading : EditCustomListState
+
+ data object NotFound : EditCustomListState
+
+ data class Content(val id: String, val name: String, val locations: List<RelayItem>) :
+ EditCustomListState
+}
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 fd775fa1bb..747e21d91c 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,25 +1,27 @@
package net.mullvad.mullvadvpn.compose.state
import net.mullvad.mullvadvpn.model.Ownership
+import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH
import net.mullvad.mullvadvpn.relaylist.RelayItem
sealed interface SelectLocationUiState {
data object Loading : SelectLocationUiState
- data class Data(
+ data class Content(
val searchTerm: String,
val selectedOwnership: Ownership?,
val selectedProvidersCount: Int?,
- val relayListState: RelayListState
+ val filteredCustomLists: List<RelayItem.CustomList>,
+ val customLists: List<RelayItem.CustomList>,
+ val countries: List<RelayItem.Country>,
+ val selectedItem: RelayItem?
) : SelectLocationUiState {
val hasFilter: Boolean = (selectedProvidersCount != null || selectedOwnership != null)
+ val inSearch = searchTerm.length >= MIN_SEARCH_LENGTH
+ val showCustomLists = inSearch.not() || filteredCustomLists.isNotEmpty()
+ // Show empty state if we don't have any relays or if we are searching and no custom list or
+ // relay is found
+ val showEmpty = countries.isEmpty() && (inSearch.not() || filteredCustomLists.isEmpty())
}
}
-
-sealed interface RelayListState {
- data object Empty : RelayListState
-
- data class RelayList(val countries: List<RelayItem.Country>, val selectedItem: RelayItem?) :
- RelayListState
-}
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
new file mode 100644
index 0000000000..7eac74a40a
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/UpdateCustomListUiState.kt
@@ -0,0 +1,5 @@
+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/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
index 996f610404..efd8e34250 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
@@ -16,7 +16,7 @@ const val LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG =
"lazy_list_wireguard_custom_port_number_test_tag"
const val CUSTOM_PORT_DIALOG_INPUT_TEST_TAG = "custom_port_dialog_input_test_tag"
-// SelectLocationScreen, ConnectScreen
+// SelectLocationScreen, ConnectScreen, CustomListLocationsScreen
const val CIRCULAR_PROGRESS_INDICATOR = "circular_progress_indicator"
// ConnectScreen
@@ -42,3 +42,25 @@ const val VOUCHER_INPUT_TEST_TAG = "voucher_input_test_tag"
// OutOfTimeScreen
const val OUT_OF_TIME_SCREEN_TITLE_TEST_TAG = "out_of_time_screen_title_test_tag"
+
+// CreateCustomListDialog
+const val CREATE_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG = "create_custom_list_dialog_input_test_tag"
+
+// UpdateCustomListDialog
+const val EDIT_CUSTOM_LIST_DIALOG_INPUT_TEST_TAG = "edit_custom_list_dialog_input_test_tag"
+
+// CustomListLocationsScreen
+const val SAVE_BUTTON_TEST_TAG = "save_button_test_tag"
+
+// CustomListsScreen
+const val NEW_LIST_BUTTON_TEST_TAG = "new_list_button_test_tag"
+const val TOP_BAR_DROPDOWN_BUTTON_TEST_TAG = "top_bar_dropdown_button_test_tag"
+const val DELETE_DROPDOWN_MENU_ITEM_TEST_TAG = "delete_dropdown_menu_item_test_tag"
+
+// SelectLocationScreen
+const val SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG =
+ "select_location_custom_list_header_test_tag"
+const val SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG =
+ "select_location_custom_list_bottom_sheet_test_tag"
+const val SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG =
+ "select_location_location_bottom_sheet_test_tag"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt
index 4b3817b441..be5750ef5c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt
@@ -38,7 +38,8 @@ fun CustomTextField(
maxCharLength: Int = Int.MAX_VALUE,
isValidValue: Boolean,
isDigitsOnlyAllowed: Boolean,
- visualTransformation: VisualTransformation = VisualTransformation.None
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ supportingText: @Composable (() -> Unit)? = null,
) {
val scope = rememberCoroutineScope()
@@ -102,6 +103,7 @@ fun CustomTextField(
visualTransformation = visualTransformation,
colors = mullvadDarkTextFieldColors(),
isError = !isValidValue,
- modifier = modifier.clip(MaterialTheme.shapes.small).fillMaxWidth()
+ modifier = modifier.clip(MaterialTheme.shapes.small).fillMaxWidth(),
+ supportingText = supportingText
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt
index 42d5f6caa0..c8a0847e89 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt
@@ -2,11 +2,14 @@ package net.mullvad.mullvadvpn.compose.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
@Composable
inline fun <T> LaunchedEffectCollect(
@@ -34,3 +37,12 @@ inline fun <T> CollectSideEffectWithLifecycle(
}
}
}
+
+@Composable
+fun RunOnKeyChange(key: Any, block: suspend CoroutineScope.() -> Unit) {
+ val scope = rememberCoroutineScope()
+ rememberSaveable(key) {
+ scope.launch { block() }
+ key
+ }
+}
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
new file mode 100644
index 0000000000..61b563564c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt
@@ -0,0 +1,78 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+
+fun generateRelayItemCountry(
+ name: String,
+ cityNames: List<String>,
+ relaysPerCity: Int,
+ active: Boolean = true,
+ expanded: Boolean = false,
+ expandChildren: Boolean = false,
+) =
+ RelayItem.Country(
+ name = name,
+ code = name.generateCountryCode(),
+ cities =
+ cityNames.map { cityName ->
+ generateRelayItemCity(
+ cityName,
+ name.generateCountryCode(),
+ relaysPerCity,
+ active,
+ expandChildren
+ )
+ },
+ expanded = expanded,
+ )
+
+fun generateRelayItemCity(
+ name: String,
+ countryCode: String,
+ numberOfRelays: Int,
+ active: Boolean = true,
+ expanded: Boolean = false,
+) =
+ RelayItem.City(
+ name = name,
+ code = name.generateCityCode(),
+ relays =
+ List(numberOfRelays) { index ->
+ generateRelayItemRelay(
+ countryCode,
+ name.generateCityCode(),
+ generateHostname(countryCode, name.generateCityCode(), index),
+ active
+ )
+ },
+ expanded = expanded,
+ location = GeographicLocationConstraint.City(countryCode, name.generateCityCode()),
+ )
+
+fun generateRelayItemRelay(
+ countryCode: String,
+ cityCode: String,
+ hostName: String,
+ active: Boolean = true,
+) =
+ RelayItem.Relay(
+ name = hostName,
+ location =
+ GeographicLocationConstraint.Hostname(
+ countryCode = countryCode,
+ cityCode = cityCode,
+ hostname = hostName,
+ ),
+ locationName = "$cityCode $hostName",
+ active = active
+ )
+
+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/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
index afd3f72211..62fd84854d 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
@@ -37,14 +37,21 @@ import net.mullvad.mullvadvpn.usecase.RelayListUseCase
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.util.ChangelogDataProvider
import net.mullvad.mullvadvpn.util.IChangelogDataProvider
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
+import net.mullvad.mullvadvpn.viewmodel.CreateCustomListDialogViewModel
+import net.mullvad.mullvadvpn.viewmodel.CustomListLocationsViewModel
+import net.mullvad.mullvadvpn.viewmodel.CustomListsViewModel
+import net.mullvad.mullvadvpn.viewmodel.DeleteCustomListConfirmationViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel
import net.mullvad.mullvadvpn.viewmodel.DnsDialogViewModel
+import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel
+import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel
import net.mullvad.mullvadvpn.viewmodel.FilterViewModel
import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel
@@ -100,7 +107,7 @@ val uiModule = module {
}
single { SettingsRepository(get()) }
single { MullvadProblemReport(get()) }
- single { CustomListsRepository(get()) }
+ single { CustomListsRepository(get(), get(), get()) }
single { AccountExpiryNotificationUseCase(get()) }
single { TunnelStateNotificationUseCase(get()) }
@@ -111,6 +118,7 @@ val uiModule = module {
single { OutOfTimeUseCase(get(), get(), MainScope()) }
single { ConnectivityUseCase(get()) }
single { SystemVpnSettingsUseCase(androidContext()) }
+ single { CustomListActionUseCase(get(), get()) }
single { InAppNotificationController(get(), get(), get(), get(), MainScope()) }
@@ -150,6 +158,7 @@ val uiModule = module {
viewModel { LoginViewModel(get(), get(), get(), get()) }
viewModel { PrivacyDisclaimerViewModel(get(), IS_PLAY_BUILD) }
viewModel { SelectLocationViewModel(get(), get(), get()) }
+ viewModel { SelectLocationViewModel(get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) }
viewModel { SplashViewModel(get(), get(), get()) }
viewModel { VoucherDialogViewModel(get(), get()) }
@@ -160,6 +169,16 @@ val uiModule = module {
viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) }
viewModel { PaymentViewModel(get()) }
viewModel { FilterViewModel(get()) }
+ viewModel { parameters -> CreateCustomListDialogViewModel(parameters.get(), get()) }
+ viewModel { parameters ->
+ CustomListLocationsViewModel(parameters.get(), parameters.get(), get(), get())
+ }
+ viewModel { parameters -> EditCustomListViewModel(parameters.get(), get()) }
+ viewModel { parameters ->
+ EditCustomListNameDialogViewModel(parameters.get(), parameters.get(), get())
+ }
+ viewModel { CustomListsViewModel(get(), get()) }
+ viewModel { parameters -> DeleteCustomListConfirmationViewModel(parameters.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/relaylist/CustomListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt
index 9ad0c220e9..6fb87a6af5 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
@@ -2,18 +2,32 @@ package net.mullvad.mullvadvpn.relaylist
import net.mullvad.mullvadvpn.model.CustomList
-fun CustomList.toRelayItemCustomList(
+private fun CustomList.toRelayItemCustomList(
relayCountries: List<RelayItem.Country>
): RelayItem.CustomList =
RelayItem.CustomList(
- code = this.id,
id = this.id,
name = this.name,
expanded = false,
locations =
- this.locations.mapNotNull { relayCountries.findItemForGeographicLocationConstraint(it) }
+ this.locations.mapNotNull {
+ relayCountries.findItemForGeographicLocationConstraint(it)
+ },
)
fun List<CustomList>.toRelayItemLists(
relayCountries: List<RelayItem.Country>
): List<RelayItem.CustomList> = this.map { it.toRelayItemCustomList(relayCountries) }
+
+fun List<RelayItem.CustomList>.filterOnSearchTerm(searchTerm: String) =
+ if (searchTerm.length >= MIN_SEARCH_LENGTH) {
+ this.filter { it.name.contains(searchTerm, ignoreCase = true) }
+ } else {
+ this
+ }
+
+fun RelayItem.CustomList.canAddLocation(location: RelayItem) =
+ this.locations.none { it.code == location.code } &&
+ this.locations.flatMap { it.descendants() }.none { it.code == location.code }
+
+fun List<RelayItem.CustomList>.getById(id: String) = this.find { it.id == id }
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
index 03ba7a02f3..54c4a9bef4 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
@@ -16,7 +16,6 @@ sealed interface RelayItem {
data class CustomList(
override val name: String,
- override val code: String,
override val expanded: Boolean,
val id: String,
val locations: List<RelayItem>,
@@ -26,6 +25,8 @@ sealed interface RelayItem {
override val hasChildren
get() = locations.isNotEmpty()
+
+ override val code = id
}
data class Country(
@@ -35,6 +36,7 @@ sealed interface RelayItem {
val cities: List<City>
) : RelayItem {
val location = GeographicLocationConstraint.Country(code)
+ val relays = cities.flatMap { city -> city.relays }
override val active
get() = cities.any { city -> city.active }
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 6808d707b2..a71005a78a 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
@@ -10,3 +10,17 @@ fun RelayItem.toLocationConstraint(): LocationConstraint {
is RelayItem.CustomList -> LocationConstraint.CustomList(id)
}
}
+
+fun RelayItem.children(): List<RelayItem> {
+ return when (this) {
+ is RelayItem.Country -> cities
+ is RelayItem.City -> relays
+ is RelayItem.CustomList -> locations
+ else -> emptyList()
+ }
+}
+
+fun RelayItem.descendants(): List<RelayItem> {
+ val children = children()
+ return children + children.flatMap { it.descendants() }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt
deleted file mode 100644
index cdbd58b291..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package net.mullvad.mullvadvpn.relaylist
-
-enum class RelayItemType {
- Country,
- City,
- Relay,
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt
new file mode 100644
index 0000000000..4fcc5c7902
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt
@@ -0,0 +1,3 @@
+package net.mullvad.mullvadvpn.relaylist
+
+const val MIN_SEARCH_LENGTH = 2
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 06e00a022a..882a3e42a4 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
@@ -236,4 +236,32 @@ private fun List<RelayItem.Country>.expandItemForSelection(
} ?: this
}
-private const val MIN_SEARCH_LENGTH = 2
+@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
+ )
+ }
+ }
+ }
+ }
+ return null
+}
+
+fun List<RelayItem.Country>.getRelayItemsByCodes(codes: List<String>): List<RelayItem> =
+ this.filter { codes.contains(it.code) } +
+ this.flatMap { it.descendants() }.filter { codes.contains(it.code) }
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 9660981688..f1a38871bd 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,28 +1,80 @@
package net.mullvad.mullvadvpn.repository
import kotlinx.coroutines.flow.first
+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.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
-class CustomListsRepository(private val messageHandler: MessageHandler) {
- suspend fun createCustomList(name: String): String? {
+class CustomListsRepository(
+ private val messageHandler: MessageHandler,
+ private val settingsRepository: SettingsRepository,
+ private val relayListListener: RelayListListener
+) {
+ suspend fun createCustomList(name: String): CreateCustomListResult {
val result = messageHandler.trySendRequest(Request.CreateCustomList(name))
return if (result) {
- messageHandler.events<Event.CreateCustomListResult>().first().listId
+ messageHandler.events<Event.CreateCustomListResultEvent>().first().result
} else {
- null
+ CreateCustomListResult.Error(CustomListsError.OtherError)
}
}
- fun deleteCustomList(id: String) {
- messageHandler.trySendRequest(Request.DeleteCustomList(id))
+ fun deleteCustomList(id: String) = messageHandler.trySendRequest(Request.DeleteCustomList(id))
+
+ private suspend fun updateCustomList(customList: CustomList): UpdateCustomListResult {
+ val result = messageHandler.trySendRequest(Request.UpdateCustomList(customList))
+
+ return if (result) {
+ messageHandler.events<Event.UpdateCustomListResultEvent>().first().result
+ } else {
+ UpdateCustomListResult.Error(CustomListsError.OtherError)
+ }
}
- fun updateCustomList(customList: CustomList) {
- messageHandler.trySendRequest(Request.UpdateCustomList(customList))
+ suspend fun updateCustomListLocationsFromCodes(
+ id: String,
+ locationCodes: List<String>
+ ): UpdateCustomListResult =
+ updateCustomListLocations(
+ id = id,
+ locations =
+ ArrayList(locationCodes.mapNotNull { getGeographicLocationConstraintByCode(it) })
+ )
+
+ suspend fun updateCustomListName(id: String, name: String): UpdateCustomListResult =
+ getCustomListById(id)?.let { updateCustomList(it.copy(name = name)) }
+ ?: UpdateCustomListResult.Error(CustomListsError.OtherError)
+
+ private suspend fun updateCustomListLocations(
+ id: String,
+ locations: ArrayList<GeographicLocationConstraint>
+ ): UpdateCustomListResult =
+ awaitCustomListById(id)?.let { updateCustomList(it.copy(locations = locations)) }
+ ?: UpdateCustomListResult.Error(CustomListsError.OtherError)
+
+ private suspend fun awaitCustomListById(id: String): CustomList? =
+ settingsRepository.settingsUpdates
+ .mapNotNull { settings -> settings?.customLists?.customLists?.find { it.id == id } }
+ .firstOrNullWithTimeout(GET_CUSTOM_LIST_TIMEOUT_MS)
+
+ fun getCustomListById(id: String): CustomList? =
+ settingsRepository.settingsUpdates.value?.customLists?.customLists?.find { it.id == id }
+
+ private fun getGeographicLocationConstraintByCode(code: String): GeographicLocationConstraint? =
+ relayListListener.relayListEvents.value.getGeographicLocationConstraintByCode(code)
+
+ companion object {
+ private const val GET_CUSTOM_LIST_TIMEOUT_MS = 5000L
}
}
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
index ab3d93e06e..4957818283 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/RelayListUseCase.kt
@@ -44,13 +44,18 @@ class RelayListUseCase(
findSelectedRelayItem(
relaySettings = settings?.relaySettings,
relayCountries = relayCountries,
- customLists = customLists
+ customLists = customLists,
)
RelayList(customLists, relayCountries, selectedItem)
}
fun selectedRelayItem(): Flow<RelayItem?> = relayListWithSelection().map { it.selectedItem }
+ fun relayList(): Flow<List<RelayItem.Country>> = relayListWithSelection().map { it.country }
+
+ fun customLists(): Flow<List<RelayItem.CustomList>> =
+ relayListWithSelection().map { it.customLists }
+
fun fetchRelayList() {
relayListListener.fetchRelayList()
}
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
new file mode 100644
index 0000000000..7b2e5a43aa
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListActionUseCase.kt
@@ -0,0 +1,117 @@
+package net.mullvad.mullvadvpn.usecase.customlists
+
+import kotlinx.coroutines.flow.firstOrNull
+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.GeographicLocationConstraint
+import net.mullvad.mullvadvpn.model.UpdateCustomListResult
+import net.mullvad.mullvadvpn.relaylist.getRelayItemsByCodes
+import net.mullvad.mullvadvpn.repository.CustomListsRepository
+import net.mullvad.mullvadvpn.usecase.RelayListUseCase
+
+class CustomListActionUseCase(
+ private val customListsRepository: CustomListsRepository,
+ private val relayListUseCase: RelayListUseCase
+) {
+ suspend fun performAction(action: CustomListAction): Result<CustomListResult> {
+ return when (action) {
+ is CustomListAction.Create -> {
+ performAction(action)
+ }
+ is CustomListAction.Rename -> {
+ performAction(action)
+ }
+ is CustomListAction.Delete -> {
+ performAction(action)
+ }
+ is CustomListAction.UpdateLocations -> {
+ performAction(action)
+ }
+ }
+ }
+
+ suspend fun performAction(action: CustomListAction.Rename): Result<CustomListResult.Renamed> =
+ when (
+ val result =
+ customListsRepository.updateCustomListName(action.customListId, action.newName)
+ ) {
+ is UpdateCustomListResult.Ok ->
+ Result.success(CustomListResult.Renamed(undo = action.not()))
+ is UpdateCustomListResult.Error -> Result.failure(CustomListsException(result.error))
+ }
+
+ suspend fun performAction(action: CustomListAction.Create): Result<CustomListResult.Created> =
+ when (val result = customListsRepository.createCustomList(action.name)) {
+ is CreateCustomListResult.Ok -> {
+ if (action.locations.isNotEmpty()) {
+ customListsRepository.updateCustomListLocationsFromCodes(
+ result.id,
+ action.locations
+ )
+ val locationNames =
+ relayListUseCase
+ .relayList()
+ .firstOrNull()
+ ?.getRelayItemsByCodes(action.locations)
+ ?.map { it.name }
+ Result.success(
+ CustomListResult.Created(
+ id = result.id,
+ name = action.name,
+ locationName = locationNames?.first(),
+ undo = action.not(result.id)
+ )
+ )
+ } else {
+ Result.success(
+ CustomListResult.Created(
+ id = result.id,
+ name = action.name,
+ locationName = null,
+ undo = action.not(result.id)
+ )
+ )
+ }
+ }
+ is CreateCustomListResult.Error -> Result.failure(CustomListsException(result.error))
+ }
+
+ fun performAction(action: CustomListAction.Delete): Result<CustomListResult.Deleted> {
+ val customList: CustomList? = customListsRepository.getCustomListById(action.customListId)
+ val oldLocations = customList.locations()
+ val name = customList?.name ?: ""
+ customListsRepository.deleteCustomList(action.customListId)
+ return Result.success(
+ CustomListResult.Deleted(undo = action.not(locations = oldLocations, name = name))
+ )
+ }
+
+ suspend fun performAction(
+ action: CustomListAction.UpdateLocations
+ ): Result<CustomListResult.LocationsChanged> {
+ val customList: CustomList? = customListsRepository.getCustomListById(action.customListId)
+ val oldLocations = customList.locations()
+ val name = customList?.name ?: ""
+ customListsRepository.updateCustomListLocationsFromCodes(
+ action.customListId,
+ action.locations
+ )
+ return Result.success(
+ CustomListResult.LocationsChanged(
+ name = name,
+ undo = action.not(locations = oldLocations)
+ )
+ )
+ }
+
+ private fun CustomList?.locations(): List<String> =
+ this?.locations?.map {
+ when (it) {
+ is GeographicLocationConstraint.City -> it.cityCode
+ is GeographicLocationConstraint.Country -> it.countryCode
+ is GeographicLocationConstraint.Hostname -> it.hostname
+ }
+ } ?: emptyList()
+}
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
new file mode 100644
index 0000000000..07c37f7333
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/customlists/CustomListsException.kt
@@ -0,0 +1,5 @@
+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/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt
index 843c7c2930..b3a8727df9 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
@@ -8,6 +8,7 @@ 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
@@ -149,3 +150,7 @@ suspend inline fun <T> Flow<T>.retryWithExponentialBackOff(
}
class ExceptionWrapper(val item: Any) : Throwable()
+
+suspend fun <T> Flow<T>.firstOrNullWithTimeout(timeMillis: Long): T? {
+ return withTimeoutOrNull(timeMillis) { firstOrNull() }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt
new file mode 100644
index 0000000000..9ae5bb7a64
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModel.kt
@@ -0,0 +1,85 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.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.state.CreateCustomListUiState
+import net.mullvad.mullvadvpn.model.CustomListsError
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException
+
+class CreateCustomListDialogViewModel(
+ private val locationCode: String,
+ private val customListActionUseCase: CustomListActionUseCase,
+) : ViewModel() {
+
+ private val _uiSideEffect =
+ Channel<CreateCustomListDialogSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ private val _error = MutableStateFlow<CustomListsError?>(null)
+
+ val uiState =
+ _error
+ .map { CreateCustomListUiState(it) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CreateCustomListUiState())
+
+ fun createCustomList(name: String) {
+ viewModelScope.launch {
+ customListActionUseCase
+ .performAction(
+ CustomListAction.Create(
+ name,
+ if (locationCode.isNotEmpty()) {
+ listOf(locationCode)
+ } else {
+ emptyList()
+ }
+ )
+ )
+ .fold(
+ onSuccess = { result ->
+ if (result.locationName != null) {
+ _uiSideEffect.send(
+ CreateCustomListDialogSideEffect.ReturnWithResult(result)
+ )
+ } else {
+ _uiSideEffect.send(
+ CreateCustomListDialogSideEffect
+ .NavigateToCustomListLocationsScreen(result.id)
+ )
+ }
+ },
+ onFailure = { error ->
+ if (error is CustomListsException) {
+ _error.emit(error.error)
+ } else {
+ _error.emit(CustomListsError.OtherError)
+ }
+ }
+ )
+ }
+ }
+
+ fun clearError() {
+ viewModelScope.launch { _error.emit(null) }
+ }
+}
+
+sealed interface CreateCustomListDialogSideEffect {
+
+ data class NavigateToCustomListLocationsScreen(val customListId: String) :
+ CreateCustomListDialogSideEffect
+
+ data class ReturnWithResult(val result: CustomListResult.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
new file mode 100644
index 0000000000..5fa99306a3
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModel.kt
@@ -0,0 +1,216 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+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.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.state.CustomListLocationsUiState
+import net.mullvad.mullvadvpn.relaylist.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.usecase.customlists.CustomListActionUseCase
+import net.mullvad.mullvadvpn.util.firstOrNullWithTimeout
+
+class CustomListLocationsViewModel(
+ private val customListId: String,
+ private val newList: Boolean,
+ private val relayListUseCase: RelayListUseCase,
+ private val customListActionUseCase: CustomListActionUseCase
+) : ViewModel() {
+ private var customListName: String = ""
+
+ private val _uiSideEffect =
+ MutableSharedFlow<CustomListLocationsSideEffect>(replay = 1, extraBufferCapacity = 1)
+ val uiSideEffect: SharedFlow<CustomListLocationsSideEffect> = _uiSideEffect
+
+ private val _initialLocations = MutableStateFlow<Set<RelayItem>>(emptySet())
+ private val _selectedLocations = MutableStateFlow<Set<RelayItem>?>(null)
+ private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM)
+
+ val uiState =
+ combine(relayListUseCase.relayList(), _searchTerm, _selectedLocations) {
+ relayCountries,
+ searchTerm,
+ selectedLocations ->
+ val filteredRelayCountries = relayCountries.filterOnSearchTerm(searchTerm, null)
+
+ when {
+ selectedLocations == null ->
+ CustomListLocationsUiState.Loading(newList = newList)
+ filteredRelayCountries.isEmpty() ->
+ CustomListLocationsUiState.Content.Empty(
+ newList = newList,
+ searchTerm = searchTerm
+ )
+ else ->
+ CustomListLocationsUiState.Content.Data(
+ newList = newList,
+ searchTerm = searchTerm,
+ availableLocations = filteredRelayCountries,
+ selectedLocations = selectedLocations,
+ saveEnabled =
+ selectedLocations.isNotEmpty() &&
+ selectedLocations != _initialLocations.value,
+ hasUnsavedChanges = selectedLocations != _initialLocations.value
+ )
+ }
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ CustomListLocationsUiState.Loading(newList = newList)
+ )
+
+ init {
+ viewModelScope.launch { fetchInitialSelectedLocations() }
+ }
+
+ fun save() {
+ viewModelScope.launch {
+ _selectedLocations.value?.let { selectedLocations ->
+ val result =
+ customListActionUseCase.performAction(
+ CustomListAction.UpdateLocations(
+ customListId,
+ selectedLocations.calculateLocationsToSave().map { it.code }
+ )
+ )
+ _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())
+ }
+ )
+ }
+ }
+ }
+
+ fun onRelaySelectionClick(relayItem: RelayItem, selected: Boolean) {
+ if (selected) {
+ selectLocation(relayItem)
+ } else {
+ deselectLocation(relayItem)
+ }
+ }
+
+ fun onSearchTermInput(searchTerm: String) {
+ 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) {
+ viewModelScope.launch {
+ _selectedLocations.update {
+ it?.plus(relayItem)?.plus(relayItem.descendants()) ?: setOf(relayItem)
+ }
+ }
+ }
+
+ private fun deselectLocation(relayItem: RelayItem) {
+ viewModelScope.launch {
+ _selectedLocations.update {
+ val newSelectedLocations = it?.toMutableSet() ?: mutableSetOf()
+ newSelectedLocations.remove(relayItem)
+ newSelectedLocations.removeAll(relayItem.descendants().toSet())
+ // If a parent is selected, deselect it, since we only want to select a parent if
+ // all children are selected
+ newSelectedLocations.deselectParents(relayItem)
+ }
+ }
+ }
+
+ private fun availableLocations(): List<RelayItem.Country> =
+ (uiState.value as? CustomListLocationsUiState.Content.Data)?.availableLocations
+ ?: emptyList()
+
+ private fun Set<RelayItem>.deselectParents(relayItem: RelayItem): Set<RelayItem> {
+ val availableLocations = availableLocations()
+ val updateSelectionList = this.toMutableSet()
+ when (relayItem) {
+ is RelayItem.City -> {
+ availableLocations
+ .find { it.code == relayItem.location.countryCode }
+ ?.let { updateSelectionList.remove(it) }
+ }
+ is RelayItem.Relay -> {
+ availableLocations
+ .flatMap { country -> country.cities }
+ .find { it.code == relayItem.location.cityCode }
+ ?.let { updateSelectionList.remove(it) }
+ availableLocations
+ .find { it.code == relayItem.location.countryCode }
+ ?.let { updateSelectionList.remove(it) }
+ }
+ is RelayItem.Country,
+ is RelayItem.CustomList -> {
+ /* Do nothing */
+ }
+ }
+
+ return updateSelectionList
+ }
+
+ private fun Set<RelayItem>.calculateLocationsToSave(): List<RelayItem> {
+ // We don't want to save children for a selected parent
+ val saveSelectionList = this.toMutableList()
+ this.forEach { relayItem ->
+ when (relayItem) {
+ is RelayItem.Country -> {
+ saveSelectionList.removeAll(relayItem.cities)
+ saveSelectionList.removeAll(relayItem.relays)
+ }
+ is RelayItem.City -> {
+ saveSelectionList.removeAll(relayItem.relays)
+ }
+ is RelayItem.Relay,
+ is RelayItem.CustomList -> {
+ /* Do nothing */
+ }
+ }
+ }
+ return saveSelectionList
+ }
+
+ private fun List<RelayItem>.selectChildren(): Set<RelayItem> =
+ (this + flatMap { it.descendants() }).toSet()
+
+ private suspend fun fetchInitialSelectedLocations() {
+ _selectedLocations.value =
+ awaitCustomListById(customListId)
+ ?.apply { customListName = name }
+ ?.locations
+ ?.selectChildren()
+ .apply { _initialLocations.value = this ?: emptySet() }
+ }
+
+ 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
+}
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
new file mode 100644
index 0000000000..79a2ba61c6
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModel.kt
@@ -0,0 +1,32 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+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.usecase.customlists.CustomListActionUseCase
+
+class CustomListsViewModel(
+ relayListUseCase: RelayListUseCase,
+ private val customListActionUseCase: CustomListActionUseCase
+) : ViewModel() {
+
+ val uiState =
+ relayListUseCase
+ .customLists()
+ .map { CustomListsUiState.Content(it) }
+ .stateIn(
+ viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ CustomListsUiState.Loading
+ )
+
+ fun undoDeleteCustomList(action: CustomListAction.Create) {
+ viewModelScope.launch { customListActionUseCase.performAction(action) }
+ }
+}
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
new file mode 100644
index 0000000000..e3c7f45664
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModel.kt
@@ -0,0 +1,33 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.communication.CustomListAction
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
+
+class DeleteCustomListConfirmationViewModel(
+ private val customListId: String,
+ private val customListActionUseCase: CustomListActionUseCase
+) : ViewModel() {
+ private val _uiSideEffect = Channel<DeleteCustomListConfirmationSideEffect>(Channel.BUFFERED)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ fun deleteCustomList() {
+ viewModelScope.launch {
+ val result =
+ customListActionUseCase
+ .performAction(CustomListAction.Delete(customListId))
+ .getOrThrow()
+ _uiSideEffect.send(DeleteCustomListConfirmationSideEffect.ReturnWithResult(result))
+ }
+ }
+}
+
+sealed class DeleteCustomListConfirmationSideEffect {
+ data class ReturnWithResult(val result: CustomListResult.Deleted) :
+ DeleteCustomListConfirmationSideEffect()
+}
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
new file mode 100644
index 0000000000..c2625e6d56
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModel.kt
@@ -0,0 +1,76 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.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.state.UpdateCustomListUiState
+import net.mullvad.mullvadvpn.model.CustomListsError
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListsException
+
+class EditCustomListNameDialogViewModel(
+ private val customListId: String,
+ private val initialName: String,
+ private val customListActionUseCase: CustomListActionUseCase
+) : ViewModel() {
+
+ private val _uiSideEffect =
+ Channel<EditCustomListNameDialogSideEffect>(1, BufferOverflow.DROP_OLDEST)
+ val uiSideEffect = _uiSideEffect.receiveAsFlow()
+
+ private val _error = MutableStateFlow<CustomListsError?>(null)
+
+ val uiState =
+ _error
+ .map { UpdateCustomListUiState(name = initialName, error = it) }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ UpdateCustomListUiState(name = initialName)
+ )
+
+ fun updateCustomListName(name: String) {
+ viewModelScope.launch {
+ customListActionUseCase
+ .performAction(
+ CustomListAction.Rename(
+ customListId = customListId,
+ name = initialName,
+ newName = name
+ )
+ )
+ .fold(
+ onSuccess = { result ->
+ _uiSideEffect.send(
+ EditCustomListNameDialogSideEffect.ReturnWithResult(result)
+ )
+ },
+ onFailure = { exception ->
+ if (exception is CustomListsException) {
+ _error.emit(exception.error)
+ } else {
+ _error.emit(CustomListsError.OtherError)
+ }
+ }
+ )
+ }
+ }
+
+ fun clearError() {
+ viewModelScope.launch { _error.emit(null) }
+ }
+}
+
+sealed interface EditCustomListNameDialogSideEffect {
+ data class ReturnWithResult(val result: CustomListResult.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
new file mode 100644
index 0000000000..81232e63d5
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModel.kt
@@ -0,0 +1,30 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+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
+
+class EditCustomListViewModel(
+ private val customListId: String,
+ relayListUseCase: RelayListUseCase
+) : ViewModel() {
+ val uiState =
+ relayListUseCase
+ .customLists()
+ .map { customLists ->
+ customLists
+ .find { it.id == customListId }
+ ?.let {
+ EditCustomListState.Content(
+ id = it.id,
+ name = it.name,
+ locations = it.locations
+ )
+ } ?: EditCustomListState.NotFound
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), EditCustomListState.Loading)
+}
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 4ca27a105a..15df90be9e 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
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
-import net.mullvad.mullvadvpn.compose.state.RelayListState
+import net.mullvad.mullvadvpn.compose.communication.CustomListAction
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.compose.state.toNullableOwnership
import net.mullvad.mullvadvpn.compose.state.toSelectedProviders
@@ -24,11 +25,13 @@ 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.usecase.customlists.CustomListActionUseCase
class SelectLocationViewModel(
private val serviceConnectionManager: ServiceConnectionManager,
private val relayListUseCase: RelayListUseCase,
- private val relayListFilterUseCase: RelayListFilterUseCase
+ private val relayListFilterUseCase: RelayListFilterUseCase,
+ private val customListActionUseCase: CustomListActionUseCase
) : ViewModel() {
private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM)
@@ -38,9 +41,9 @@ class SelectLocationViewModel(
_searchTerm,
relayListFilterUseCase.selectedOwnership(),
relayListFilterUseCase.availableProviders(),
- relayListFilterUseCase.selectedProviders()
+ relayListFilterUseCase.selectedProviders(),
) {
- (customList, relayCountries, selectedItem),
+ (customLists, relayCountries, selectedItem),
searchTerm,
selectedOwnership,
allProviders,
@@ -60,25 +63,22 @@ class SelectLocationViewModel(
val filteredRelayCountries =
relayCountries.filterOnSearchTerm(searchTerm, selectedItem)
- SelectLocationUiState.Data(
+ val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm)
+
+ SelectLocationUiState.Content(
searchTerm = searchTerm,
selectedOwnership = selectedOwnershipItem,
selectedProvidersCount = selectedProvidersCount,
- relayListState =
- if (filteredRelayCountries.isNotEmpty()) {
- RelayListState.RelayList(
- countries = filteredRelayCountries,
- selectedItem = selectedItem
- )
- } else {
- RelayListState.Empty
- },
+ filteredCustomLists = filteredCustomLists,
+ customLists = customLists,
+ countries = filteredRelayCountries,
+ selectedItem = selectedItem,
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
- SelectLocationUiState.Loading
+ SelectLocationUiState.Loading,
)
private val _uiSideEffect = Channel<SelectLocationSideEffect>()
@@ -113,7 +113,7 @@ class SelectLocationViewModel(
viewModelScope.launch {
relayListFilterUseCase.updateOwnershipAndProviderFilter(
Constraint.Any(),
- relayListFilterUseCase.selectedProviders().first()
+ relayListFilterUseCase.selectedProviders().first(),
)
}
}
@@ -122,11 +122,28 @@ class SelectLocationViewModel(
viewModelScope.launch {
relayListFilterUseCase.updateOwnershipAndProviderFilter(
relayListFilterUseCase.selectedOwnership().first(),
- Constraint.Any()
+ Constraint.Any(),
)
}
}
+ fun addLocationToList(item: RelayItem, customList: RelayItem.CustomList) {
+ viewModelScope.launch {
+ val newLocations = (customList.locations + item).map { it.code }
+ val result =
+ customListActionUseCase.performAction(
+ CustomListAction.UpdateLocations(customList.id, newLocations)
+ )
+ _uiSideEffect.send(
+ SelectLocationSideEffect.LocationAddedToCustomList(result.getOrThrow())
+ )
+ }
+ }
+
+ fun performAction(action: CustomListAction) {
+ viewModelScope.launch { customListActionUseCase.performAction(action) }
+ }
+
companion object {
private const val EMPTY_SEARCH_TERM = ""
}
@@ -134,4 +151,7 @@ class SelectLocationViewModel(
sealed interface SelectLocationSideEffect {
data object CloseScreen : SelectLocationSideEffect
+
+ data class LocationAddedToCustomList(val result: CustomListResult.LocationsChanged) :
+ SelectLocationSideEffect
}
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
new file mode 100644
index 0000000000..129d921c36
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/CustomListsRepositoryTest.kt
@@ -0,0 +1,268 @@
+package net.mullvad.mullvadvpn.repository
+
+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.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.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 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<Settings?> = MutableStateFlow(null)
+ private val relayListFlow: MutableStateFlow<RelayList> = MutableStateFlow(mockk())
+
+ @BeforeEach
+ fun setup() {
+ mockkStatic(RELAY_LIST_EXTENSIONS)
+ every { mockSettingsRepository.settingsUpdates } returns settingsFlow
+ every { mockRelayListListener.relayListEvents } returns relayListFlow
+ }
+
+ @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
+
+ // Act
+ val result = customListsRepository.getCustomListById(customListId)
+
+ // Assert
+ assertEquals(mockCustomList, result)
+ }
+
+ @Test
+ fun `get custom list by id should return null when id does not matches custom list in settings`() {
+ // 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
+
+ // Act
+ val result = customListsRepository.getCustomListById(otherCustomListId)
+
+ // Assert
+ assertNull(result)
+ }
+
+ @Test
+ fun `create custom list should return Ok when creation is successful`() = runTest {
+ // Arrange
+ val customListId = "1"
+ val expectedResult = CreateCustomListResult.Ok(customListId)
+ val customListName = "CUSTOM"
+ every {
+ mockMessageHandler.trySendRequest(Request.CreateCustomList(customListName))
+ } returns true
+ every { mockMessageHandler.events<Event.CreateCustomListResultEvent>() } returns
+ flowOf(Event.CreateCustomListResultEvent(expectedResult))
+
+ // Act
+ 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`() =
+ runTest {
+ // Arrange
+ val expectedResult = CreateCustomListResult.Error(CustomListsError.CustomListExists)
+ val customListName = "CUSTOM"
+ every {
+ mockMessageHandler.trySendRequest(Request.CreateCustomList(customListName))
+ } returns true
+ every { mockMessageHandler.events<Event.CreateCustomListResultEvent>() } returns
+ flowOf(Event.CreateCustomListResultEvent(expectedResult))
+
+ // Act
+ val result = customListsRepository.createCustomList(customListName)
+
+ // Assert
+ assertEquals(expectedResult, result)
+ }
+
+ @Test
+ fun `update custom list name should return ok when list updated event is received`() = runTest {
+ // Arrange
+ val customListId = "1"
+ val expectedResult = UpdateCustomListResult.Ok
+ val customListName = "CUSTOM"
+ val mockSettings: Settings = mockk()
+ val mockCustomList: CustomList = mockk()
+ val updatedCustomList: CustomList = mockk()
+ settingsFlow.value = mockSettings
+ every { mockCustomList.id } returns customListId
+ every { mockCustomList.copy(customListId, customListName, any()) } returns updatedCustomList
+ every {
+ mockMessageHandler.trySendRequest(Request.UpdateCustomList(updatedCustomList))
+ } returns true
+ every { mockMessageHandler.events<Event.UpdateCustomListResultEvent>() } returns
+ flowOf(Event.UpdateCustomListResultEvent(expectedResult))
+ every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList)
+
+ // Act
+ val result = customListsRepository.updateCustomListName(customListId, customListName)
+
+ // 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 mockSettings: Settings = mockk()
+ val mockCustomList: CustomList = mockk()
+ val updatedCustomList: CustomList = mockk()
+ settingsFlow.value = mockSettings
+ every { mockCustomList.id } returns customListId
+ every { mockCustomList.copy(customListId, customListName, any()) } returns
+ updatedCustomList
+ every {
+ mockMessageHandler.trySendRequest(Request.UpdateCustomList(updatedCustomList))
+ } returns true
+ every { mockMessageHandler.events<Event.UpdateCustomListResultEvent>() } returns
+ flowOf(Event.UpdateCustomListResultEvent(expectedResult))
+ every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList)
+
+ // Act
+ val result = customListsRepository.updateCustomListName(customListId, customListName)
+
+ // 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
+
+ // Act
+ customListsRepository.deleteCustomList(customListId)
+
+ // Assert
+ verify { mockMessageHandler.trySendRequest(Request.DeleteCustomList(customListId)) }
+ }
+
+ @Test
+ fun `update custom list locations should return ok when list exists and ok updated list event is received`() =
+ runTest {
+ // Arrange
+ val expectedResult = UpdateCustomListResult.Ok
+ val customListId = "1"
+ val customListName = "CUSTOM"
+ val locationCode = "AB"
+ val mockSettings: Settings = mockk()
+ val mockRelayList: RelayList = mockk()
+ val mockCustomList: CustomList = mockk()
+ val updatedCustomList: CustomList = mockk()
+ val mockLocationConstraint: GeographicLocationConstraint = mockk()
+ settingsFlow.value = mockSettings
+ relayListFlow.value = mockRelayList
+ every { mockCustomList.id } returns customListId
+ every { mockCustomList.name } returns customListName
+ every {
+ mockCustomList.copy(
+ customListId,
+ customListName,
+ arrayListOf(mockLocationConstraint)
+ )
+ } returns updatedCustomList
+ every {
+ mockMessageHandler.trySendRequest(Request.UpdateCustomList(updatedCustomList))
+ } returns true
+ every { mockMessageHandler.events<Event.UpdateCustomListResultEvent>() } returns
+ flowOf(Event.UpdateCustomListResultEvent(expectedResult))
+ every { mockSettings.customLists.customLists } returns arrayListOf(mockCustomList)
+ every { mockRelayList.getGeographicLocationConstraintByCode(locationCode) } returns
+ mockLocationConstraint
+
+ // Act
+ val result =
+ customListsRepository.updateCustomListLocationsFromCodes(
+ customListId,
+ listOf(locationCode)
+ )
+
+ // Assert
+ assertEquals(expectedResult, result)
+ }
+
+ @Test
+ fun `update custom list locations should return other 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()
+ 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(
+ otherCustomListId,
+ listOf(locationCode)
+ )
+
+ // Assert
+ assertEquals(expectedResult, result)
+ }
+
+ companion object {
+ private const val RELAY_LIST_EXTENSIONS =
+ "net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt"
+ }
+}
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
new file mode 100644
index 0000000000..0370f23ffb
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/CustomListActionUseCaseTest.kt
@@ -0,0 +1,220 @@
+package net.mullvad.mullvadvpn.usecase
+
+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.test.runTest
+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.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.repository.CustomListsRepository
+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.BeforeEach
+import org.junit.jupiter.api.Test
+
+class CustomListActionUseCaseTest {
+ private val mockCustomListsRepository: CustomListsRepository = mockk()
+ private val mockRelayListUseCase: RelayListUseCase = mockk()
+ private val customListActionUseCase =
+ CustomListActionUseCase(
+ customListsRepository = mockCustomListsRepository,
+ relayListUseCase = mockRelayListUseCase
+ )
+
+ @BeforeEach
+ fun setup() {
+ mockkStatic(RELAY_LIST_EXTENSIONS)
+ }
+
+ @Test
+ fun `create action should return success when ok`() = runTest {
+ // Arrange
+ val name = "test"
+ val locationCode = "AB"
+ val locationName = "Acklaba"
+ val createdId = "1"
+ val action = CustomListAction.Create(name = name, locations = listOf(locationCode))
+ val expectedResult =
+ Result.success(
+ CustomListResult.Created(
+ id = createdId,
+ name = name,
+ locationName = locationName,
+ undo = action.not(createdId)
+ )
+ )
+ val relayItem =
+ RelayItem.Country(
+ name = locationName,
+ code = locationCode,
+ expanded = false,
+ cities = emptyList()
+ )
+ val mockLocations: List<RelayItem.Country> = listOf(relayItem)
+ coEvery { mockCustomListsRepository.createCustomList(name) } returns
+ CreateCustomListResult.Ok(createdId)
+ coEvery {
+ mockCustomListsRepository.updateCustomListLocationsFromCodes(
+ createdId,
+ listOf(locationCode)
+ )
+ } returns UpdateCustomListResult.Ok
+ coEvery { mockRelayListUseCase.relayList() } returns flowOf(mockLocations)
+ every { mockLocations.getRelayItemsByCodes(listOf(locationCode)) } returns mockLocations
+
+ // Act
+ val result = customListActionUseCase.performAction(action)
+
+ // Assert
+ assertEquals(expectedResult, result)
+ }
+
+ @Test
+ fun `create action should return error when name already exists`() = runTest {
+ // Arrange
+ val name = "test"
+ val locationCode = "AB"
+ val action = CustomListAction.Create(name = name, locations = listOf(locationCode))
+ val expectedError = CustomListsError.CustomListExists
+ coEvery { mockCustomListsRepository.createCustomList(name) } returns
+ CreateCustomListResult.Error(CustomListsError.CustomListExists)
+
+ // Act
+ val result = customListActionUseCase.performAction(action)
+
+ // Assert
+ assertIs<Result<CustomListsException>>(result)
+ val exception = result.exceptionOrNull()
+ assertIs<CustomListsException>(exception)
+ assertEquals(expectedError, exception.error)
+ }
+
+ @Test
+ fun `rename action should return success when ok`() = runTest {
+ // Arrange
+ val name = "test"
+ val newName = "test2"
+ val customListId = "1"
+ val action =
+ CustomListAction.Rename(customListId = customListId, name = name, newName = newName)
+ val expectedResult = Result.success(CustomListResult.Renamed(undo = action.not()))
+ coEvery {
+ mockCustomListsRepository.updateCustomListName(id = customListId, name = newName)
+ } returns UpdateCustomListResult.Ok
+
+ // Act
+ val result = customListActionUseCase.performAction(action)
+
+ // Assert
+ assertEquals(expectedResult, result)
+ }
+
+ @Test
+ fun `rename action should return error when name already exists`() = runTest {
+ // Arrange
+ val name = "test"
+ val newName = "test2"
+ val customListId = "1"
+ val action =
+ CustomListAction.Rename(customListId = customListId, name = name, newName = newName)
+ val expectedError = CustomListsError.CustomListExists
+ coEvery {
+ mockCustomListsRepository.updateCustomListName(id = customListId, name = newName)
+ } returns UpdateCustomListResult.Error(expectedError)
+
+ // Act
+ val result = customListActionUseCase.performAction(action)
+
+ // Assert
+ assertIs<Result<CustomListsException>>(result)
+ val exception = result.exceptionOrNull()
+ assertIs<CustomListsException>(exception)
+ assertEquals(expectedError, exception.error)
+ }
+
+ @Test
+ fun `delete action should return successful with deleted list`() = runTest {
+ // Arrange
+ val mockCustomList: CustomList = mockk()
+ val mockLocation: GeographicLocationConstraint.Country = mockk()
+ val mockLocations: ArrayList<GeographicLocationConstraint> = arrayListOf(mockLocation)
+ val name = "test"
+ val customListId = "1"
+ val locationCode = "AB"
+ val action = CustomListAction.Delete(customListId = 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
+ every { mockLocation.countryCode } returns locationCode
+ coEvery { mockCustomListsRepository.deleteCustomList(id = customListId) } returns true
+ every { mockCustomListsRepository.getCustomListById(customListId) } returns mockCustomList
+
+ // Act
+ val result = customListActionUseCase.performAction(action)
+
+ // Assert
+ assertEquals(expectedResult, result)
+ }
+
+ @Test
+ fun `update locations action should return success with changed locations`() = runTest {
+ // Arrange
+ val name = "test"
+ val oldLocationCodes = listOf("AB", "CD")
+ val newLocationCodes = listOf("EF", "GH")
+ val oldLocations: ArrayList<GeographicLocationConstraint> =
+ arrayListOf(
+ GeographicLocationConstraint.Country("AB"),
+ GeographicLocationConstraint.Country("CD")
+ )
+ val customListId = "1"
+ val customList = CustomList(id = customListId, name = name, locations = oldLocations)
+ val action =
+ CustomListAction.UpdateLocations(
+ customListId = customListId,
+ locations = newLocationCodes
+ )
+ val expectedResult =
+ Result.success(
+ CustomListResult.LocationsChanged(
+ name = name,
+ undo = action.not(locations = oldLocationCodes)
+ )
+ )
+ coEvery { mockCustomListsRepository.getCustomListById(customListId) } returns customList
+
+ coEvery {
+ mockCustomListsRepository.updateCustomListLocationsFromCodes(
+ customListId,
+ newLocationCodes
+ )
+ } returns UpdateCustomListResult.Ok
+
+ // Act
+ val result = customListActionUseCase.performAction(action)
+
+ // Assert
+ assertEquals(expectedResult, result)
+ }
+
+ companion object {
+ private const val RELAY_LIST_EXTENSIONS =
+ "net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt"
+ }
+}
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
new file mode 100644
index 0000000000..7b14db3ffb
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CreateCustomListDialogViewModelTest.kt
@@ -0,0 +1,114 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import app.cash.turbine.test
+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.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.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
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(TestCoroutineRule::class)
+class CreateCustomListDialogViewModelTest {
+ private val mockCustomListActionUseCase: CustomListActionUseCase = mockk()
+
+ @Test
+ fun `when successfully creating a list with locations should emit return with result side effect`() =
+ runTest {
+ // Arrange
+ val expectedResult: CustomListResult.Created = mockk()
+ val customListName = "list"
+ val viewModel = createViewModelWithLocationCode("AB")
+ coEvery {
+ mockCustomListActionUseCase.performAction(any<CustomListAction.Create>())
+ } returns Result.success(expectedResult)
+ every { expectedResult.locationName } returns "locationName"
+
+ // Act, Assert
+ viewModel.uiSideEffect.test {
+ viewModel.createCustomList(customListName)
+ val sideEffect = awaitItem()
+ assertIs<CreateCustomListDialogSideEffect.ReturnWithResult>(sideEffect)
+ assertEquals(expectedResult, sideEffect.result)
+ }
+ }
+
+ @Test
+ 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("")
+ coEvery {
+ mockCustomListActionUseCase.performAction(any<CustomListAction.Create>())
+ } returns Result.success(expectedResult)
+ every { expectedResult.locationName } returns null
+ every { expectedResult.id } returns createdId
+
+ // Act, Assert
+ viewModel.uiSideEffect.test {
+ viewModel.createCustomList(customListName)
+ val sideEffect = awaitItem()
+ assertIs<CreateCustomListDialogSideEffect.NavigateToCustomListLocationsScreen>(
+ sideEffect
+ )
+ assertEquals(createdId, sideEffect.customListId)
+ }
+ }
+
+ @Test
+ fun `when failing to creating a list should update ui state with error`() = runTest {
+ // Arrange
+ val expectedError = CustomListsError.CustomListExists
+ val customListName = "list"
+ val viewModel = createViewModelWithLocationCode("")
+ coEvery {
+ mockCustomListActionUseCase.performAction(any<CustomListAction.Create>())
+ } returns Result.failure(CustomListsException(expectedError))
+
+ // Act, Assert
+ viewModel.uiState.test {
+ awaitItem() // Default state
+ viewModel.createCustomList(customListName)
+ assertEquals(expectedError, awaitItem().error)
+ }
+ }
+
+ @Test
+ fun `given error state when calling clear error then should update to state without error`() =
+ runTest {
+ // Arrange
+ val expectedError = CustomListsError.CustomListExists
+ val customListName = "list"
+ val viewModel = createViewModelWithLocationCode("")
+ coEvery {
+ mockCustomListActionUseCase.performAction(any<CustomListAction.Create>())
+ } returns Result.failure(CustomListsException(expectedError))
+
+ // Act, Assert
+ viewModel.uiState.test {
+ awaitItem() // Default state
+ viewModel.createCustomList(customListName)
+ assertEquals(expectedError, awaitItem().error) // Showing error
+ viewModel.clearError()
+ assertNull(awaitItem().error)
+ }
+ }
+
+ private fun createViewModelWithLocationCode(locationCode: String) =
+ 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
new file mode 100644
index 0000000000..df10ba96c4
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListLocationsViewModelTest.kt
@@ -0,0 +1,294 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import app.cash.turbine.test
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+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.state.CustomListLocationsUiState
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.relaylist.descendants
+import net.mullvad.mullvadvpn.usecase.RelayListUseCase
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(TestCoroutineRule::class)
+class CustomListLocationsViewModelTest {
+ private val mockRelayListUseCase: RelayListUseCase = mockk()
+ private val mockCustomListUseCase: CustomListActionUseCase = mockk()
+
+ private val relayListFlow = MutableStateFlow<List<RelayItem.Country>>(emptyList())
+ private val customListFlow = MutableStateFlow<List<RelayItem.CustomList>>(emptyList())
+
+ @BeforeEach
+ fun setup() {
+ every { mockRelayListUseCase.relayList() } returns relayListFlow
+ every { mockRelayListUseCase.customLists() } returns customListFlow
+ }
+
+ @Test
+ fun `given new list false state should return new list false`() = runTest {
+ // Arrange
+ val newList = false
+ val viewModel = createViewModel("id", newList)
+
+ // Act, Assert
+ viewModel.uiState.test { assertEquals(newList, awaitItem().newList) }
+ }
+
+ @Test
+ fun `when selected locations is not null and relay countries is not empty should return ui state content`() =
+ 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 expectedState =
+ CustomListLocationsUiState.Content.Data(
+ newList = true,
+ availableLocations = expectedList
+ )
+ val viewModel = createViewModel(customListId, true)
+ relayListFlow.value = expectedList
+
+ // Act, Assert
+ viewModel.uiState.test { assertEquals(expectedState, awaitItem()) }
+ }
+
+ @Test
+ 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 expectedSelection =
+ (DUMMY_COUNTRIES + DUMMY_COUNTRIES.flatMap { it.descendants() }).toSet()
+ val viewModel = createViewModel(customListId, true)
+ relayListFlow.value = expectedList
+
+ // Act, Assert
+ viewModel.uiState.test {
+ // Check no selected
+ val firstState = awaitItem()
+ assertIs<CustomListLocationsUiState.Content.Data>(firstState)
+ assertEquals(emptySet<RelayItem>(), firstState.selectedLocations)
+ viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0], true)
+ // Check all items selected
+ val secondState = awaitItem()
+ assertIs<CustomListLocationsUiState.Content.Data>(secondState)
+ assertEquals(expectedSelection, secondState.selectedLocations)
+ }
+ }
+
+ @Test
+ fun `when deselecting child should deselect parent`() = runTest {
+ // Arrange
+ 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 expectedSelection = emptySet<RelayItem>()
+ val viewModel = createViewModel(customListId, true)
+ relayListFlow.value = expectedList
+
+ // Act, Assert
+ viewModel.uiState.test {
+ // Check initial selected
+ val firstState = awaitItem()
+ assertIs<CustomListLocationsUiState.Content.Data>(firstState)
+ assertEquals(initialSelection, firstState.selectedLocations)
+ viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0].cities[0].relays[0], false)
+ // Check all items selected
+ val secondState = awaitItem()
+ assertIs<CustomListLocationsUiState.Content.Data>(secondState)
+ assertEquals(expectedSelection, secondState.selectedLocations)
+ }
+ }
+
+ @Test
+ fun `when deselecting parent should deselect child`() = runTest {
+ // Arrange
+ 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 expectedSelection = emptySet<RelayItem>()
+ val viewModel = createViewModel(customListId, true)
+ relayListFlow.value = expectedList
+
+ // Act, Assert
+ viewModel.uiState.test {
+ // Check initial selected
+ val firstState = awaitItem()
+ assertIs<CustomListLocationsUiState.Content.Data>(firstState)
+ assertEquals(initialSelection, firstState.selectedLocations)
+ viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0], false)
+ // Check all items selected
+ val secondState = awaitItem()
+ assertIs<CustomListLocationsUiState.Content.Data>(secondState)
+ assertEquals(expectedSelection, secondState.selectedLocations)
+ }
+ }
+
+ @Test
+ 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 expectedSelection = DUMMY_COUNTRIES[0].cities[0].relays.toSet()
+ val viewModel = createViewModel(customListId, true)
+ relayListFlow.value = expectedList
+
+ // Act, Assert
+ viewModel.uiState.test {
+ // Check no selected
+ val firstState = awaitItem()
+ assertIs<CustomListLocationsUiState.Content.Data>(firstState)
+ assertEquals(emptySet<RelayItem>(), firstState.selectedLocations)
+ viewModel.onRelaySelectionClick(DUMMY_COUNTRIES[0].cities[0].relays[0], true)
+ // Check all items selected
+ val secondState = awaitItem()
+ assertIs<CustomListLocationsUiState.Content.Data>(secondState)
+ assertEquals(expectedSelection, secondState.selectedLocations)
+ }
+ }
+
+ @Test
+ fun `given new list true when saving successfully should emit close screen side effect`() =
+ runTest {
+ // Arrange
+ val customListId = "1"
+ val customListName = "name"
+ 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)
+ coEvery {
+ mockCustomListUseCase.performAction(any<CustomListAction.UpdateLocations>())
+ } returns Result.success(expectedResult)
+ val viewModel = createViewModel(customListId, newList)
+
+ // Act, Assert
+ viewModel.uiSideEffect.test {
+ viewModel.save()
+ val sideEffect = awaitItem()
+ assertIs<CustomListLocationsSideEffect.CloseScreen>(sideEffect)
+ }
+ }
+
+ @Test
+ fun `given new list false when saving successfully should emit return with result side effect`() =
+ runTest {
+ // Arrange
+ val customListId = "1"
+ val customListName = "name"
+ 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)
+ coEvery {
+ mockCustomListUseCase.performAction(any<CustomListAction.UpdateLocations>())
+ } returns Result.success(expectedResult)
+ val viewModel = createViewModel(customListId, newList)
+
+ // Act, Assert
+ viewModel.uiSideEffect.test {
+ viewModel.save()
+ val sideEffect = awaitItem()
+ assertIs<CustomListLocationsSideEffect.ReturnWithResult>(sideEffect)
+ assertEquals(expectedResult, sideEffect.result)
+ }
+ }
+
+ private fun createViewModel(customListId: String, newList: Boolean) =
+ CustomListLocationsViewModel(
+ customListId = customListId,
+ newList = newList,
+ relayListUseCase = mockRelayListUseCase,
+ customListActionUseCase = mockCustomListUseCase
+ )
+
+ companion object {
+ private val DUMMY_COUNTRIES =
+ listOf(
+ RelayItem.Country(
+ name = "Sweden",
+ code = "SE",
+ expanded = false,
+ cities =
+ listOf(
+ RelayItem.City(
+ name = "Gothenburg",
+ code = "GBG",
+ expanded = false,
+ location = GeographicLocationConstraint.City("SE", "GBG"),
+ relays =
+ listOf(
+ RelayItem.Relay(
+ name = "gbg-1",
+ locationName = "GBG gbg-1",
+ active = true,
+ location =
+ GeographicLocationConstraint.Hostname(
+ "SE",
+ "GBG",
+ "gbg-1"
+ )
+ )
+ )
+ )
+ )
+ )
+ )
+ }
+}
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
new file mode 100644
index 0000000000..612ae38a3a
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/CustomListsViewModelTest.kt
@@ -0,0 +1,54 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+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.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.usecase.customlists.CustomListActionUseCase
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(TestCoroutineRule::class)
+class CustomListsViewModelTest {
+ private val mockRelayListUseCase: RelayListUseCase = mockk(relaxed = true)
+ private val mockCustomListsActionUseCase: CustomListActionUseCase = mockk(relaxed = true)
+
+ @Test
+ fun `given custom list from relay list use case should be in state`() = runTest {
+ // Arrange
+ val customLists: List<RelayItem.CustomList> = mockk()
+ val expectedState = CustomListsUiState.Content(customLists)
+ every { mockRelayListUseCase.customLists() } returns flowOf(customLists)
+ val viewModel = createViewModel()
+
+ // Act, Assert
+ viewModel.uiState.test { assertEquals(expectedState, awaitItem()) }
+ }
+
+ @Test
+ fun `undo delete action should call custom list use case`() = runTest {
+ // Arrange
+ val viewModel = createViewModel()
+ val action: CustomListAction.Create = mockk()
+
+ // Act
+ viewModel.undoDeleteCustomList(action)
+
+ // Assert
+ coVerify { mockCustomListsActionUseCase.performAction(action) }
+ }
+
+ private fun createViewModel() =
+ CustomListsViewModel(
+ relayListUseCase = mockRelayListUseCase,
+ 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
new file mode 100644
index 0000000000..9f7f3f1f0b
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeleteCustomListConfirmationViewModelTest.kt
@@ -0,0 +1,43 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import app.cash.turbine.test
+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.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(TestCoroutineRule::class)
+class DeleteCustomListConfirmationViewModelTest {
+ private val mockCustomListActionUseCase: CustomListActionUseCase = mockk()
+
+ @Test
+ fun `when successfully deleting a list should emit return with result side effect`() = runTest {
+ // Arrange
+ val expectedResult: CustomListResult.Deleted = mockk()
+ val viewModel = createViewModel()
+ coEvery {
+ mockCustomListActionUseCase.performAction(any<CustomListAction.Delete>())
+ } returns Result.success(expectedResult)
+
+ // Act, Assert
+ viewModel.uiSideEffect.test {
+ viewModel.deleteCustomList()
+ val sideEffect = awaitItem()
+ assertIs<DeleteCustomListConfirmationSideEffect.ReturnWithResult>(sideEffect)
+ assertEquals(expectedResult, sideEffect.result)
+ }
+ }
+
+ private fun createViewModel() =
+ DeleteCustomListConfirmationViewModel(
+ customListId = "1",
+ customListActionUseCase = mockCustomListActionUseCase
+ )
+}
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
new file mode 100644
index 0000000000..e9592d0336
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListNameDialogViewModelTest.kt
@@ -0,0 +1,90 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import app.cash.turbine.test
+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.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.CustomListsError
+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
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(TestCoroutineRule::class)
+class EditCustomListNameDialogViewModelTest {
+ private val mockCustomListActionUseCase: CustomListActionUseCase = mockk()
+
+ @Test
+ fun `when successfully renamed list should emit return with result side effect`() = runTest {
+ // Arrange
+ val expectedResult: CustomListResult.Renamed = mockk()
+ val customListId = "id"
+ val customListName = "list"
+ val viewModel = createViewModel(customListId, customListName)
+ coEvery {
+ mockCustomListActionUseCase.performAction(any<CustomListAction.Rename>())
+ } returns Result.success(expectedResult)
+
+ // Act, Assert
+ viewModel.uiSideEffect.test {
+ viewModel.updateCustomListName(customListName)
+ val sideEffect = awaitItem()
+ assertIs<EditCustomListNameDialogSideEffect.ReturnWithResult>(sideEffect)
+ assertEquals(expectedResult, sideEffect.result)
+ }
+ }
+
+ @Test
+ fun `when failing to creating a list should update ui state with error`() = runTest {
+ // Arrange
+ val expectedError = CustomListsError.CustomListExists
+ val customListId = "id2"
+ val customListName = "list2"
+ val viewModel = createViewModel(customListId, customListName)
+ coEvery {
+ mockCustomListActionUseCase.performAction(any<CustomListAction.Rename>())
+ } returns Result.failure(CustomListsException(expectedError))
+
+ // Act, Assert
+ viewModel.uiState.test {
+ awaitItem() // Default state
+ viewModel.updateCustomListName(customListName)
+ assertEquals(expectedError, awaitItem().error)
+ }
+ }
+
+ @Test
+ 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 customListName = "list"
+ val viewModel = createViewModel(customListId, customListName)
+ coEvery {
+ mockCustomListActionUseCase.performAction(any<CustomListAction.Rename>())
+ } returns Result.failure(CustomListsException(expectedError))
+
+ // Act, Assert
+ viewModel.uiState.test {
+ awaitItem() // Default state
+ viewModel.updateCustomListName(customListName)
+ assertEquals(expectedError, awaitItem().error) // Showing error
+ viewModel.clearError()
+ assertNull(awaitItem().error)
+ }
+ }
+
+ private fun createViewModel(customListId: String, initialName: String) =
+ EditCustomListNameDialogViewModel(
+ customListId = customListId,
+ initialName = 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
new file mode 100644
index 0000000000..33986961b3
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/EditCustomListViewModelTest.kt
@@ -0,0 +1,66 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+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.test.runTest
+import net.mullvad.mullvadvpn.compose.state.EditCustomListState
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.relaylist.RelayItem
+import net.mullvad.mullvadvpn.usecase.RelayListUseCase
+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)
+
+ @Test
+ fun `given a custom list id that does not exists should return not found ui state`() = runTest {
+ // Arrange
+ val customListId = "2"
+ val customList =
+ RelayItem.CustomList(id = "1", name = "test", expanded = false, locations = emptyList())
+ every { mockRelayListUseCase.customLists() } returns flowOf(listOf(customList))
+ val viewModel = createViewModel(customListId)
+
+ // Act, Assert
+ viewModel.uiState.test {
+ val item = awaitItem()
+ assertIs<EditCustomListState.NotFound>(item)
+ }
+ }
+
+ @Test
+ fun `given a custom list id that exists should return content ui state`() = runTest {
+ // Arrange
+ val customListId = "1"
+ val customList =
+ RelayItem.CustomList(
+ id = customListId,
+ name = "test",
+ expanded = false,
+ locations = emptyList()
+ )
+ every { mockRelayListUseCase.customLists() } returns flowOf(listOf(customList))
+ val viewModel = createViewModel(customListId)
+
+ // Act, Assert
+ viewModel.uiState.test {
+ val item = awaitItem()
+ assertIs<EditCustomListState.Content>(item)
+ assertEquals(item.id, customList.id)
+ assertEquals(item.name, customList.name)
+ assertEquals(item.locations, customList.locations)
+ }
+ }
+
+ private fun createViewModel(customListId: String) =
+ EditCustomListViewModel(
+ customListId = customListId,
+ relayListUseCase = mockRelayListUseCase
+ )
+}
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 d8bcc7080f..41bff94ccd 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,6 +2,8 @@ package net.mullvad.mullvadvpn.viewmodel
import androidx.lifecycle.viewModelScope
import app.cash.turbine.test
+import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -14,7 +16,8 @@ import kotlin.test.assertIs
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
-import net.mullvad.mullvadvpn.compose.state.RelayListState
+import net.mullvad.mullvadvpn.compose.communication.CustomListAction
+import net.mullvad.mullvadvpn.compose.communication.CustomListResult
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.common.test.assertLists
@@ -33,6 +36,7 @@ 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.usecase.customlists.CustomListActionUseCase
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -47,6 +51,7 @@ class SelectLocationViewModelTest {
private val relayListWithSelectionFlow =
MutableStateFlow(RelayList(emptyList(), emptyList(), null))
private val mockRelayListUseCase: RelayListUseCase = mockk()
+ private val mockCustomListActionUseCase: CustomListActionUseCase = mockk(relaxed = true)
private val selectedOwnership = MutableStateFlow<Constraint<Ownership>>(Constraint.Any())
private val selectedProvider = MutableStateFlow<Constraint<Providers>>(Constraint.Any())
private val allProvider = MutableStateFlow<List<Provider>>(emptyList())
@@ -63,11 +68,13 @@ class SelectLocationViewModelTest {
mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS)
mockkStatic(RELAY_LIST_EXTENSIONS)
mockkStatic(RELAY_ITEM_EXTENSIONS)
+ mockkStatic(CUSTOM_LIST_EXTENSIONS)
viewModel =
SelectLocationViewModel(
mockServiceConnectionManager,
mockRelayListUseCase,
- mockRelayListFilterUseCase
+ mockRelayListFilterUseCase,
+ mockCustomListActionUseCase
)
}
@@ -94,16 +101,9 @@ class SelectLocationViewModelTest {
// Act, Assert
viewModel.uiState.test {
val actualState = awaitItem()
- assertIs<SelectLocationUiState.Data>(actualState)
- assertIs<RelayListState.RelayList>(actualState.relayListState)
- assertLists(
- mockCountries,
- (actualState.relayListState as RelayListState.RelayList).countries
- )
- assertEquals(
- selectedItem,
- (actualState.relayListState as RelayListState.RelayList).selectedItem
- )
+ assertIs<SelectLocationUiState.Content>(actualState)
+ assertLists(mockCountries, actualState.countries)
+ assertEquals(selectedItem, actualState.selectedItem)
}
}
@@ -121,16 +121,9 @@ class SelectLocationViewModelTest {
// Act, Assert
viewModel.uiState.test {
val actualState = awaitItem()
- assertIs<SelectLocationUiState.Data>(actualState)
- assertIs<RelayListState.RelayList>(actualState.relayListState)
- assertLists(
- mockCountries,
- (actualState.relayListState as RelayListState.RelayList).countries
- )
- assertEquals(
- selectedItem,
- (actualState.relayListState as RelayListState.RelayList).selectedItem
- )
+ assertIs<SelectLocationUiState.Content>(actualState)
+ assertLists(mockCountries, actualState.countries)
+ assertEquals(selectedItem, actualState.selectedItem)
}
}
@@ -169,28 +162,22 @@ class SelectLocationViewModelTest {
val mockSearchString = "SEARCH"
every { mockRelayList.filterOnSearchTerm(mockSearchString, selectedItem) } returns
mockCountries
+ every { mockCustomList.filterOnSearchTerm(mockSearchString) } returns mockCustomList
relayListWithSelectionFlow.value = RelayList(mockCustomList, mockRelayList, selectedItem)
// Act, Assert
viewModel.uiState.test {
// Wait for first data
- assertIs<SelectLocationUiState.Data>(awaitItem())
+ assertIs<SelectLocationUiState.Content>(awaitItem())
// Update search string
viewModel.onSearchTermInput(mockSearchString)
// Assert
val actualState = awaitItem()
- assertIs<SelectLocationUiState.Data>(actualState)
- assertIs<RelayListState.RelayList>(actualState.relayListState)
- assertLists(
- mockCountries,
- (actualState.relayListState as RelayListState.RelayList).countries
- )
- assertEquals(
- selectedItem,
- (actualState.relayListState as RelayListState.RelayList).selectedItem
- )
+ assertIs<SelectLocationUiState.Content>(actualState)
+ assertLists(mockCountries, actualState.countries)
+ assertEquals(selectedItem, actualState.selectedItem)
}
}
@@ -204,19 +191,20 @@ class SelectLocationViewModelTest {
val mockSearchString = "SEARCH"
every { mockRelayList.filterOnSearchTerm(mockSearchString, selectedItem) } returns
mockCountries
+ every { mockCustomList.filterOnSearchTerm(mockSearchString) } returns mockCustomList
relayListWithSelectionFlow.value = RelayList(mockCustomList, mockRelayList, selectedItem)
// Act, Assert
viewModel.uiState.test {
// Wait for first data
- assertIs<SelectLocationUiState.Data>(awaitItem())
+ assertIs<SelectLocationUiState.Content>(awaitItem())
// Update search string
viewModel.onSearchTermInput(mockSearchString)
// Assert
val actualState = awaitItem()
- assertIs<SelectLocationUiState.Data>(actualState)
+ assertIs<SelectLocationUiState.Content>(actualState)
assertEquals(mockSearchString, actualState.searchTerm)
}
}
@@ -257,6 +245,40 @@ class SelectLocationViewModelTest {
}
}
+ @Test
+ fun `when perform action is called should call custom list use case`() {
+ // Arrange
+ val action: CustomListAction = mockk()
+
+ // Act
+ viewModel.performAction(action)
+
+ // Assert
+ coVerify { mockCustomListActionUseCase.performAction(action) }
+ }
+
+ @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 customList: RelayItem.CustomList = mockk {
+ every { id } returns "1"
+ every { locations } returns emptyList()
+ }
+ coEvery {
+ mockCustomListActionUseCase.performAction(any<CustomListAction.UpdateLocations>())
+ } returns Result.success(expectedResult)
+
+ // Act, Assert
+ viewModel.uiSideEffect.test {
+ viewModel.addLocationToList(item = location, customList = customList)
+ val sideEffect = awaitItem()
+ assertIs<SelectLocationSideEffect.LocationAddedToCustomList>(sideEffect)
+ assertEquals(expectedResult, sideEffect.result)
+ }
+ }
+
companion object {
private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS =
"net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt"
@@ -264,5 +286,7 @@ class SelectLocationViewModelTest {
"net.mullvad.mullvadvpn.relaylist.RelayListExtensionsKt"
private const val RELAY_ITEM_EXTENSIONS =
"net.mullvad.mullvadvpn.relaylist.RelayItemExtensionsKt"
+ private const val CUSTOM_LIST_EXTENSIONS =
+ "net.mullvad.mullvadvpn.relaylist.CustomListExtensionsKt"
}
}
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
index 1136ae8c55..cce2ab1f87 100644
--- 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
@@ -5,6 +5,7 @@ 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
@@ -14,6 +15,7 @@ import net.mullvad.mullvadvpn.model.RelayList
import net.mullvad.mullvadvpn.model.RemoveDeviceResult
import net.mullvad.mullvadvpn.model.Settings
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() {
@@ -65,7 +67,9 @@ sealed class Event : Message.EventMessage() {
@Parcelize object VpnPermissionRequest : Event()
- @Parcelize data class CreateCustomListResult(val listId: String) : Event()
+ @Parcelize data class CreateCustomListResultEvent(val result: CreateCustomListResult) : Event()
+
+ @Parcelize data class UpdateCustomListResultEvent(val result: UpdateCustomListResult) : Event()
companion object {
private const val MESSAGE_KEY = "event"
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
new file mode 100644
index 0000000000..73eaa209c8
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CreateCustomListResult.kt
@@ -0,0 +1,10 @@
+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/CustomListsError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsError.kt
new file mode 100644
index 0000000000..83806af4f7
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/CustomListsError.kt
@@ -0,0 +1,6 @@
+package net.mullvad.mullvadvpn.model
+
+enum class CustomListsError {
+ CustomListExists,
+ OtherError
+}
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
new file mode 100644
index 0000000000..ebfe9e8cd6
--- /dev/null
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/UpdateCustomListResult.kt
@@ -0,0 +1,10 @@
+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/resource/src/main/res/drawable/icon_add.xml b/android/lib/resource/src/main/res/drawable/icon_add.xml
new file mode 100644
index 0000000000..1b016dcfb2
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_add.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
+</vector>
diff --git a/android/lib/resource/src/main/res/drawable/icon_delete.xml b/android/lib/resource/src/main/res/drawable/icon_delete.xml
new file mode 100644
index 0000000000..0e8b2004cb
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_delete.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" />
+</vector>
diff --git a/android/lib/resource/src/main/res/drawable/icon_edit.xml b/android/lib/resource/src/main/res/drawable/icon_edit.xml
new file mode 100644
index 0000000000..3df2eb93a6
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_edit.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z" />
+</vector>
diff --git a/android/lib/resource/src/main/res/drawable/icon_more_vert.xml b/android/lib/resource/src/main/res/drawable/icon_more_vert.xml
new file mode 100644
index 0000000000..59400ec977
--- /dev/null
+++ b/android/lib/resource/src/main/res/drawable/icon_more_vert.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/>
+</vector>
diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml
index 66c4fbb885..1b6088b643 100644
--- a/android/lib/resource/src/main/res/values-da/strings.xml
+++ b/android/lib/resource/src/main/res/values-da/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s blev føjet til din konto.</string>
<string name="agree_and_continue">Accepter og fortsæt</string>
<string name="all_applications">Alle applikationer</string>
+ <string name="all_locations">Alle placeringer</string>
<string name="all_providers">Alle udbydere</string>
<string name="allow_lan_footer">Giver adgang til andre enheder på det samme netværk til deling, udskrivning osv.</string>
<string name="always_on_vpn_error_notification_content">Kunne ikke starte tunnelforbindelse. Deaktiver Altid-til VPN for &lt;b&gt;%1$s&lt;/b&gt;.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Deaktiver alle &lt;b&gt;%1$s&lt;/b&gt; ovenfor for at aktivere denne indstilling.</string>
<string name="custom_dns_footer">Aktiver for at tilføje mindst én DNS-server.</string>
<string name="custom_dns_hint">Indtast IP</string>
+ <string name="custom_list_error_list_exists">Navnet er allerede taget.</string>
+ <string name="custom_lists">Brugerdefinerede lister</string>
<string name="custom_port_dialog_placeholder">Indtast port</string>
<string name="custom_port_dialog_remove">Fjern brugerdefineret port</string>
<string name="custom_port_dialog_submit">Indstil port</string>
<string name="custom_port_dialog_title">Brugerdefineret WireGuard-port</string>
<string name="custom_port_dialog_valid_ranges">Gyldige intervaller: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Kunne ikke fortolke værten for den tilpassede tunnel. Prøv at ændre dine indstillinger.</string>
+ <string name="delete">Slet</string>
+ <string name="delete_list">Slet liste</string>
<string name="device_inactive_description">Du har fjernet denne enhed. For at oprette forbindelse igen skal du logge ind igen.</string>
<string name="device_inactive_title">Enheden er inaktiv</string>
<string name="device_inactive_unblock_warning">Hvis du logger på, ophæves blokeringen af internettet på denne enhed.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Kun lejet</string>
<string name="report_a_problem">Rapporter et problem</string>
<string name="reset_to_default_button">Nulstil til standard</string>
+ <string name="save">Gem</string>
<string name="search_placeholder">Søg efter...</string>
<string name="secure_connection">SIKKER TILSLUTNING</string>
<string name="secured">Sikret</string>
diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml
index 59a04fcb7e..0b72c89620 100644
--- a/android/lib/resource/src/main/res/values-de/strings.xml
+++ b/android/lib/resource/src/main/res/values-de/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s wurde zu Ihrem Konto hinzugefügt.</string>
<string name="agree_and_continue">Akzeptieren und weiter</string>
<string name="all_applications">Alle Anwendungen</string>
+ <string name="all_locations">Alle Standorte</string>
<string name="all_providers">Alle Anbieter</string>
<string name="allow_lan_footer">Ermöglicht den Zugriff auf andere Geräte im selben Netzwerk zum Teilen von Dateien, Drucken etc.</string>
<string name="always_on_vpn_error_notification_content">Tunnelverbindung kann nicht gestartet werden. Bitte deaktivieren Sie Always-on VPN für &lt;b&gt;%1$s&lt;/b&gt;, bevor Sie Mullvad VPN verwenden.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Deaktivieren Sie oben alle &lt;b&gt;%1$s&lt;/b&gt;, um diese Einstellung zu aktivieren.</string>
<string name="custom_dns_footer">Aktivieren, um mindestens einen DNS-Server hinzuzufügen.</string>
<string name="custom_dns_hint">IP eingeben</string>
+ <string name="custom_list_error_list_exists">Der Name ist bereits vergeben.</string>
+ <string name="custom_lists">Eigene Listen</string>
<string name="custom_port_dialog_placeholder">Port eingeben</string>
<string name="custom_port_dialog_remove">Eigenen Port entfernen</string>
<string name="custom_port_dialog_submit">Port festlegen</string>
<string name="custom_port_dialog_title">Eigener WireGuard-Port</string>
<string name="custom_port_dialog_valid_ranges">Gültige Bereiche: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Der Host des benutzerdefinierten Tunnels konnte nicht aufgelöst werden. Versuchen Sie, Ihre Einstellungen zu ändern.</string>
+ <string name="delete">Löschen</string>
+ <string name="delete_list">Liste löschen</string>
<string name="device_inactive_description">Sie haben dieses Gerät entfernt. Um sich erneut zu verbinden, müssen Sie sich erneut anmelden.</string>
<string name="device_inactive_title">Gerät ist inaktiv</string>
<string name="device_inactive_unblock_warning">Wenn Sie mit der Anmeldung fortfahren, wird die Internetsperre auf diesem Gerät aufgehoben.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Nur gemietet</string>
<string name="report_a_problem">Problem melden</string>
<string name="reset_to_default_button">Auf Standard zurücksetzen</string>
+ <string name="save">Speichern</string>
<string name="search_placeholder">Suchen nach …</string>
<string name="secure_connection">SICHERE VERBINDUNG</string>
<string name="secured">Gesichert</string>
diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml
index b43f206ba9..0a2369af88 100644
--- a/android/lib/resource/src/main/res/values-es/strings.xml
+++ b/android/lib/resource/src/main/res/values-es/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">Se ha(n) añadido %1$s a su cuenta.</string>
<string name="agree_and_continue">Aceptar y continuar</string>
<string name="all_applications">Todas las aplicaciones</string>
+ <string name="all_locations">Todas las ubicaciones</string>
<string name="all_providers">Todos los proveedores</string>
<string name="allow_lan_footer">Permite el acceso a otros dispositivos de la misma red para compartir, imprimir, etc.</string>
<string name="always_on_vpn_error_notification_content">No se puede iniciar la conexión de túnel. Deshabilite la VPN siempre activa en &lt;b&gt;%1$s&lt;/b&gt; antes de utilizar la VPN de Mullvad.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Deshabilite todos los &lt;b&gt;%1$s&lt;/b&gt; de arriba para activar este ajuste.</string>
<string name="custom_dns_footer">Active esta opción para agregar como mínimo un servidor DNS.</string>
<string name="custom_dns_hint">Introducir IP</string>
+ <string name="custom_list_error_list_exists">Este nombre ya se está utilizando.</string>
+ <string name="custom_lists">Listas personalizadas</string>
<string name="custom_port_dialog_placeholder">Introducir puerto</string>
<string name="custom_port_dialog_remove">Quitar puerto personalizado</string>
<string name="custom_port_dialog_submit">Establecer puerto</string>
<string name="custom_port_dialog_title">Puerto personalizado de WireGuard</string>
<string name="custom_port_dialog_valid_ranges">Intervalos válidos: %1$s</string>
<string name="custom_tunnel_host_resolution_error">No se puede resolver el host del túnel personalizado. Pruebe a cambiar la configuración.</string>
+ <string name="delete">Eliminar</string>
+ <string name="delete_list">Eliminar lista</string>
<string name="device_inactive_description">Ha quitado este dispositivo. Vuelva a iniciar la sesión para conectarse.</string>
<string name="device_inactive_title">El dispositivo está inactivo</string>
<string name="device_inactive_unblock_warning">Al iniciar la sesión, se desbloqueará el acceso a Internet en este dispositivo.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Solo alquilados</string>
<string name="report_a_problem">Informar de un problema</string>
<string name="reset_to_default_button">Restablecer a valores predeterminados</string>
+ <string name="save">Guardar</string>
<string name="search_placeholder">Buscar...</string>
<string name="secure_connection">CONEXIÓN SEGURA</string>
<string name="secured">Protegido</string>
diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml
index a88eaa3ea4..2df0abd210 100644
--- a/android/lib/resource/src/main/res/values-fi/strings.xml
+++ b/android/lib/resource/src/main/res/values-fi/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">Tilillesi lisättiin %1$s käyttöaikaa.</string>
<string name="agree_and_continue">Hyväksy ja jatka</string>
<string name="all_applications">Kaikki sovellukset</string>
+ <string name="all_locations">Kaikki sijainnit</string>
<string name="all_providers">Kaikki palveluntarjoajat</string>
<string name="allow_lan_footer">Sallii jakamisen, tulostuksen ym. saman verkon muille laitteille.</string>
<string name="always_on_vpn_error_notification_content">Tunneliyhteyden käynnistäminen ei onnistu. Poista aina päällä oleva VPN käytöstä sovellukselle &lt;b&gt;%1$s&lt;/b&gt; ennen Mullvad VPN:n käyttämistä.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Ota tämä asetus käyttöön poistamalla kaikki &lt;b&gt;%1$s&lt;/b&gt; käytöstä yllä.</string>
<string name="custom_dns_footer">Ota käyttöön lisätäksesi vähintään yhden DNS-palvelimen.</string>
<string name="custom_dns_hint">Anna IP-osoite</string>
+ <string name="custom_list_error_list_exists">Nimi on jo olemassa.</string>
+ <string name="custom_lists">Mukautetut luettelot</string>
<string name="custom_port_dialog_placeholder">Anna portti</string>
<string name="custom_port_dialog_remove">Poista mukautettu portti</string>
<string name="custom_port_dialog_submit">Määritä portti</string>
<string name="custom_port_dialog_title">Mukautettu WireGuard-portti</string>
<string name="custom_port_dialog_valid_ranges">Kelvolliset portit: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Muokatun tunnelin isännän selvittäminen ei onnistu. Kokeile muuttaa asetuksiasi.</string>
+ <string name="delete">Poista</string>
+ <string name="delete_list">Poista luettelo</string>
<string name="device_inactive_description">Olet poistanut tämän laitteen. Jos haluat muodostaa yhteyden uudelleen, sinun täytyy kirjautua takaisin sisään.</string>
<string name="device_inactive_title">Laite ei ole aktiivinen</string>
<string name="device_inactive_unblock_warning">Kirjautumiseen siirtyminen purkaa internetin käytön eston tältä laitteelta.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Vain vuokratut</string>
<string name="report_a_problem">Raportoi ongelma</string>
<string name="reset_to_default_button">Palauta oletusarvo</string>
+ <string name="save">Tallenna</string>
<string name="search_placeholder">Hae...</string>
<string name="secure_connection">SUOJATTU YHTEYS</string>
<string name="secured">Suojattu</string>
diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml
index 633dbc9ffc..e834a0209e 100644
--- a/android/lib/resource/src/main/res/values-fr/strings.xml
+++ b/android/lib/resource/src/main/res/values-fr/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s ajouté(s) à votre compte.</string>
<string name="agree_and_continue">Accepter et continuer</string>
<string name="all_applications">Toutes les applications</string>
+ <string name="all_locations">Toutes les localisations</string>
<string name="all_providers">Tous les fournisseurs</string>
<string name="allow_lan_footer">Autorise l\'accès aux autres appareils sur le même réseau pour partager, imprimer, etc.</string>
<string name="always_on_vpn_error_notification_content">Impossible de démarrer la connexion au tunnel. Veuillez désactiver « Toujours exiger un VPN « pour &lt;b&gt;%1$s&lt;/b&gt; avant d\'utiliser Mullvad VPN.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Désactivez tous les &lt;b&gt;%1$s&lt;/b&gt; ci-dessus pour activer ce paramètre.</string>
<string name="custom_dns_footer">Activez pour ajouter au moins un serveur DNS.</string>
<string name="custom_dns_hint">Saisir l\'IP</string>
+ <string name="custom_list_error_list_exists">Le nom est déjà pris.</string>
+ <string name="custom_lists">Listes personnalisées</string>
<string name="custom_port_dialog_placeholder">Saisir le port</string>
<string name="custom_port_dialog_remove">Supprimer le port personnalisé</string>
<string name="custom_port_dialog_submit">Définir le port</string>
<string name="custom_port_dialog_title">Port WireGuard personnalisé</string>
<string name="custom_port_dialog_valid_ranges">Plages valides : %1$s</string>
<string name="custom_tunnel_host_resolution_error">Échec de la résolution de l\'hôte du tunnel personnalisé. Essayez de modifier vos paramètres.</string>
+ <string name="delete">Supprimer</string>
+ <string name="delete_list">Supprimer la liste</string>
<string name="device_inactive_description">Vous avez supprimé cet appareil. Vous devrez vous reconnecter pour connecter cet appareil à nouveau.</string>
<string name="device_inactive_title">L\'appareil est inactif</string>
<string name="device_inactive_unblock_warning">Aller à la connexion débloquera la connexion Internet sur cet appareil.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Loués uniquement</string>
<string name="report_a_problem">Signaler un problème</string>
<string name="reset_to_default_button">Réinitialiser à la valeur par défaut</string>
+ <string name="save">Enregistrer</string>
<string name="search_placeholder">Rechercher...</string>
<string name="secure_connection">CONNEXION SÉCURISÉE</string>
<string name="secured">Sécurisé</string>
diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml
index b2343abd86..bc707e2b96 100644
--- a/android/lib/resource/src/main/res/values-it/strings.xml
+++ b/android/lib/resource/src/main/res/values-it/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s aggiunti al tuo account.</string>
<string name="agree_and_continue">Accetta e continua</string>
<string name="all_applications">Tutte le applicazioni</string>
+ <string name="all_locations">Tutti i luoghi</string>
<string name="all_providers">Tutti i fornitori</string>
<string name="allow_lan_footer">Consenti l\'accesso ad altri dispositivi sulla stessa rete per condividere, stampare e altro.</string>
<string name="always_on_vpn_error_notification_content">Impossibile avviare la connessione tunnel. Disabilita VPN sempre attiva per &lt;b&gt;%1$s&lt;/b&gt; prima di utilizzare Mullvad VPN.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Disabilita tutti i &lt;b&gt;%1$s&lt;/b&gt; sopra per attivare questa impostazione.</string>
<string name="custom_dns_footer">Abilita per aggiungere almeno un server DNS.</string>
<string name="custom_dns_hint">Inserisci IP</string>
+ <string name="custom_list_error_list_exists">Il nome è già preso.</string>
+ <string name="custom_lists">Elenchi personalizzati</string>
<string name="custom_port_dialog_placeholder">Inserisci porta</string>
<string name="custom_port_dialog_remove">Rimuovi porta personalizzata</string>
<string name="custom_port_dialog_submit">Imposta porta</string>
<string name="custom_port_dialog_title">Porta personalizzata WireGuard</string>
<string name="custom_port_dialog_valid_ranges">Intervalli validi: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Impossibile risolvere l\'host del tunnel personalizzato. Prova a modificare le impostazioni.</string>
+ <string name="delete">Elimina</string>
+ <string name="delete_list">Elimina elenco</string>
<string name="device_inactive_description">Hai rimosso questo dispositivo. Per riconnetterti, dovrai effettuare nuovamente il login.</string>
<string name="device_inactive_title">Il dispositivo è inattivo</string>
<string name="device_inactive_unblock_warning">Andare al login sbloccherà Internet su questo dispositivo.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Solo noleggiati</string>
<string name="report_a_problem">Segnala un problema</string>
<string name="reset_to_default_button">Ripristina predefiniti</string>
+ <string name="save">Salva</string>
<string name="search_placeholder">Cerca...</string>
<string name="secure_connection">CONNESSIONE PROTETTA</string>
<string name="secured">Protetto</string>
diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml
index 4a92cd78af..71e1907d9e 100644
--- a/android/lib/resource/src/main/res/values-ja/strings.xml
+++ b/android/lib/resource/src/main/res/values-ja/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$sがアカウントに追加されました。</string>
<string name="agree_and_continue">同意して続行</string>
<string name="all_applications">すべてのアプリケーション</string>
+ <string name="all_locations">すべての場所</string>
<string name="all_providers">すべてのプロバイダ</string>
<string name="allow_lan_footer">共有や印刷などのため、同一ネットワーク上の他のデバイスへのアクセスを許可します。</string>
<string name="always_on_vpn_error_notification_content">トンネル接続を開始できません。Mullvad VPNを使用する前に&lt;b&gt;%1$s&lt;/b&gt;のAlways-on VPNを無効にしてください。</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">この設定を有効にするには、上記のすべての&lt;b&gt;%1$s&lt;/b&gt;を無効にしてください。</string>
<string name="custom_dns_footer">1つ以上のDNSサーバーを追加するには有効にしてください。</string>
<string name="custom_dns_hint">IPを入力</string>
+ <string name="custom_list_error_list_exists">名前はすでに使用されています。</string>
+ <string name="custom_lists">カスタムリスト</string>
<string name="custom_port_dialog_placeholder">ポートを入力</string>
<string name="custom_port_dialog_remove">カスタムポートを削除</string>
<string name="custom_port_dialog_submit">ポートを設定</string>
<string name="custom_port_dialog_title">WireGuardカスタムポート</string>
<string name="custom_port_dialog_valid_ranges">有効な範囲: %1$s</string>
<string name="custom_tunnel_host_resolution_error">カスタムトンネルのホストを解決できません。設定を変更してみてください。</string>
+ <string name="delete">削除</string>
+ <string name="delete_list">リストを削除</string>
<string name="device_inactive_description">このデバイスを削除しました。再度接続するには、ログインし直す必要があります。</string>
<string name="device_inactive_title">デバイスが無効です</string>
<string name="device_inactive_unblock_warning">ログインに進むと、このデバイスのインターネットのブロックが解除されます。</string>
@@ -194,6 +199,7 @@
<string name="rented_only">レンタルサーバーのみ</string>
<string name="report_a_problem">問題を報告する</string>
<string name="reset_to_default_button">デフォルトにリセット</string>
+ <string name="save">保存</string>
<string name="search_placeholder">検索...</string>
<string name="secure_connection">セキュリティ保護された接続</string>
<string name="secured">セキュリティ保護されています</string>
diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml
index 063628f691..7700ad87dc 100644
--- a/android/lib/resource/src/main/res/values-ko/strings.xml
+++ b/android/lib/resource/src/main/res/values-ko/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s이(가) 계정에 추가되었습니다.</string>
<string name="agree_and_continue">동의하고 계속하기</string>
<string name="all_applications">모든 애플리케이션</string>
+ <string name="all_locations">모든 위치</string>
<string name="all_providers">모든 제공업체</string>
<string name="allow_lan_footer">공유, 인쇄 등을 위해 동일한 네트워크의 다른 장치에 액세스할 수 있습니다.</string>
<string name="always_on_vpn_error_notification_content">터널 연결을 시작할 수 없습니다. Mullvad VPN을 사용하기 전에 &lt;b&gt;%1$s&lt;/b&gt;에 대한 상시 접속 VPN을 비활성화하세요.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">이 설정을 활성화하려면 위의 모든 &lt;b&gt;%1$s&lt;/b&gt;을(를) 비활성화하세요.</string>
<string name="custom_dns_footer">하나 이상의 DNS 서버를 추가하려면 활성화합니다.</string>
<string name="custom_dns_hint">IP 입력</string>
+ <string name="custom_list_error_list_exists">이미 사용 중인 이름입니다.</string>
+ <string name="custom_lists">사용자 지정 목록</string>
<string name="custom_port_dialog_placeholder">포트 입력</string>
<string name="custom_port_dialog_remove">사용자 지정 포트 제거</string>
<string name="custom_port_dialog_submit">포트 설정</string>
<string name="custom_port_dialog_title">WireGuard 사용자 지정 포트</string>
<string name="custom_port_dialog_valid_ranges">유효한 범위: %1$s</string>
<string name="custom_tunnel_host_resolution_error">사용자 지정 터널의 호스트를 확인할 수 없습니다. 설정을 변경해 보세요.</string>
+ <string name="delete">삭제</string>
+ <string name="delete_list">목록 삭제</string>
<string name="device_inactive_description">이 장치를 제거했습니다. 다시 연결하려면 다시 로그인해야 합니다.</string>
<string name="device_inactive_title">장치가 비활성 상태입니다.</string>
<string name="device_inactive_unblock_warning">로그인하면 이 장치에서 인터넷 차단이 해제됩니다.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">대여만</string>
<string name="report_a_problem">문제 신고</string>
<string name="reset_to_default_button">기본값으로 재설정</string>
+ <string name="save">저장</string>
<string name="search_placeholder">검색...</string>
<string name="secure_connection">보안 연결</string>
<string name="secured">안전함</string>
diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml
index 9a3cf2ba40..34b27d3cf8 100644
--- a/android/lib/resource/src/main/res/values-my/strings.xml
+++ b/android/lib/resource/src/main/res/values-my/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">သင့် အကောင့်ထဲသို့ %1$s ကို ပေါင်းထည့်ထားပါသည်။</string>
<string name="agree_and_continue">သဘောတူပြီး ဆက်လုပ်ရန်</string>
<string name="all_applications">အပလီကေးရှင်း အားလုံး</string>
+ <string name="all_locations">တည်နေရာအာလုံး</string>
<string name="all_providers">ပံ့ပိုးသူအားလုံး</string>
<string name="allow_lan_footer">ဝေမျှရန်၊ ပရင့်ထုတ်ရန်စသည်တို့အတွက် တူညီသည့် ကွန်ရက်ရှိ အခြားစက်များကို ရယူသုံးစွဲခွင့်ပြုပေးပါသည်။</string>
<string name="always_on_vpn_error_notification_content">Tunnel ချိတ်ဆက်မှုကို စတင်၍ မရနိုင်ပါ။ Mullvad VPN ကို မသုံးမီ &lt;b&gt;%1$s&lt;/b&gt; အတွက် VPN အမြဲဖွင့်ထားမှုကို ပိတ်ပေးပါ။</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">ဤဆက်တင်ကို သက်ဝင်လုပ်ဆောင်ရန် အထက်ရှိ &lt;b&gt;%1$s&lt;/b&gt; အားလုံးကို ပိတ်ပါ။</string>
<string name="custom_dns_footer">အနည်းဆုံး DNS ဆာဗာတစ်ခုကို ပေါင်းထည့်ပါ။</string>
<string name="custom_dns_hint">IP ရိုက်ထည့်ရန်</string>
+ <string name="custom_list_error_list_exists">အမည်သည် ရှိနှင့်ပြီး ဖြစ်သည်။</string>
+ <string name="custom_lists">စိတ်ကြိုက်စာရင်းများ</string>
<string name="custom_port_dialog_placeholder">ပေါ့တ် ရိုက်ထည့်ရန်</string>
<string name="custom_port_dialog_remove">စိတ်ကြိုက် ပေါ့တ်ကို ဖယ်ရှားရန်</string>
<string name="custom_port_dialog_submit">ပေါ့တ် သတ်မှတ်ရန်</string>
<string name="custom_port_dialog_title">စိတ်ကြိုက် WireGuard ပေါ့တ်</string>
<string name="custom_port_dialog_valid_ranges">အကျုံးဝင်သည့် အပိုင်းအခြား- %1$s</string>
<string name="custom_tunnel_host_resolution_error">စိတ်ကြိုက်ပြုလုပ်ထားသည့် Tunnel ၏ Host ကို ဖြေရှင်း၍ မရနိုင်ပါ။ သင့်ဆက်တင်ကို ပြောင်းကြည့်ပါ။</string>
+ <string name="delete">ဖျက်ရန်</string>
+ <string name="delete_list">စာရင်းကို ဖျက်ရန်</string>
<string name="device_inactive_description">ဤစက်ကို ဖယ်ရှားပြီး ဖြစ်သည်။ ထပ်မံချိတ်ဆက်ရန်အတွက် ပြန်လည် ဝင်ရောက်ရန် လိုပါသည်။</string>
<string name="device_inactive_title">စက်သည် သက်ဝင်လုပ်ဆောင်မှု မရှိပါ</string>
<string name="device_inactive_unblock_warning">ဝင်ရောက်ရန်သွားခြင်းဖြင့် ဤစက်တွင် အင်တာနက်ကို ပိတ်ဆို့ထားမှုမှ ဖယ်ရှားပါလိမ့်မည်။</string>
@@ -194,6 +199,7 @@
<string name="rented_only">အငှားသီးသန့်</string>
<string name="report_a_problem">ပြဿနာ ရီပို့တ်လုပ်ရန်</string>
<string name="reset_to_default_button">ပုံသေသို့ ပြန်လည်သတ်မှတ်ရန်</string>
+ <string name="save">သိမ်းမည်</string>
<string name="search_placeholder">ရှာရန်...</string>
<string name="secure_connection">လုံခြုံသည့် ချိတ်ဆက်မှု</string>
<string name="secured">လုံခြုံပါသည်</string>
diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml
index 5d9a283305..e227790588 100644
--- a/android/lib/resource/src/main/res/values-nb/strings.xml
+++ b/android/lib/resource/src/main/res/values-nb/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s ble lagt til kontoen din.</string>
<string name="agree_and_continue">Godta og fortsett</string>
<string name="all_applications">Alle applikasjoner</string>
+ <string name="all_locations">Alle steder</string>
<string name="all_providers">Alle leverandører</string>
<string name="allow_lan_footer">Gir tilgang til andre enheter på samme nettverk for deling, utskrift osv.</string>
<string name="always_on_vpn_error_notification_content">Kunne ikke starte tunneltilkobling. Deaktiver VPN som alltid er på, for &lt;b&gt;%1$s&lt;/b&gt; før du bruker Mullvad VPN.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Deaktiver alle &lt;b&gt;%1$s&lt;/b&gt; ovenfor for å aktivere denne innstillingen.</string>
<string name="custom_dns_footer">Aktiver for å legge til minst én DNS-server.</string>
<string name="custom_dns_hint">Skriv inn IP</string>
+ <string name="custom_list_error_list_exists">Navn allerede i bruk.</string>
+ <string name="custom_lists">Tilpassede lister</string>
<string name="custom_port_dialog_placeholder">Skriv inn port</string>
<string name="custom_port_dialog_remove">Fjern tilpasset port</string>
<string name="custom_port_dialog_submit">Konfigurer port</string>
<string name="custom_port_dialog_title">Tilpasset WireGuard-port</string>
<string name="custom_port_dialog_valid_ranges">Gyldige verdiområder: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Kunne ikke løse vert for egendefinert tunnel. Forsøk å endre innstillingene dine.</string>
+ <string name="delete">Slett</string>
+ <string name="delete_list">Slett liste</string>
<string name="device_inactive_description">Du har fjernet denne enheten. For å koble til igjen, må du logge inn på nytt.</string>
<string name="device_inactive_title">Enheten er inaktiv</string>
<string name="device_inactive_unblock_warning">Å gå til pålogging vil oppheve blokkeringen av internettet på denne enheten.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Kun leid</string>
<string name="report_a_problem">Rapporter et problem</string>
<string name="reset_to_default_button">Tilbakestill til standard</string>
+ <string name="save">Lagre</string>
<string name="search_placeholder">Søk etter ...</string>
<string name="secure_connection">SIKKER TILKOBLING</string>
<string name="secured">Sikret</string>
diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml
index 63d878951f..eeae7d2718 100644
--- a/android/lib/resource/src/main/res/values-nl/strings.xml
+++ b/android/lib/resource/src/main/res/values-nl/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s is toegevoegd aan uw account.</string>
<string name="agree_and_continue">Akkoord en doorgaan</string>
<string name="all_applications">Alle toepassingen</string>
+ <string name="all_locations">Alle locaties</string>
<string name="all_providers">Alle aanbieders</string>
<string name="allow_lan_footer">Biedt toegang tot andere apparaten op hetzelfde netwerk voor delen, afdrukken en dergelijke</string>
<string name="always_on_vpn_error_notification_content">Kan de tunnelverbinding niet starten. Schakel Altijd-aan VPN uit voor &lt;b&gt;%1$s&lt;/b&gt; voordat u Mullvad VPN gebruikt.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Schakel alle &lt;b&gt;%1$s&lt;/b&gt; hierboven uit om deze instelling te activeren.</string>
<string name="custom_dns_footer">Schakel in om minimaal één DNS-server toe te voegen.</string>
<string name="custom_dns_hint">Voer IP-adres in</string>
+ <string name="custom_list_error_list_exists">Naam wordt al gebruikt.</string>
+ <string name="custom_lists">Aangepaste lijsten</string>
<string name="custom_port_dialog_placeholder">Voer poort in</string>
<string name="custom_port_dialog_remove">Aangepaste poort verwijderen</string>
<string name="custom_port_dialog_submit">Poort instellen</string>
<string name="custom_port_dialog_title">Aangepaste WireGuard-poort</string>
<string name="custom_port_dialog_valid_ranges">Geldige bereiken: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Kan host van aangepaste tunnel niet omzetten. Probeer uw instellingen te wijzigen.</string>
+ <string name="delete">Verwijderen</string>
+ <string name="delete_list">Lijst verwijderen</string>
<string name="device_inactive_description">U hebt dit apparaat verwijderd. U moet zich opnieuw aanmelden om het opnieuw te verbinden.</string>
<string name="device_inactive_title">Apparaat is niet actief</string>
<string name="device_inactive_unblock_warning">Als u naar aanmelden gaat, wordt het blokkeren van internet op dit apparaat opgeheven.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Alleen gehuurde</string>
<string name="report_a_problem">Een probleem rapporteren</string>
<string name="reset_to_default_button">Standaardwaarde herstellen</string>
+ <string name="save">Opslaan</string>
<string name="search_placeholder">Zoeken naar...</string>
<string name="secure_connection">BEVEILIGDE VERBINDING</string>
<string name="secured">Beveiligd</string>
diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml
index aa1f500c88..c03157ab0b 100644
--- a/android/lib/resource/src/main/res/values-pl/strings.xml
+++ b/android/lib/resource/src/main/res/values-pl/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">Do Twojego konta dodano %1$s.</string>
<string name="agree_and_continue">Zaakceptuj i kontynuuj</string>
<string name="all_applications">Wszystkie aplikacje</string>
+ <string name="all_locations">Wszystkie lokalizacje</string>
<string name="all_providers">Wszyscy dostawcy</string>
<string name="allow_lan_footer">Umożliwia dostęp do innych urządzeń w tej samej sieci w celu udostępniania, drukowania itd.</string>
<string name="always_on_vpn_error_notification_content">Nie można uruchomić połączenia tunelowego. Przed rozpoczęciem użytkowania usługi Mullvad VPN wyłącz opcję „Zawsze włączony VPN” w &lt;b&gt;%1$s&lt;/b&gt;.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Aby aktywować to ustawienie, wyłącz powyżej wszystkie &lt;b&gt;%1$s&lt;/b&gt;.</string>
<string name="custom_dns_footer">Włącz, aby dodać co najmniej jeden serwer DNS.</string>
<string name="custom_dns_hint">Wprowadź adres IP</string>
+ <string name="custom_list_error_list_exists">Nazwa jest już zajęta.</string>
+ <string name="custom_lists">Listy niestandardowe</string>
<string name="custom_port_dialog_placeholder">Wprowadź port</string>
<string name="custom_port_dialog_remove">Usuń port niestandardowy</string>
<string name="custom_port_dialog_submit">Ustaw port</string>
<string name="custom_port_dialog_title">Niestandardowy port WireGuard</string>
<string name="custom_port_dialog_valid_ranges">Prawidłowe zakresy: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Nie można rozpoznać hosta tunelu niestandardowego. Spróbuj zmienić ustawienia.</string>
+ <string name="delete">Usuń</string>
+ <string name="delete_list">Usuń listę</string>
<string name="device_inactive_description">Urządzenie usunięto. Aby połączyć się ponownie, musisz się ponownie zalogować.</string>
<string name="device_inactive_title">Urządzenie nieaktywne</string>
<string name="device_inactive_unblock_warning">Przejście do logowania odblokuje Internet na tym urządzeniu.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Wyłącznie wynajmowane</string>
<string name="report_a_problem">Zgłoś problem</string>
<string name="reset_to_default_button">Przywróć domyślne</string>
+ <string name="save">Zapisz</string>
<string name="search_placeholder">Wyszukaj...</string>
<string name="secure_connection">BEZPIECZNE POŁĄCZENIE</string>
<string name="secured">Zabezpieczone</string>
diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml
index fe7a1fcfe3..ddecdfde52 100644
--- a/android/lib/resource/src/main/res/values-pt/strings.xml
+++ b/android/lib/resource/src/main/res/values-pt/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s adicionado(s) à sua conta.</string>
<string name="agree_and_continue">Concordar e continuar</string>
<string name="all_applications">Todas as aplicações</string>
+ <string name="all_locations">Todas as localizações</string>
<string name="all_providers">Todos os fornecedores</string>
<string name="allow_lan_footer">Permite o acesso a outros dispositivos na mesma rede para partilha, impressão, etc.</string>
<string name="always_on_vpn_error_notification_content">Não foi possível iniciar a ligação de túnel. Desative a VPN sempre ligada para &lt;b&gt;%1$s&lt;/b&gt; antes de utilizar a Mullvad VPN.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Desative todos os &lt;b&gt;%1$s&lt;/b&gt; abaixo para ativar esta definição.</string>
<string name="custom_dns_footer">Ativar para adicionar pelo menos um servidor DNS.</string>
<string name="custom_dns_hint">Introduzir IP</string>
+ <string name="custom_list_error_list_exists">O nome já está a ser utilizado.</string>
+ <string name="custom_lists">Listas personalizadas</string>
<string name="custom_port_dialog_placeholder">Introduzir porta</string>
<string name="custom_port_dialog_remove">Remover porta personalizada</string>
<string name="custom_port_dialog_submit">Definir porta</string>
<string name="custom_port_dialog_title">Porta personalizada WireGuard</string>
<string name="custom_port_dialog_valid_ranges">Intervalos válidos: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Não foi possível resolver o anfitrião do túnel personalizado. Experimente alterar as suas definições.</string>
+ <string name="delete">Eliminar</string>
+ <string name="delete_list">Eliminar lista</string>
<string name="device_inactive_description">Removeu este dispositivo. Para voltar a ligar o dispositivo, terá de voltar a iniciar a sessão.</string>
<string name="device_inactive_title">O dispositivo está desativado</string>
<string name="device_inactive_unblock_warning">Ir para a ligação irá desbloquear a Internet neste dispositivo.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Apenas alugado</string>
<string name="report_a_problem">Reportar um problema</string>
<string name="reset_to_default_button">Repor para as predefinições</string>
+ <string name="save">Guardar</string>
<string name="search_placeholder">Pesquisar por...</string>
<string name="secure_connection">LIGAÇÃO SEGURA</string>
<string name="secured">Seguro</string>
diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml
index 5f2ad8489c..088ad7625f 100644
--- a/android/lib/resource/src/main/res/values-ru/strings.xml
+++ b/android/lib/resource/src/main/res/values-ru/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">На учетную запись добавлено время: %1$s.</string>
<string name="agree_and_continue">Согласиться и продолжить</string>
<string name="all_applications">Все приложения</string>
+ <string name="all_locations">Все местоположения</string>
<string name="all_providers">Все провайдеры</string>
<string name="allow_lan_footer">Разрешить доступ к другим устройствам в той же сети для организации общего доступа, печати и т. д.</string>
<string name="always_on_vpn_error_notification_content">Не удалось запустить туннельное подключение. Перед использованием Mullvad VPN отключите опцию «Постоянная VPN» для приложения &lt;b&gt;%1$s&lt;/b&gt;.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Чтобы включить эту настройку, отключите все &lt;b&gt;%1$s&lt;/b&gt; выше.</string>
<string name="custom_dns_footer">Чтобы добавить как минимум один DNS-сервер, включите этот параметр.</string>
<string name="custom_dns_hint">Введите IP-адрес</string>
+ <string name="custom_list_error_list_exists">Такое название уже используется.</string>
+ <string name="custom_lists">Свои списки</string>
<string name="custom_port_dialog_placeholder">Введите порт</string>
<string name="custom_port_dialog_remove">Удалить пользовательский порт</string>
<string name="custom_port_dialog_submit">Установить порт</string>
<string name="custom_port_dialog_title">Пользовательский порт WireGuard</string>
<string name="custom_port_dialog_valid_ranges">Допустимые диапазоны: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Не удалось преобразовать имя узла пользовательского туннеля. Попробуйте изменить настройки.</string>
+ <string name="delete">Удалить</string>
+ <string name="delete_list">Удалить список</string>
<string name="device_inactive_description">Вы удалили это устройство. Чтобы снова подключиться, нужно будет выполнить вход.</string>
<string name="device_inactive_title">Устройство неактивно</string>
<string name="device_inactive_unblock_warning">Вход в профиль разблокирует Интернет на этом устройстве.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Только арендованные</string>
<string name="report_a_problem">Сообщение о проблеме</string>
<string name="reset_to_default_button">Восстановить значение по умолчанию</string>
+ <string name="save">Сохранить</string>
<string name="search_placeholder">Поиск...</string>
<string name="secure_connection">ЗАЩИЩЕННОЕ ПОДКЛЮЧЕНИЕ</string>
<string name="secured">Подключение защищено</string>
diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml
index 0addc6598b..f5a5012025 100644
--- a/android/lib/resource/src/main/res/values-sv/strings.xml
+++ b/android/lib/resource/src/main/res/values-sv/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s har lagts till på ditt konto.</string>
<string name="agree_and_continue">Godkänn och fortsätt</string>
<string name="all_applications">Alla applikationer</string>
+ <string name="all_locations">Alla platser</string>
<string name="all_providers">Alla leverantörer</string>
<string name="allow_lan_footer">Tillåter åtkomst till andra enheter på samma nätverk för delning, utskrift osv.</string>
<string name="always_on_vpn_error_notification_content">Det går inte att starta tunnelanslutning. Aktivera VPN som alltid är på för &lt;b&gt;%1$s&lt;/b&gt; innan du använder Mullvad VPN.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Inaktivera alla &lt;b&gt;%1$s&lt;/b&gt; ovan för att aktivera inställningen.</string>
<string name="custom_dns_footer">Aktivera för att lägga till minst en DNS-server.</string>
<string name="custom_dns_hint">Ange IP</string>
+ <string name="custom_list_error_list_exists">Namnet används redan.</string>
+ <string name="custom_lists">Anpassade listor</string>
<string name="custom_port_dialog_placeholder">Ange port</string>
<string name="custom_port_dialog_remove">Ta bort anpassad port</string>
<string name="custom_port_dialog_submit">Ställ in port</string>
<string name="custom_port_dialog_title">Anpassad WireGuard-port</string>
<string name="custom_port_dialog_valid_ranges">Giltiga intervall: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Det går inte att lösa värd för anpassad tunnel. Försök att ändra inställningarna.</string>
+ <string name="delete">Ta bort</string>
+ <string name="delete_list">Ta bort lista</string>
<string name="device_inactive_description">Du har tagit bort den här enheten. Du måste logga in igen för att återansluta.</string>
<string name="device_inactive_title">Enheten är inaktiv</string>
<string name="device_inactive_unblock_warning">Om du loggar in tas blockering av internet bort på den här enheten.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Endast hyrd</string>
<string name="report_a_problem">Rapportera ett problem</string>
<string name="reset_to_default_button">Återställ till standard</string>
+ <string name="save">Spara</string>
<string name="search_placeholder">Sök efter …</string>
<string name="secure_connection">SÄKER ANSLUTNING</string>
<string name="secured">Skyddad</string>
diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml
index fd0e73ffe1..935ca673e4 100644
--- a/android/lib/resource/src/main/res/values-th/strings.xml
+++ b/android/lib/resource/src/main/res/values-th/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s ถูกเพิ่มลงในบัญชีของคุณแล้ว</string>
<string name="agree_and_continue">ยอมรับและดำเนินการต่อ</string>
<string name="all_applications">แอปพลิเคชันทั้งหมด</string>
+ <string name="all_locations">ตำแหน่งที่ตั้งทั้งหมด</string>
<string name="all_providers">ผู้ให้บริการทั้งหมด</string>
<string name="allow_lan_footer">อนุญาตให้เข้าถึงอุปกรณ์อื่นๆ บนเครือข่ายเดียวกัน เพื่อแชร์ พิมพ์ ฯลฯ</string>
<string name="always_on_vpn_error_notification_content">ไม่สามารถเริ่มการเชื่อมต่ออุโมงค์ได้ โปรดปิดใช้งาน Always-on VPN เป็นเวลา &lt;b&gt;%1$s&lt;/b&gt; ก่อนที่จะใช้งาน Mullvad VPN</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">ปิดใช้งาน &lt;b&gt;%1$s&lt;/b&gt; ด้านบนทั้งหมด เพื่อเปิดใช้การตั้งค่านี้</string>
<string name="custom_dns_footer">เปิดเพื่อเพิ่มเซิร์ฟเวอร์ DNS อย่างน้อยหนึ่งรายการ</string>
<string name="custom_dns_hint">ป้อน IP</string>
+ <string name="custom_list_error_list_exists">ชื่อนี้ถูกใช้ไปแล้ว</string>
+ <string name="custom_lists">รายการกำหนดเอง</string>
<string name="custom_port_dialog_placeholder">ป้อนพอร์ต</string>
<string name="custom_port_dialog_remove">นำพอร์ตแบบกำหนดเองออก</string>
<string name="custom_port_dialog_submit">ตั้งค่าพอร์ต</string>
<string name="custom_port_dialog_title">พอร์ต WireGuard แบบกำหนดเอง</string>
<string name="custom_port_dialog_valid_ranges">ช่วงที่ใช้ได้: %1$s</string>
<string name="custom_tunnel_host_resolution_error">ไม่พบโฮสต์ของช่องทางแบบกำหนดเอง กรุณาลองเปลี่ยนการตั้งค่าของคุณ</string>
+ <string name="delete">ลบ</string>
+ <string name="delete_list">ลบรายการ</string>
<string name="device_inactive_description">คุณได้ลบอุปกรณ์เครื่องนี้แล้ว หากต้องการเชื่อมต่ออีกครั้ง คุณจะต้องเข้าสู่ระบบใหม่อีกครั้ง</string>
<string name="device_inactive_title">อุปกรณ์ไม่ได้ใช้งาน</string>
<string name="device_inactive_unblock_warning">การไปที่ส่วนเข้าสู่ระบบจะปลดบล็อกอินเทอร์เน็ตบนอุปกรณ์เครื่องนี้</string>
@@ -194,6 +199,7 @@
<string name="rented_only">เช่าเท่านั้น</string>
<string name="report_a_problem">รายงานปัญหา</string>
<string name="reset_to_default_button">รีเซ็ตเป็นค่าเริ่มต้น</string>
+ <string name="save">บันทึก</string>
<string name="search_placeholder">ค้นหา…</string>
<string name="secure_connection">การเชื่อมต่อที่ปลอดภัย</string>
<string name="secured">ปลอดภัย</string>
diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml
index 120faf329c..4d3a0e4b45 100644
--- a/android/lib/resource/src/main/res/values-tr/strings.xml
+++ b/android/lib/resource/src/main/res/values-tr/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">Hesabınıza %1$s eklendi.</string>
<string name="agree_and_continue">Kabul et ve devam et</string>
<string name="all_applications">Tüm uygulamalar</string>
+ <string name="all_locations">Tüm konumlar</string>
<string name="all_providers">Tüm hizmet sağlayıcılar</string>
<string name="allow_lan_footer">Paylaşım, yazdırma gibi özellikler için aynı ağdaki diğer cihazlara erişim izni verir.</string>
<string name="always_on_vpn_error_notification_content">Tünel bağlantısı başlatılamıyor. Mullvad VPN\'i kullanmadan önce lütfen Her zaman açık VPN\'i &lt;b&gt;%1$s&lt;/b&gt; için devre dışı bırakın.</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">Bu ayarı etkinleştirmek için yukarıdaki &lt;b&gt;%1$s&lt;/b&gt; öğelerinin tümünü devre dışı bırakın.</string>
<string name="custom_dns_footer">En az bir DNS sunucusu eklemek için etkinleştirin.</string>
<string name="custom_dns_hint">IP\'yi girin</string>
+ <string name="custom_list_error_list_exists">İsim zaten kullanılıyor.</string>
+ <string name="custom_lists">Özel listeler</string>
<string name="custom_port_dialog_placeholder">Portu girin</string>
<string name="custom_port_dialog_remove">Özel portu kaldır</string>
<string name="custom_port_dialog_submit">Portu ayarla</string>
<string name="custom_port_dialog_title">WireGuard özel portu</string>
<string name="custom_port_dialog_valid_ranges">Geçerli aralıklar: %1$s</string>
<string name="custom_tunnel_host_resolution_error">Özel tünel ana bilgisayarı çözülemedi. Ayarlarınızı değiştirmeyi deneyin.</string>
+ <string name="delete">Sil</string>
+ <string name="delete_list">Listeyi sil</string>
<string name="device_inactive_description">Bu cihazı kaldırdın. Tekrar bağlanmak için yeniden giriş yapmanız gerekecek.</string>
<string name="device_inactive_title">Cihaz etkin değil</string>
<string name="device_inactive_unblock_warning">Giriş yapmak bu cihazdaki internet engelini kaldıracaktır.</string>
@@ -194,6 +199,7 @@
<string name="rented_only">Sadece kiralananlar</string>
<string name="report_a_problem">Bir sorun bildir</string>
<string name="reset_to_default_button">Varsayılana sıfırla</string>
+ <string name="save">Kaydet</string>
<string name="search_placeholder">Ara...</string>
<string name="secure_connection">GÜVENLİ BAĞLANTI</string>
<string name="secured">Güvenli</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
index 6fcf9acc4d..f9e3e30ea6 100644
--- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s已添加到您的帐户中。</string>
<string name="agree_and_continue">同意并继续</string>
<string name="all_applications">所有应用程序</string>
+ <string name="all_locations">所有位置</string>
<string name="all_providers">所有提供商</string>
<string name="allow_lan_footer">允许访问同一网络上的其他设备,以进行共享、打印等</string>
<string name="always_on_vpn_error_notification_content">无法启动隧道连接。在使用 Mullvad VPN 之前,请为 &lt;b&gt;%1$s&lt;/b&gt; 禁用“始终开启的 VPN”。</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">禁用上方的所有 &lt;b&gt;%1$s&lt;/b&gt;以激活此设置。</string>
<string name="custom_dns_footer">启用以添加至少一个 DNS 服务器。</string>
<string name="custom_dns_hint">输入 IP</string>
+ <string name="custom_list_error_list_exists">名称已被占用。</string>
+ <string name="custom_lists">自定义列表</string>
<string name="custom_port_dialog_placeholder">输入端口</string>
<string name="custom_port_dialog_remove">移除自定义端口</string>
<string name="custom_port_dialog_submit">设置端口</string>
<string name="custom_port_dialog_title">WireGuard 自定义端口</string>
<string name="custom_port_dialog_valid_ranges">有效范围:%1$s</string>
<string name="custom_tunnel_host_resolution_error">无法解析自定义隧道的主机。请尝试更改您的设置。</string>
+ <string name="delete">删除</string>
+ <string name="delete_list">删除列表</string>
<string name="device_inactive_description">您已移除此设备。要重新连接,您需要重新登录。</string>
<string name="device_inactive_title">设备处于非活动状态</string>
<string name="device_inactive_unblock_warning">前往登录将在此设备上解除阻止互联网。</string>
@@ -194,6 +199,7 @@
<string name="rented_only">仅租用</string>
<string name="report_a_problem">报告问题</string>
<string name="reset_to_default_button">重置为默认值</string>
+ <string name="save">保存</string>
<string name="search_placeholder">搜索…</string>
<string name="secure_connection">安全连接</string>
<string name="secured">已受保护</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
index c7e5f7f137..a250e018e4 100644
--- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
@@ -15,6 +15,7 @@
<string name="added_to_your_account">%1$s已新增至您的帳戶。</string>
<string name="agree_and_continue">同意並繼續</string>
<string name="all_applications">所有應用程式</string>
+ <string name="all_locations">所有位置</string>
<string name="all_providers">所有供應商</string>
<string name="allow_lan_footer">允許存取同一網路上的其他裝置,以進行分享、列印等。</string>
<string name="always_on_vpn_error_notification_content">無法啟動通道連線。在使用 Mullvad VPN 之前,請先為 &lt;b&gt;%1$s&lt;/b&gt; 停用「始終啟用 VPN」。</string>
@@ -68,12 +69,16 @@
<string name="custom_dns_disable_mode_subtitle">停用上方所有 &lt;b&gt;%1$s&lt;/b&gt;以啟動此設定。</string>
<string name="custom_dns_footer">啟用以新增至少一個 DNS 伺服器。</string>
<string name="custom_dns_hint">輸入 IP</string>
+ <string name="custom_list_error_list_exists">名稱已在別處使用。</string>
+ <string name="custom_lists">自訂清單</string>
<string name="custom_port_dialog_placeholder">輸入連接埠</string>
<string name="custom_port_dialog_remove">移除自訂連接埠</string>
<string name="custom_port_dialog_submit">設定連接埠</string>
<string name="custom_port_dialog_title">WireGuard 自訂連接埠</string>
<string name="custom_port_dialog_valid_ranges">有效範圍:%1$s</string>
<string name="custom_tunnel_host_resolution_error">無法解析自訂通道的主機。請嘗試變更您的設定。</string>
+ <string name="delete">刪除</string>
+ <string name="delete_list">刪除清單</string>
<string name="device_inactive_description">您已移除此裝置。若要重新連線,您需要重新登入。</string>
<string name="device_inactive_title">裝置處於非活動狀態</string>
<string name="device_inactive_unblock_warning">若前往登入,則會在此裝置上解除對網際網路的封鎖。</string>
@@ -194,6 +199,7 @@
<string name="rented_only">僅租用</string>
<string name="report_a_problem">回報問題</string>
<string name="reset_to_default_button">重設為預設值</string>
+ <string name="save">儲存</string>
<string name="search_placeholder">搜尋…</string>
<string name="secure_connection">安全連線</string>
<string name="secured">安全</string>
diff --git a/android/lib/resource/src/main/res/values/plurals.xml b/android/lib/resource/src/main/res/values/plurals.xml
index 455d42c1f2..cd913ffa05 100644
--- a/android/lib/resource/src/main/res/values/plurals.xml
+++ b/android/lib/resource/src/main/res/values/plurals.xml
@@ -48,4 +48,8 @@
<item quantity="one">%d month</item>
<item quantity="other">%d months</item>
</plurals>
+ <plurals name="number_of_locations">
+ <item quantity="one">%d location</item>
+ <item quantity="other">%d locations</item>
+ </plurals>
</resources>
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 4400250dbc..88c38adc7f 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -282,4 +282,39 @@
<string name="split_tunneling_disabled_description">Split tunneling is disabled.</string>
<string name="auto_connect_legacy">Auto-connect (legacy)</string>
<string name="auto_connect_footer_legacy"><![CDATA[Please use the <b>Always-on</b> system setting instead by following the guide in <b>%s</b> above.]]></string>
+ <string name="custom_lists">Custom lists</string>
+ <string name="all_locations">All locations</string>
+ <string name="edit_lists">Edit lists</string>
+ <string name="create_new_list">Create new list</string>
+ <string name="create">Create</string>
+ <string name="no_locations_found">No locations found</string>
+ <string name="add_locations">Add locations</string>
+ <string name="save">Save</string>
+ <string name="edit_custom_lists">Edit custom lists</string>
+ <string name="delete_list">Delete list</string>
+ <string name="list_name">List name</string>
+ <string name="locations">Locations</string>
+ <string name="edit_locations">Edit locations</string>
+ <string name="delete_custom_list_confirmation_description">
+ Delete \"%s\"?
+ </string>
+ <string name="custom_list_error_list_exists">Name is already taken.</string>
+ <string name="update_list_name">Update list name</string>
+ <string name="no_custom_lists_available">No custom lists available</string>
+ <string name="to_create_a_custom_list">To create a custom list press the \"︙\"</string>
+ <string name="new_list">New list</string>
+ <string name="to_add_locations_to_a_list">To add locations to a list, press the \"︙\" or long press on a country, city, or server.</string>
+ <string name="edit_list">Edit list</string>
+ <string name="delete">Delete</string>
+ <string name="delete_custom_list_message">\"%s\" was deleted</string>
+ <string name="undo">Undo</string>
+ <string name="discard_changes">Discard changes?</string>
+ <string name="discard">Discard</string>
+ <string name="add_location_to_list">Add %s to list</string>
+ <string name="location_was_added_to_list">%s was added to \"%s\"</string>
+ <string name="location_added">%s (added)</string>
+ <string name="edit_name">Edit name</string>
+ <string name="name_was_changed_to">Name was changed to %s</string>
+ <string name="locations_were_changed_for">Locations were changed for \"%s\"</string>
+ <string name="not_found">Not found</string>
</resources>
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
index 85d45c4d2b..69096ceccb 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
@@ -33,6 +33,12 @@ import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_onSurfaceVariant
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_onTertiaryContainer
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_primary
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_secondaryContainer
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainer
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerHigh
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerHighest
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerLow
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerLowest
+import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceTint
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceVariant
import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_tertiaryContainer
import net.mullvad.mullvadvpn.lib.theme.dimensions.Dimensions
@@ -89,6 +95,12 @@ private val darkColorScheme =
// surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = Color.Transparent, // Used by divider,
// scrim = md_theme_dark_scrim,
+ surfaceContainerHighest = md_theme_dark_surfaceContainerHighest,
+ surfaceContainerHigh = md_theme_dark_surfaceContainerHigh,
+ surfaceContainerLow = md_theme_dark_surfaceContainerLow,
+ surfaceContainerLowest = md_theme_dark_surfaceContainerLowest,
+ surfaceContainer = md_theme_dark_surfaceContainer,
+ surfaceTint = md_theme_dark_surfaceTint
)
val Shapes =
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt
index 82f924ebe0..01959b7934 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt
@@ -21,6 +21,7 @@ const val AlphaDescription = 0.6f
const val AlphaDisconnectButton = 0.6f
const val AlphaChevron = 0.6f
const val AlphaScrollbar = 0.6f
+const val Alpha60 = 0.6f
const val AlphaTopBar = 0.8f
const val AlphaInvisible = 0f
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt
index 413a37f93e..1915cc911a 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt
@@ -61,6 +61,11 @@ internal val md_theme_dark_outline = Color(0xFF8D9199) // Generated
internal val md_theme_dark_inverseOnSurface = Color(0xFFFFFFFF) // MullvadWhite
internal val md_theme_dark_inverseSurface = Color(0xFFFFFFFF) // MullvadWhite
internal val md_theme_dark_inversePrimary = Color(0xFF0561A3) // Generated
-internal val md_theme_dark_surfaceTint = Color(0xFF9FCAFF) // Generated
+internal val md_theme_dark_surfaceTint = Color(0xFF233953) // Custom list disabled
internal val md_theme_dark_outlineVariant = Color(0xFF43474E) // Generated
internal val md_theme_dark_scrim = Color(0xFF000000) // Generated
+internal val md_theme_dark_surfaceContainerHighest = Color(0xFF234161) // Relay list depth 0
+internal val md_theme_dark_surfaceContainerHigh = Color(0xFF1F3A57) // Relay list depth 1
+internal val md_theme_dark_surfaceContainerLow = Color(0xFF1C344E) // Relay list depth 2
+internal val md_theme_dark_surfaceContainerLowest = Color(0xFF1B314A) // Relay list depth 3
+internal val md_theme_dark_surfaceContainer = Color(0xFF192638) // Alert Blue
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
index ef6b04146e..2763033a30 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
@@ -16,8 +16,10 @@ data class Dimensions(
val cellEndPadding: Dp = 16.dp,
val cellFooterTopPadding: Dp = 6.dp,
val cellHeight: Dp = 56.dp,
+ val cellHeightTwoRows: Dp = 72.dp,
val cellLabelVerticalPadding: Dp = 14.dp,
val cellStartPadding: Dp = 22.dp,
+ val cellStartPaddingInteractive: Dp = 14.dp,
val cellTopPadding: Dp = 6.dp,
val cellVerticalSpacing: Dp = 14.dp,
val checkBoxSize: Dp = 24.dp,
@@ -36,7 +38,8 @@ data class Dimensions(
val customPortBoxMinWidth: Dp = 80.dp,
val deleteIconSize: Dp = 24.dp,
val dialogIconHeight: Dp = 44.dp,
- val dialogIconSize: Dp = 48.dp,
+ val dropdownMenuVerticalPadding: Dp = 8.dp, // Used to remove padding from dropdown menu
+ val dropdownMenuBorder: Dp = 1.dp,
val expandableCellChevronSize: Dp = 30.dp,
val filterTittlePadding: Dp = 4.dp,
val iconFailSuccessTopMargin: Dp = 30.dp,
@@ -61,6 +64,7 @@ data class Dimensions(
val progressIndicatorSize: Dp = 48.dp,
val relayCircleSize: Dp = 16.dp,
val relayRowPadding: Dp = 50.dp,
+ val relayRowPaddingExtra: Dp = 66.dp,
val screenVerticalMargin: Dp = 22.dp,
val searchFieldHeight: Dp = 42.dp,
val searchFieldHorizontalPadding: Dp = 22.dp,
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt
index 501cb72946..aa2f40782c 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt
@@ -11,3 +11,9 @@ val Shapes.chipShape: Shape
get() {
return RoundedCornerShape(8.dp)
}
+
+val Shapes.fabShape: Shape
+ @Composable
+ get() {
+ return RoundedCornerShape(16.dp)
+ }
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 d09287fbf2..f99d36c679 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
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.asSharedFlow
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
@@ -24,6 +25,7 @@ import net.mullvad.mullvadvpn.model.RemoveDeviceEvent
import net.mullvad.mullvadvpn.model.RemoveDeviceResult
import net.mullvad.mullvadvpn.model.Settings
import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.model.UpdateCustomListResult
import net.mullvad.mullvadvpn.model.VoucherSubmissionResult
import net.mullvad.talpid.util.EventNotifier
@@ -190,17 +192,15 @@ class MullvadDaemon(
setQuantumResistantTunnel(daemonInterfaceAddress, quantumResistant)
}
- fun createCustomList(name: String): String {
- return createCustomList(daemonInterfaceAddress, name)
- }
+ fun createCustomList(name: String): CreateCustomListResult =
+ createCustomList(daemonInterfaceAddress, name)
fun deleteCustomList(id: String) {
deleteCustomList(daemonInterfaceAddress, id)
}
- fun updateCustomList(customList: CustomList) {
+ fun updateCustomList(customList: CustomList): UpdateCustomListResult =
updateCustomList(daemonInterfaceAddress, customList)
- }
fun onDestroy() {
onSettingsChange.unsubscribeAll()
@@ -311,11 +311,17 @@ class MullvadDaemon(
// Used by JNI
- private external fun createCustomList(daemonInterfaceAddress: Long, name: String): String
+ 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)
+ private external fun updateCustomList(
+ daemonInterfaceAddress: Long,
+ customList: CustomList
+ ): UpdateCustomListResult
@Suppress("unused")
private fun notifyAppVersionInfoEvent(appVersionInfo: AppVersionInfo) {
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
index d80bcf04ff..39702398c7 100644
--- 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
@@ -9,6 +9,7 @@ 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,
@@ -34,13 +35,18 @@ class CustomLists(
scope.launch {
endpoint.dispatcher.parsedMessages
.filterIsInstance<Request.UpdateCustomList>()
- .collect { daemon.await().updateCustomList(it.customList) }
+ .collect { updateCustomList(it.customList) }
}
}
private suspend fun createCustomList(name: String) {
val result = daemon.await().createCustomList(name)
- endpoint.sendEvent(Event.CreateCustomListResult(result))
+ endpoint.sendEvent(Event.CreateCustomListResultEvent(result))
+ }
+
+ private suspend fun updateCustomList(customList: CustomList) {
+ val result = daemon.await().updateCustomList(customList)
+ endpoint.sendEvent(Event.UpdateCustomListResultEvent(result))
}
fun onDestroy() {