summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2025-04-15 15:59:12 +0200
committerKalle Lindström <karl.lindstrom@mullvad.net>2025-04-22 12:12:12 +0200
commit926468949b2facb49182c1a5a7597b720f9e5cc7 (patch)
tree30950262e3f6adb784a12d89f8c8def37a604356 /android
parent3a58848059a1fb8c429a49acff9e2f57b1d91fd2 (diff)
downloadmullvadvpn-926468949b2facb49182c1a5a7597b720f9e5cc7.tar.xz
mullvadvpn-926468949b2facb49182c1a5a7597b720f9e5cc7.zip
Implement manage devices screen
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt4
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt157
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/util/Matcher.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt149
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CircularProgressIndicator.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/DeviceListItem.kt66
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateAccountConfirmationDialog.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DaitaDirectOnlyConfirmationDialog.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ManageDevicesRemoveConfirmationDialog.kt69
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoConfirmationDialog.kt33
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt41
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt41
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt187
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ManageDevicesUiState.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt23
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt84
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/utils/AppendTextWithStyledSubstringTest.kt96
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt147
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml5
33 files changed, 1095 insertions, 170 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
index 88b783df83..6f7b848e8a 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
@@ -41,9 +41,9 @@ class AccountScreenTest {
onManageAccountClick: () -> Unit = {},
onLogoutClick: () -> Unit = {},
onPurchaseBillingProductClick: (productId: ProductId) -> Unit = {},
- navigateToDeviceInfo: () -> Unit = {},
navigateToVerificationPendingDialog: () -> Unit = {},
onBackClick: () -> Unit = {},
+ onManageDevicesClick: () -> Unit = {},
) {
setContentWithTheme {
AccountScreen(
@@ -53,9 +53,9 @@ class AccountScreenTest {
onManageAccountClick = onManageAccountClick,
onLogoutClick = onLogoutClick,
onPurchaseBillingProductClick = onPurchaseBillingProductClick,
- navigateToDeviceInfo = navigateToDeviceInfo,
navigateToVerificationPendingDialog = navigateToVerificationPendingDialog,
onBackClick = onBackClick,
+ onManageDevicesClick = onManageDevicesClick,
)
}
}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt
new file mode 100644
index 0000000000..a54064b885
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt
@@ -0,0 +1,157 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import de.mannodermaus.junit5.compose.ComposeContext
+import io.mockk.MockKAnnotations
+import io.mockk.mockk
+import io.mockk.verify
+import java.time.ZonedDateTime
+import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension
+import net.mullvad.mullvadvpn.compose.setContentWithTheme
+import net.mullvad.mullvadvpn.compose.state.ManageDevicesItemUiState
+import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState
+import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
+import net.mullvad.mullvadvpn.compose.util.withRole
+import net.mullvad.mullvadvpn.lib.model.Device
+import net.mullvad.mullvadvpn.lib.model.DeviceId
+import net.mullvad.mullvadvpn.lib.model.GetDeviceListError
+import net.mullvad.mullvadvpn.util.Lce
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@ExperimentalTestApi
+class ManageDevicesScreenTest {
+ @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension()
+
+ @BeforeEach
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ private fun ComposeContext.initScreen(
+ state: Lce<ManageDevicesUiState, GetDeviceListError>,
+ snackbarHostState: SnackbarHostState = SnackbarHostState(),
+ onBackClick: () -> Unit = {},
+ onTryAgainClicked: () -> Unit = {},
+ navigateToRemoveDeviceConfirmationDialog: (device: Device) -> Unit = {},
+ ) {
+ setContentWithTheme {
+ ManageDevicesScreen(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ onBackClick = onBackClick,
+ onTryAgainClicked = onTryAgainClicked,
+ navigateToRemoveDeviceConfirmationDialog = navigateToRemoveDeviceConfirmationDialog,
+ )
+ }
+ }
+
+ @Test
+ fun loadingStateShowsProgressIndicator() {
+ composeExtension.use {
+ // Arrange
+ initScreen(state = Lce.Loading)
+
+ // Assert
+ onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertIsDisplayed()
+ }
+ }
+
+ @Test
+ fun errorStateShowsErrorMessageAndTryAgainButton() {
+ composeExtension.use {
+ // Arrange
+ val onTryAgainClicked: () -> Unit = mockk(relaxed = true)
+ initScreen(
+ state = Lce.Error(GetDeviceListError.Unknown(Throwable("error"))),
+ onTryAgainClicked = onTryAgainClicked,
+ )
+
+ // Assert
+ onNodeWithText("Failed to fetch list of devices").assertIsDisplayed()
+ onNodeWithText("Try again").assertIsDisplayed()
+ onNodeWithText("Manage devices").assertIsDisplayed()
+
+ // Act
+ onNodeWithText("Try again").performClick()
+
+ // Assert
+ verify(exactly = 1) { onTryAgainClicked.invoke() }
+ }
+ }
+
+ @Test
+ fun contentStateShowsDeviceListCorrectly() {
+ composeExtension.use {
+ // Arrange
+ val device1 =
+ Device(
+ id = DeviceId.fromString("12345678-1234-5678-1234-567812345678"),
+ name = "Laptop",
+ creationDate = ZonedDateTime.now().minusSeconds(100),
+ )
+ val device2 =
+ Device(
+ id = DeviceId.fromString("87654321-1234-5678-1234-567812345678"),
+ name = "My Phone",
+ creationDate = ZonedDateTime.now().minusSeconds(200),
+ )
+
+ val device3 =
+ Device(
+ id = DeviceId.fromString("87654321-4321-5678-1234-567812345678"),
+ name = "Tablet",
+ creationDate = ZonedDateTime.now().minusSeconds(300),
+ )
+
+ val state =
+ ManageDevicesUiState(
+ devices =
+ listOf(
+ ManageDevicesItemUiState(
+ device2,
+ isLoading = false,
+ isCurrentDevice = true,
+ ),
+ ManageDevicesItemUiState(
+ device1,
+ isLoading = false,
+ isCurrentDevice = false,
+ ),
+ ManageDevicesItemUiState(
+ device3,
+ isLoading = true,
+ isCurrentDevice = false,
+ ),
+ )
+ )
+ initScreen(state = Lce.Content(state))
+
+ // Assert
+ onNodeWithText("Manage devices").assertIsDisplayed()
+
+ onNodeWithText("Laptop").assertIsDisplayed()
+ onNodeWithText("Current device").assertIsDisplayed()
+ onNodeWithText("My Phone").assertIsDisplayed()
+ onNodeWithText("Tablet").assertIsDisplayed()
+
+ // We should have 2 visible buttons (the navbar back button and the remove button for
+ // device "Laptop"
+ val buttons = onAllNodes(withRole(Role.Button))
+ buttons.assertCountEquals(2)
+ buttons[0].assertIsDisplayed()
+ buttons[1].assertIsDisplayed()
+
+ // Make sure the device that is loading is displaying the spinner
+ onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertIsDisplayed()
+ }
+ }
+}
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/util/Matcher.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/util/Matcher.kt
new file mode 100644
index 0000000000..636084a335
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/util/Matcher.kt
@@ -0,0 +1,12 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.test.SemanticsMatcher
+
+fun withRole(role: Role): SemanticsMatcher =
+ SemanticsMatcher("${SemanticsProperties.Role.name} == '$role'") {
+ val roleProperty = it.config.getOrNull(SemanticsProperties.Role) ?: false
+ roleProperty == role
+ }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt
index bbe22c9b11..7160f16ba8 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt
@@ -1,20 +1,25 @@
package net.mullvad.mullvadvpn.compose.button
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
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.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorSmall
@@ -62,6 +67,18 @@ private fun PreviewPrimaryButtonDisabled() {
AppTheme { PrimaryButton(onClick = {}, text = "Primary Button", isEnabled = false) }
}
+@Preview
+@Composable
+private fun PreviewTextButtonEnabled() {
+ AppTheme { PrimaryTextButton(onClick = {}, text = "Text Button") }
+}
+
+@Preview
+@Composable
+private fun PreviewTextButtonDisabled() {
+ AppTheme { PrimaryTextButton(onClick = {}, text = "Text Button", isEnabled = false) }
+}
+
@Composable
fun NegativeButton(
onClick: () -> Unit,
@@ -147,6 +164,46 @@ fun PrimaryButton(
}
@Composable
+fun PrimaryTextButton(
+ onClick: () -> Unit,
+ text: String,
+ modifier: Modifier = Modifier,
+ colors: ButtonColors =
+ ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = Alpha20),
+ ),
+ textDecoration: TextDecoration = TextDecoration.None,
+ isEnabled: Boolean = true,
+ isLoading: Boolean = false,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+) {
+ val hasIcon = leadingIcon != null || trailingIcon != null
+ TextButton(
+ onClick = onClick,
+ modifier = modifier.wrapContentHeight().width(IntrinsicSize.Max),
+ colors = colors,
+ enabled = isEnabled,
+ contentPadding =
+ if (hasIcon) {
+ PaddingValues(vertical = Dimens.buttonVerticalPadding)
+ } else {
+ ButtonDefaults.TextButtonContentPadding
+ },
+ shape = MaterialTheme.shapes.small,
+ ) {
+ BaseButtonContent(
+ text = text,
+ textDecoration = textDecoration,
+ isLoading = isLoading,
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon,
+ )
+ }
+}
+
+@Composable
private fun BaseButton(
onClick: () -> Unit,
colors: ButtonColors,
@@ -171,44 +228,58 @@ private fun BaseButton(
modifier = modifier.wrapContentHeight().fillMaxWidth(),
shape = MaterialTheme.shapes.small,
) {
- // Used to center the text
- when {
- leadingIcon != null ->
- Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { leadingIcon() }
- trailingIcon != null ->
- // Used to center the text
- Box(
- modifier =
- Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible)
- ) {
- trailingIcon()
- }
- }
- if (isLoading) {
- MullvadCircularProgressIndicatorSmall()
- } else {
- Text(
- text = text,
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.titleMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f),
- )
- }
- when {
- trailingIcon != null ->
- Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) {
- trailingIcon()
- }
- leadingIcon != null ->
- // Used to center the text
- Box(
- modifier =
- Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible)
- ) {
- leadingIcon()
- }
- }
+ BaseButtonContent(
+ text = text,
+ isLoading = isLoading,
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon,
+ )
+ }
+}
+
+@Composable
+private fun RowScope.BaseButtonContent(
+ text: String,
+ isLoading: Boolean,
+ textDecoration: TextDecoration = TextDecoration.None,
+ leadingIcon: @Composable() (() -> Unit)?,
+ trailingIcon: @Composable() (() -> Unit)?,
+) {
+ when {
+ leadingIcon != null ->
+ Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { leadingIcon() }
+
+ trailingIcon != null ->
+ // Used to center the text
+ Box(
+ modifier = Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible)
+ ) {
+ trailingIcon()
+ }
+ }
+ if (isLoading) {
+ MullvadCircularProgressIndicatorSmall()
+ } else {
+ Text(
+ text = text,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleMedium,
+ textDecoration = textDecoration,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ when {
+ trailingIcon != null ->
+ Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { trailingIcon() }
+
+ leadingIcon != null ->
+ // Used to center the text
+ Box(
+ modifier = Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible)
+ ) {
+ leadingIcon()
+ }
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CircularProgressIndicator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CircularProgressIndicator.kt
index 2e1bf30eab..1c843f0fcd 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CircularProgressIndicator.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CircularProgressIndicator.kt
@@ -10,8 +10,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.Alpha20
@@ -38,7 +40,7 @@ fun MullvadCircularProgressIndicatorLarge(
trackColor: Color = color.copy(alpha = Alpha20),
) {
CircularProgressIndicator(
- modifier.size(Dimens.circularProgressBarLargeSize),
+ modifier.size(Dimens.circularProgressBarLargeSize).testTag(CIRCULAR_PROGRESS_INDICATOR),
color,
Dimens.circularProgressBarLargeStrokeWidth,
trackColor,
@@ -53,7 +55,7 @@ fun MullvadCircularProgressIndicatorMedium(
trackColor: Color = color.copy(alpha = Alpha20),
) {
CircularProgressIndicator(
- modifier.size(Dimens.circularProgressBarMediumSize),
+ modifier.size(Dimens.circularProgressBarMediumSize).testTag(CIRCULAR_PROGRESS_INDICATOR),
color,
Dimens.circularProgressBarMediumStrokeWidth,
trackColor,
@@ -68,7 +70,7 @@ fun MullvadCircularProgressIndicatorSmall(
trackColor: Color = color.copy(alpha = Alpha20),
) {
CircularProgressIndicator(
- modifier.size(Dimens.circularProgressBarSmallSize),
+ modifier.size(Dimens.circularProgressBarSmallSize).testTag(CIRCULAR_PROGRESS_INDICATOR),
color,
Dimens.circularProgressBarSmallStrokeWidth,
trackColor,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/DeviceListItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/DeviceListItem.kt
new file mode 100644
index 0000000000..d8878fd0fc
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/DeviceListItem.kt
@@ -0,0 +1,66 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.cell.TwoRowCell
+import net.mullvad.mullvadvpn.lib.common.util.formatDate
+import net.mullvad.mullvadvpn.lib.model.Device
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.typeface.listItemSubText
+import net.mullvad.mullvadvpn.lib.theme.typeface.listItemText
+
+@Composable
+fun DeviceListItem(
+ device: Device,
+ isLoading: Boolean,
+ isCurrentDevice: Boolean = false,
+ onDeviceRemovalClicked: () -> Unit,
+) {
+ TwoRowCell(
+ titleStyle = MaterialTheme.typography.listItemText,
+ titleColor = MaterialTheme.colorScheme.onPrimary,
+ subtitleStyle = MaterialTheme.typography.listItemSubText,
+ subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ titleText = device.displayName(),
+ subtitleText = stringResource(id = R.string.created_x, device.creationDate.formatDate()),
+ bodyView = {
+ if (isLoading) {
+ MullvadCircularProgressIndicatorMedium(
+ modifier = Modifier.padding(Dimens.smallPadding)
+ )
+ } else if (isCurrentDevice) {
+ Text(
+ modifier = Modifier.padding(Dimens.smallPadding),
+ text = stringResource(R.string.current_device),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ } else {
+ IconButton(onClick = onDeviceRemovalClicked) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = stringResource(id = R.string.remove_button),
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(size = Dimens.deleteIconSize),
+ )
+ }
+ }
+ },
+ onCellClicked = null,
+ endPadding = Dimens.smallPadding,
+ minHeight = Dimens.cellHeight,
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateAccountConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateAccountConfirmationDialog.kt
index b670f7d79e..04f6e92f6b 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateAccountConfirmationDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateAccountConfirmationDialog.kt
@@ -15,6 +15,7 @@ import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
import com.ramcosta.composedestinations.result.ResultBackNavigator
import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed
import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialog
import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialogTitleType
import net.mullvad.mullvadvpn.lib.theme.AppTheme
@@ -28,7 +29,7 @@ private fun PreviewCreateAccountConfirmationDialog() {
@Composable
@Destination<RootGraph>(style = DestinationStyle.Dialog::class)
-fun CreateAccountConfirmation(navigator: ResultBackNavigator<Boolean>) {
+fun CreateAccountConfirmation(navigator: ResultBackNavigator<Confirmed>) {
InfoConfirmationDialog(
navigator = navigator,
titleType = InfoConfirmationDialogTitleType.IconOnly,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DaitaDirectOnlyConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DaitaDirectOnlyConfirmationDialog.kt
index 13c3d04e77..aa8d6aa216 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DaitaDirectOnlyConfirmationDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DaitaDirectOnlyConfirmationDialog.kt
@@ -13,6 +13,7 @@ import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
import com.ramcosta.composedestinations.result.ResultBackNavigator
import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed
import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialog
import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialogTitleType
import net.mullvad.mullvadvpn.lib.theme.AppTheme
@@ -25,7 +26,7 @@ private fun PreviewDaitaDirectOnlyConfirmationDialog() {
@Destination<RootGraph>(style = DestinationStyle.Dialog::class)
@Composable
-fun DaitaDirectOnlyConfirmation(navigator: ResultBackNavigator<Boolean>) {
+fun DaitaDirectOnlyConfirmation(navigator: ResultBackNavigator<Confirmed>) {
InfoConfirmationDialog(
navigator = navigator,
titleType = InfoConfirmationDialogTitleType.IconOnly,
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
index 643508792c..a7115ebc05 100644
--- 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
@@ -9,6 +9,7 @@ import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
import com.ramcosta.composedestinations.result.ResultBackNavigator
import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed
import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialog
import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialogTitleType
import net.mullvad.mullvadvpn.lib.theme.AppTheme
@@ -21,7 +22,7 @@ private fun PreviewDiscardChangesDialog() {
@Destination<RootGraph>(style = DestinationStyle.Dialog::class)
@Composable
-fun DiscardChanges(resultBackNavigator: ResultBackNavigator<Boolean>) {
+fun DiscardChanges(resultBackNavigator: ResultBackNavigator<Confirmed>) {
InfoConfirmationDialog(
navigator = resultBackNavigator,
titleType =
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ManageDevicesRemoveConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ManageDevicesRemoveConfirmationDialog.kt
new file mode 100644
index 0000000000..8516860ca8
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ManageDevicesRemoveConfirmationDialog.kt
@@ -0,0 +1,69 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.annotation.RootGraph
+import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
+import com.ramcosta.composedestinations.result.ResultBackNavigator
+import com.ramcosta.composedestinations.spec.DestinationStyle
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.textResource
+import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialog
+import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialogTitleType
+import net.mullvad.mullvadvpn.compose.preview.DevicePreviewParameterProvider
+import net.mullvad.mullvadvpn.lib.model.Device
+import net.mullvad.mullvadvpn.lib.model.DeviceId
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.util.appendTextWithStyledSubstring
+
+@Preview
+@Composable
+private fun PreviewManageDevicesRemoveConfirmationDialog(
+ @PreviewParameter(DevicePreviewParameterProvider::class) device: Device
+) {
+ AppTheme { ManageDevicesRemoveConfirmation(EmptyResultBackNavigator(), device = device) }
+}
+
+@Destination<RootGraph>(style = DestinationStyle.Dialog::class)
+@Composable
+fun ManageDevicesRemoveConfirmation(navigator: ResultBackNavigator<DeviceId>, device: Device) {
+ InfoConfirmationDialog(
+ navigator = navigator,
+ confirmValue = device.id,
+ titleType = InfoConfirmationDialogTitleType.IconOnly,
+ confirmButtonTitle = textResource(R.string.remove_button),
+ cancelButtonTitle = textResource(R.string.cancel),
+ ) {
+ Text(
+ text = device.descriptionText(),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.labelMedium,
+ )
+ }
+}
+
+@Composable
+private fun Device.descriptionText(): AnnotatedString {
+ val line1 =
+ textResource(id = R.string.manage_devices_confirm_removal_description_line1, displayName())
+
+ val line2 = textResource(id = R.string.manage_devices_confirm_removal_description_line2)
+
+ return buildAnnotatedString {
+ appendTextWithStyledSubstring(
+ text = line1,
+ substring = displayName(),
+ substringStyle = SpanStyle(color = Color.White),
+ )
+ appendLine()
+ append(line2)
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt
index f351c95c0e..08ff7bcae0 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt
@@ -106,7 +106,9 @@ fun SaveApiAccessMethodDialog(
)
}
},
- title = { Text(text = state.text(), style = MaterialTheme.typography.headlineSmall) },
+ title = {
+ Text(text = state.descriptionText(), style = MaterialTheme.typography.headlineSmall)
+ },
onDismissRequest = { /*Should not be able to dismiss*/ },
confirmButton = {
PrimaryButton(
@@ -134,7 +136,7 @@ fun SaveApiAccessMethodDialog(
}
@Composable
-private fun SaveApiAccessMethodUiState.text() =
+private fun SaveApiAccessMethodUiState.descriptionText() =
stringResource(
id =
when (testingState) {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoConfirmationDialog.kt
index ef3eb2b48b..0f314dc233 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoConfirmationDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoConfirmationDialog.kt
@@ -1,5 +1,6 @@
package net.mullvad.mullvadvpn.compose.dialog.info
+import android.os.Parcelable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -20,6 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.ramcosta.composedestinations.result.EmptyResultBackNavigator
import com.ramcosta.composedestinations.result.ResultBackNavigator
+import kotlinx.parcelize.Parcelize
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
@@ -64,9 +66,30 @@ sealed interface InfoConfirmationDialogTitleType {
data class IconAndTitle(val title: String) : InfoConfirmationDialogTitleType
}
+@Parcelize data object Confirmed : Parcelable
+
@Composable
fun InfoConfirmationDialog(
- navigator: ResultBackNavigator<Boolean>,
+ navigator: ResultBackNavigator<Confirmed>,
+ titleType: InfoConfirmationDialogTitleType,
+ confirmButtonTitle: String,
+ cancelButtonTitle: String,
+ content: @Composable (() -> Unit)? = null,
+) {
+ InfoConfirmationDialog(
+ navigator = navigator,
+ confirmValue = Confirmed,
+ titleType = titleType,
+ confirmButtonTitle = confirmButtonTitle,
+ cancelButtonTitle = cancelButtonTitle,
+ content = content,
+ )
+}
+
+@Composable
+fun <T> InfoConfirmationDialog(
+ navigator: ResultBackNavigator<T>,
+ confirmValue: T,
titleType: InfoConfirmationDialogTitleType,
confirmButtonTitle: String,
cancelButtonTitle: String,
@@ -87,7 +110,7 @@ fun InfoConfirmationDialog(
}
AlertDialog(
- onDismissRequest = { navigator.navigateBack(false) },
+ onDismissRequest = { navigator.navigateBack() },
title =
if (title != null) {
@Composable { Text(title) }
@@ -126,17 +149,17 @@ fun InfoConfirmationDialog(
null
},
confirmButton = {
- Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) {
+ Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonVerticalPadding)) {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = confirmButtonTitle,
- onClick = { navigator.navigateBack(true) },
+ onClick = { navigator.navigateBack(confirmValue) },
)
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = cancelButtonTitle,
- onClick = { navigator.navigateBack(false) },
+ onClick = { navigator.navigateBack() },
)
}
},
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt
index 82bf05ba64..138706930f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt
@@ -10,7 +10,7 @@ internal object DevicePreviewData {
fun generateDevices(count: Int) =
List(count) { index -> generateDevice(index) }
.mapIndexed { index, device ->
- DeviceItemUiState(device = device, isLoading = index == 0)
+ DeviceItemUiState(device = device, isLoading = index == 1)
}
fun generateDevice(index: Int = 0, id: String = UUID, name: String? = null) =
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt
new file mode 100644
index 0000000000..41c9d14205
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt
@@ -0,0 +1,41 @@
+package net.mullvad.mullvadvpn.compose.preview
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import net.mullvad.mullvadvpn.compose.state.DeviceItemUiState
+import net.mullvad.mullvadvpn.compose.state.ManageDevicesItemUiState
+import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState
+import net.mullvad.mullvadvpn.lib.model.GetDeviceListError
+import net.mullvad.mullvadvpn.util.Lce
+
+class ManageDevicesUiStatePreviewParameterProvider :
+ PreviewParameterProvider<Lce<ManageDevicesUiState, GetDeviceListError>> {
+ override val values =
+ sequenceOf(
+ Lce.Content(
+ ManageDevicesUiState(
+ DevicePreviewData.generateDevices(NUMBER_OF_DEVICES_NORMAL)
+ .toManageDevicesState()
+ )
+ ),
+ Lce.Content(
+ ManageDevicesUiState(
+ DevicePreviewData.generateDevices(NUMBER_OF_DEVICES_TOO_MANY)
+ .toManageDevicesState()
+ )
+ ),
+ Lce.Content(ManageDevicesUiState(emptyList())),
+ Lce.Loading,
+ Lce.Error(GetDeviceListError.Unknown(IllegalStateException("Error"))),
+ )
+
+ private fun List<DeviceItemUiState>.toManageDevicesState() = mapIndexed { index, state ->
+ ManageDevicesItemUiState(
+ device = state.device,
+ isLoading = state.isLoading,
+ isCurrentDevice = index == 0,
+ )
+ }
+}
+
+private const val NUMBER_OF_DEVICES_NORMAL = 4
+private const val NUMBER_OF_DEVICES_TOO_MANY = 5
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
index b771853ad2..c077f72823 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
@@ -8,11 +8,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
@@ -23,6 +19,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -30,8 +27,8 @@ import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.NavGraphs
-import com.ramcosta.composedestinations.generated.destinations.DeviceNameInfoDestination
import com.ramcosta.composedestinations.generated.destinations.LoginDestination
+import com.ramcosta.composedestinations.generated.destinations.ManageDevicesDestination
import com.ramcosta.composedestinations.generated.destinations.PaymentDestination
import com.ramcosta.composedestinations.generated.destinations.RedeemVoucherDestination
import com.ramcosta.composedestinations.generated.destinations.VerificationPendingDestination
@@ -43,6 +40,7 @@ import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.ExternalButton
import net.mullvad.mullvadvpn.compose.button.NegativeButton
+import net.mullvad.mullvadvpn.compose.button.PrimaryTextButton
import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton
import net.mullvad.mullvadvpn.compose.component.CopyableObfuscationView
import net.mullvad.mullvadvpn.compose.component.InformationView
@@ -122,11 +120,14 @@ fun Account(
state = state,
snackbarHostState = snackbarHostState,
onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) },
+ onManageDevicesClick =
+ dropUnlessResumed {
+ state.accountNumber?.let { navigator.navigate(ManageDevicesDestination(it)) }
+ },
onManageAccountClick = vm::onManageAccountClick,
onLogoutClick = vm::onLogoutClick,
onCopyAccountNumber = vm::onCopyAccountNumber,
onBackClick = dropUnlessResumed { navigator.navigateUp() },
- navigateToDeviceInfo = dropUnlessResumed { navigator.navigate(DeviceNameInfoDestination) },
onPurchaseBillingProductClick =
dropUnlessResumed { productId -> navigator.navigate(PaymentDestination(productId)) },
navigateToVerificationPendingDialog =
@@ -142,9 +143,9 @@ fun AccountScreen(
onCopyAccountNumber: (String) -> Unit,
onRedeemVoucherClick: () -> Unit,
onManageAccountClick: () -> Unit,
+ onManageDevicesClick: () -> Unit,
onLogoutClick: () -> Unit,
onPurchaseBillingProductClick: (productId: ProductId) -> Unit,
- navigateToDeviceInfo: () -> Unit,
navigateToVerificationPendingDialog: () -> Unit,
onBackClick: () -> Unit,
) {
@@ -170,7 +171,7 @@ fun AccountScreen(
) {
DeviceNameRow(
deviceName = state.deviceName ?: "",
- onInfoClick = navigateToDeviceInfo,
+ onManageDevicesClick = onManageDevicesClick,
)
AccountNumberRow(
@@ -219,7 +220,7 @@ fun AccountScreen(
}
@Composable
-private fun DeviceNameRow(deviceName: String, onInfoClick: () -> Unit) {
+private fun DeviceNameRow(deviceName: String, onManageDevicesClick: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
style = MaterialTheme.typography.labelMedium,
@@ -229,13 +230,12 @@ private fun DeviceNameRow(deviceName: String, onInfoClick: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
InformationView(content = deviceName, whenMissing = MissingPolicy.SHOW_SPINNER)
- IconButton(onClick = onInfoClick) {
- Icon(
- imageVector = Icons.Default.Info,
- contentDescription = stringResource(id = R.string.more_information),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- }
+ Spacer(modifier = Modifier.weight(1f))
+ PrimaryTextButton(
+ onClick = onManageDevicesClick,
+ text = stringResource(R.string.manage_devices),
+ textDecoration = TextDecoration.Underline,
+ )
}
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt
index f9422d8bcc..662a7a1316 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt
@@ -97,7 +97,6 @@ import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed
import net.mullvad.mullvadvpn.compose.extensions.safeOpenUri
import net.mullvad.mullvadvpn.compose.preview.ConnectUiStatePreviewParameterProvider
import net.mullvad.mullvadvpn.compose.state.ConnectUiState
-import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.CONNECT_CARD_HEADER_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG
@@ -425,8 +424,7 @@ private fun Content(
)
}
}
- .alpha(if (state.showLoading) AlphaVisible else AlphaInvisible)
- .testTag(CIRCULAR_PROGRESS_INDICATOR),
+ .alpha(if (state.showLoading) AlphaVisible else AlphaInvisible),
)
Box(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt
index 12d5bde055..27ff38d671 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt
@@ -29,7 +29,6 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.DiscardChangesDestination
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
@@ -42,14 +41,15 @@ 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.dialog.info.Confirmed
import net.mullvad.mullvadvpn.compose.extensions.animateScrollAndCentralizeItem
import net.mullvad.mullvadvpn.compose.preview.CustomListLocationUiStatePreviewParameterProvider
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.CollectSideEffectWithLifecycle
+import net.mullvad.mullvadvpn.compose.util.OnNavResultValue
import net.mullvad.mullvadvpn.lib.model.CustomListId
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.theme.AppTheme
@@ -78,20 +78,11 @@ data class CustomListLocationsNavArgs(val customListId: CustomListId, val newLis
fun CustomListLocations(
navigator: DestinationsNavigator,
backNavigator: ResultBackNavigator<CustomListActionResultData>,
- discardChangesResultRecipient: ResultRecipient<DiscardChangesDestination, Boolean>,
+ discardChangesResultRecipient: ResultRecipient<DiscardChangesDestination, Confirmed>,
) {
val customListsViewModel = koinViewModel<CustomListLocationsViewModel>()
- discardChangesResultRecipient.onNavResult {
- when (it) {
- NavResult.Canceled -> {}
- is NavResult.Value -> {
- if (it.value) {
- backNavigator.navigateBack()
- }
- }
- }
- }
+ discardChangesResultRecipient.OnNavResultValue { backNavigator.navigateBack() }
CollectSideEffectWithLifecycle(customListsViewModel.uiSideEffect) { sideEffect ->
when (sideEffect) {
@@ -209,9 +200,7 @@ private fun Actions(isSaveEnabled: Boolean, onSaveClick: () -> Unit) {
private fun LazyListScope.loading() {
item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) {
- MullvadCircularProgressIndicatorLarge(
- modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)
- )
+ MullvadCircularProgressIndicatorLarge()
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt
index 0d2a47e026..f4a2cd186a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt
@@ -45,7 +45,6 @@ import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed
import net.mullvad.mullvadvpn.compose.extensions.itemsWithDivider
import net.mullvad.mullvadvpn.compose.preview.CustomListsUiStatePreviewParameterProvider
import net.mullvad.mullvadvpn.compose.state.CustomListsUiState
-import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
import net.mullvad.mullvadvpn.compose.test.NEW_LIST_BUTTON_TEST_TAG
import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately
@@ -156,11 +155,7 @@ fun CustomListsScreen(
}
private fun LazyListScope.loading() {
- item(contentType = ContentType.PROGRESS) {
- MullvadCircularProgressIndicatorLarge(
- modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)
- )
- }
+ item(contentType = ContentType.PROGRESS) { MullvadCircularProgressIndicatorLarge() }
}
private fun LazyListScope.content(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt
index ac95a6c0b6..230ac3a42c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt
@@ -46,6 +46,7 @@ import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed
import net.mullvad.mullvadvpn.compose.state.DaitaUiState
import net.mullvad.mullvadvpn.compose.test.DAITA_SCREEN_TEST_TAG
import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
@@ -78,16 +79,13 @@ private fun PreviewDaitaScreen() {
fun SharedTransitionScope.Daita(
navigator: DestinationsNavigator,
animatedVisibilityScope: AnimatedVisibilityScope,
- daitaConfirmationDialogResult: ResultRecipient<DaitaDirectOnlyConfirmationDestination, Boolean>,
+ daitaConfirmationDialogResult:
+ ResultRecipient<DaitaDirectOnlyConfirmationDestination, Confirmed>,
) {
val viewModel = koinViewModel<DaitaViewModel>()
val state by viewModel.uiState.collectAsStateWithLifecycle()
- daitaConfirmationDialogResult.OnNavResultValue {
- if (it) {
- viewModel.setDirectOnly(true)
- }
- }
+ daitaConfirmationDialogResult.OnNavResultValue { viewModel.setDirectOnly(true) }
DaitaScreen(
state = state,
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 0a9504f46c..fee2b70973 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
@@ -12,11 +12,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
@@ -45,9 +41,8 @@ import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.button.VariantButton
-import net.mullvad.mullvadvpn.compose.cell.TwoRowCell
+import net.mullvad.mullvadvpn.compose.component.DeviceListItem
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
-import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed
@@ -56,15 +51,12 @@ import net.mullvad.mullvadvpn.compose.state.DeviceListUiState
import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition
import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately
-import net.mullvad.mullvadvpn.lib.common.util.formatDate
import net.mullvad.mullvadvpn.lib.model.AccountNumber
import net.mullvad.mullvadvpn.lib.model.Device
import net.mullvad.mullvadvpn.lib.model.DeviceId
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.selected
-import net.mullvad.mullvadvpn.lib.theme.typeface.listItemSubText
-import net.mullvad.mullvadvpn.lib.theme.typeface.listItemText
import net.mullvad.mullvadvpn.viewmodel.DeviceListSideEffect
import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
import org.koin.androidx.compose.koinViewModel
@@ -298,37 +290,6 @@ private fun ColumnScope.DeviceListHeader(state: DeviceListUiState) {
}
@Composable
-private fun DeviceListItem(device: Device, isLoading: Boolean, onDeviceRemovalClicked: () -> Unit) {
- TwoRowCell(
- titleStyle = MaterialTheme.typography.listItemText,
- titleColor = MaterialTheme.colorScheme.onPrimary,
- subtitleStyle = MaterialTheme.typography.listItemSubText,
- subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant,
- titleText = device.displayName(),
- subtitleText = stringResource(id = R.string.created_x, device.creationDate.formatDate()),
- bodyView = {
- if (isLoading) {
- MullvadCircularProgressIndicatorMedium(
- modifier = Modifier.padding(Dimens.smallPadding)
- )
- } else {
- IconButton(onClick = onDeviceRemovalClicked) {
- Icon(
- imageVector = Icons.Default.Clear,
- contentDescription = stringResource(id = R.string.remove_button),
- tint = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.size(size = Dimens.deleteIconSize),
- )
- }
- }
- },
- onCellClicked = null,
- endPadding = Dimens.smallPadding,
- minHeight = Dimens.cellHeight,
- )
-}
-
-@Composable
private fun DeviceListButtonPanel(
state: DeviceListUiState,
onContinueWithLogin: () -> Unit,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt
index b9f2fb6dd3..bbc2a9ed04 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt
@@ -49,6 +49,7 @@ import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.component.textResource
+import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed
import net.mullvad.mullvadvpn.compose.preview.EditApiAccessMethodUiStatePreviewParameterProvider
import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodTypes
import net.mullvad.mullvadvpn.compose.state.EditApiAccessFormData
@@ -110,7 +111,7 @@ fun EditApiAccessMethod(
navigator: DestinationsNavigator,
backNavigator: ResultBackNavigator<Boolean>,
saveApiAccessMethodResultRecipient: ResultRecipient<SaveApiAccessMethodDestination, Boolean>,
- discardChangesResultRecipient: ResultRecipient<DiscardChangesDestination, Boolean>,
+ discardChangesResultRecipient: ResultRecipient<DiscardChangesDestination, Confirmed>,
) {
val viewModel = koinViewModel<EditApiAccessMethodViewModel>()
@@ -160,11 +161,7 @@ fun EditApiAccessMethod(
}
}
- discardChangesResultRecipient.OnNavResultValue { discardChanges ->
- if (discardChanges) {
- navigator.navigateUp()
- }
- }
+ discardChangesResultRecipient.OnNavResultValue { navigator.navigateUp() }
val state by viewModel.uiState.collectAsStateWithLifecycle()
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
index 513bc636d4..32bdf4703b 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt
@@ -44,7 +44,6 @@ import net.mullvad.mullvadvpn.compose.component.SpacedColumn
import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed
import net.mullvad.mullvadvpn.compose.preview.EditCustomListUiStatePreviewParameterProvider
import net.mullvad.mullvadvpn.compose.state.EditCustomListUiState
-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
@@ -139,9 +138,7 @@ fun EditCustomListScreen(
SpacedColumn(modifier = modifier, alignment = Alignment.Top) {
when (state) {
EditCustomListUiState.Loading -> {
- MullvadCircularProgressIndicatorLarge(
- modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)
- )
+ MullvadCircularProgressIndicatorLarge()
}
EditCustomListUiState.NotFound -> {
Text(
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt
index fef29516c5..b4eea1f277 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt
@@ -68,6 +68,7 @@ import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.button.VariantButton
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
+import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed
import net.mullvad.mullvadvpn.compose.preview.LoginUiStatePreviewParameterProvider
import net.mullvad.mullvadvpn.compose.state.LoginError
import net.mullvad.mullvadvpn.compose.state.LoginState
@@ -108,7 +109,7 @@ fun Login(
accountNumber: String? = null,
vm: LoginViewModel = koinViewModel(),
createAccountConfirmationDialogResult:
- ResultRecipient<CreateAccountConfirmationDestination, Boolean>,
+ ResultRecipient<CreateAccountConfirmationDestination, Confirmed>,
) {
val state by vm.uiState.collectAsStateWithLifecycle()
@@ -120,11 +121,7 @@ fun Login(
}
}
- createAccountConfirmationDialogResult.OnNavResultValue { createAccount ->
- if (createAccount) {
- vm.onCreateAccountConfirmed()
- }
- }
+ createAccountConfirmationDialogResult.OnNavResultValue { vm.onCreateAccountConfirmed() }
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt
new file mode 100644
index 0000000000..d6a4e38256
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt
@@ -0,0 +1,187 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.compose.dropUnlessResumed
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.annotation.RootGraph
+import com.ramcosta.composedestinations.generated.destinations.ManageDevicesRemoveConfirmationDestination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.result.ResultRecipient
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.cell.BaseSubtitleCell
+import net.mullvad.mullvadvpn.compose.component.DeviceListItem
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium
+import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
+import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed
+import net.mullvad.mullvadvpn.compose.preview.ManageDevicesUiStatePreviewParameterProvider
+import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState
+import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition
+import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
+import net.mullvad.mullvadvpn.compose.util.OnNavResultValue
+import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately
+import net.mullvad.mullvadvpn.lib.model.Device
+import net.mullvad.mullvadvpn.lib.model.DeviceId
+import net.mullvad.mullvadvpn.lib.model.GetDeviceListError
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.util.Lce
+import net.mullvad.mullvadvpn.viewmodel.ManageDevicesSideEffect
+import net.mullvad.mullvadvpn.viewmodel.ManageDevicesViewModel
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+@Preview("Normal|TooMany|Empty|Loading|Error")
+private fun PreviewDeviceListScreenContent(
+ @PreviewParameter(ManageDevicesUiStatePreviewParameterProvider::class)
+ state: Lce<ManageDevicesUiState, GetDeviceListError>
+) {
+ AppTheme { ManageDevicesScreen(state = state, SnackbarHostState(), {}, {}, {}) }
+}
+
+private typealias StateLce = Lce<ManageDevicesUiState, GetDeviceListError>
+
+@Destination<RootGraph>(style = DefaultTransition::class, navArgs = DeviceListNavArgs::class)
+@Composable
+fun ManageDevices(
+ navigator: DestinationsNavigator,
+ confirmRemoveResultRecipient:
+ ResultRecipient<ManageDevicesRemoveConfirmationDestination, DeviceId>,
+) {
+ val viewModel = koinViewModel<ManageDevicesViewModel>()
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+ confirmRemoveResultRecipient.OnNavResultValue { deviceId ->
+ viewModel.removeDevice(deviceIdToRemove = deviceId)
+ }
+
+ val snackbarHostState = remember { SnackbarHostState() }
+ val context = LocalContext.current
+ CollectSideEffectWithLifecycle(
+ viewModel.uiSideEffect,
+ minActiveState = Lifecycle.State.RESUMED,
+ ) { sideEffect ->
+ when (sideEffect) {
+ ManageDevicesSideEffect.FailedToRemoveDevice -> {
+ launch {
+ snackbarHostState.showSnackbarImmediately(
+ message = context.getString(R.string.failed_to_remove_device)
+ )
+ }
+ }
+ }
+ }
+
+ ManageDevicesScreen(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ onBackClick = dropUnlessResumed { navigator.navigateUp() },
+ onTryAgainClicked = viewModel::fetchDevices,
+ navigateToRemoveDeviceConfirmationDialog =
+ dropUnlessResumed<Device> {
+ navigator.navigate(ManageDevicesRemoveConfirmationDestination(it))
+ },
+ )
+}
+
+@Composable
+fun ManageDevicesScreen(
+ state: StateLce,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
+ onBackClick: () -> Unit,
+ onTryAgainClicked: () -> Unit,
+ navigateToRemoveDeviceConfirmationDialog: (device: Device) -> Unit,
+) {
+ ScaffoldWithMediumTopBar(
+ appBarTitle = stringResource(id = R.string.manage_devices),
+ navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) },
+ snackbarHostState = snackbarHostState,
+ ) { modifier ->
+ when (state) {
+ is Lce.Content ->
+ Content(modifier, state.value, navigateToRemoveDeviceConfirmationDialog)
+ is Lce.Error -> Error(modifier, onTryAgainClicked)
+ Lce.Loading -> Loading(modifier)
+ }
+ }
+}
+
+@Composable
+private fun Content(
+ modifier: Modifier,
+ state: ManageDevicesUiState,
+ navigateToRemoveDeviceConfirmationDialog: (device: Device) -> Unit,
+) {
+ Column(modifier) {
+ BaseSubtitleCell(
+ text = stringResource(R.string.manage_devices_description),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ ManageDevicesItems(
+ state = state,
+ navigateToRemoveDeviceConfirmationDialog = navigateToRemoveDeviceConfirmationDialog,
+ )
+ }
+}
+
+@Composable
+private fun Error(modifier: Modifier, tryAgain: () -> Unit) {
+ Column(modifier, verticalArrangement = Arrangement.Center) {
+ Text(
+ text = stringResource(id = R.string.failed_to_fetch_devices),
+ modifier = Modifier.padding(Dimens.smallPadding).align(Alignment.CenterHorizontally),
+ )
+ PrimaryButton(
+ onClick = tryAgain,
+ text = stringResource(id = R.string.try_again),
+ modifier =
+ Modifier.padding(
+ top = Dimens.buttonSpacing,
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ ),
+ )
+ }
+}
+
+@Composable
+private fun Loading(modifier: Modifier) {
+ Box(modifier, contentAlignment = Alignment.Center) {
+ MullvadCircularProgressIndicatorMedium(modifier = Modifier.padding(Dimens.smallPadding))
+ }
+}
+
+@Composable
+private fun ManageDevicesItems(
+ state: ManageDevicesUiState,
+ navigateToRemoveDeviceConfirmationDialog: (Device) -> Unit,
+) {
+ state.devices.forEachIndexed { index, (device, loading, isCurrentDevice) ->
+ DeviceListItem(device = device, isLoading = loading, isCurrentDevice = isCurrentDevice) {
+ navigateToRemoveDeviceConfirmationDialog(device)
+ }
+ if (state.devices.lastIndex != index) {
+ HorizontalDivider()
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt
index d08a902ae3..33113b03c9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt
@@ -15,7 +15,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
@@ -30,7 +29,6 @@ import net.mullvad.mullvadvpn.compose.extensions.animateScrollAndCentralizeItem
import net.mullvad.mullvadvpn.compose.state.RelayListItem
import net.mullvad.mullvadvpn.compose.state.RelayListType
import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState
-import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -99,9 +97,7 @@ fun SelectLocationList(
}
private fun LazyListScope.loading() {
- item(contentType = ContentType.PROGRESS) {
- MullvadCircularProgressIndicatorLarge(Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR))
- }
+ item(contentType = ContentType.PROGRESS) { MullvadCircularProgressIndicatorLarge() }
}
private fun LazyListScope.entryBlocked(openDaitaSettings: () -> Unit) {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ManageDevicesUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ManageDevicesUiState.kt
new file mode 100644
index 0000000000..ce57cf46b5
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ManageDevicesUiState.kt
@@ -0,0 +1,11 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.lib.model.Device
+
+data class ManageDevicesUiState(val devices: List<ManageDevicesItemUiState>)
+
+data class ManageDevicesItemUiState(
+ val device: Device,
+ val isLoading: Boolean,
+ val isCurrentDevice: Boolean,
+)
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 0e86fa0f55..776a36ccb7 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
@@ -82,6 +82,7 @@ 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.ManageDevicesViewModel
import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel
import net.mullvad.mullvadvpn.viewmodel.MullvadAppViewModel
import net.mullvad.mullvadvpn.viewmodel.MultihopViewModel
@@ -223,6 +224,7 @@ val uiModule = module {
)
}
viewModel { DeviceListViewModel(get(), get()) }
+ viewModel { ManageDevicesViewModel(get(), get()) }
viewModel { DeviceRevokedViewModel(get(), get()) }
viewModel { MtuDialogViewModel(get(), get()) }
viewModel { DnsDialogViewModel(get(), get(), get(), get()) }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt
index 8be59ef814..c16aa9242f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt
@@ -7,21 +7,19 @@ sealed interface Lce<out T, out E> {
data class Error<E>(val error: E) : Lce<Nothing, E>
- fun content(): T? =
+ fun contentOrNull(): T? =
when (this) {
is Loading,
is Error -> null
is Content -> value
}
- fun error(): E? =
+ fun errorOrNull(): E? =
when (this) {
is Loading,
is Content -> null
is Error -> error
}
-
- fun isLoading(): Boolean = this is Loading
}
fun <T, E> T.toLce(): Lce<T, E> = Lce.Content(this)
@@ -31,13 +29,11 @@ sealed interface Lc<out T> {
data class Content<T>(val value: T) : Lc<T>
- fun content(): T? =
+ fun contentOrNull(): T? =
when (this) {
is Content -> value
Loading -> null
}
-
- fun isLoading(): Boolean = this is Loading
}
fun <T> T.toLc(): Lc<T> = Lc.Content(this)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt
index adc64ec41e..a1cc49811a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt
@@ -1,6 +1,9 @@
package net.mullvad.mullvadvpn.util
import android.text.Html
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.withStyle
import androidx.core.text.HtmlCompat
fun String.appendHideNavOnPlayBuild(isPlayBuild: Boolean): String =
@@ -14,3 +17,23 @@ fun String.removeHtmlTags(): String =
Html.fromHtml(this, HtmlCompat.FROM_HTML_MODE_LEGACY).toString()
fun List<String>.trimAll() = map { it.trim() }
+
+/**
+ * Appends `text` and styles occurrences of `substring` in `text` with the given `substringStyle`.
+ */
+fun AnnotatedString.Builder.appendTextWithStyledSubstring(
+ text: String,
+ substring: String,
+ substringStyle: SpanStyle,
+ ignoreCase: Boolean = false,
+ limit: Int = 0,
+) {
+ val parts = text.split(substring, ignoreCase = ignoreCase, limit = limit)
+
+ parts.forEachIndexed { index, part ->
+ append(part)
+ if (index != parts.lastIndex) {
+ withStyle(substringStyle) { append(substring) }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt
new file mode 100644
index 0000000000..9e8dfde0a8
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt
@@ -0,0 +1,84 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import net.mullvad.mullvadvpn.compose.state.DeviceItemUiState
+import net.mullvad.mullvadvpn.compose.state.DeviceListUiState
+import net.mullvad.mullvadvpn.compose.state.ManageDevicesItemUiState
+import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState
+import net.mullvad.mullvadvpn.lib.model.Device
+import net.mullvad.mullvadvpn.lib.model.DeviceId
+import net.mullvad.mullvadvpn.lib.model.DeviceState
+import net.mullvad.mullvadvpn.lib.model.GetDeviceListError
+import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
+import net.mullvad.mullvadvpn.util.Lce
+import net.mullvad.mullvadvpn.util.toLce
+
+class ManageDevicesViewModel(
+ deviceRepository: DeviceRepository,
+ private val deviceListViewModel: DeviceListViewModel,
+) : ViewModel() {
+
+ val uiSideEffect =
+ deviceListViewModel.uiSideEffect
+ .filter { it is DeviceListSideEffect.FailedToRemoveDevice }
+ .map { ManageDevicesSideEffect.FailedToRemoveDevice }
+
+ val uiState: StateFlow<Lce<ManageDevicesUiState, GetDeviceListError>> =
+ combine(
+ deviceRepository.deviceState.filterIsInstance<DeviceState.LoggedIn>(),
+ deviceListViewModel.uiState,
+ ) { loggedInState, deviceListState ->
+ when (deviceListState) {
+ DeviceListUiState.Loading -> Lce.Loading
+ is DeviceListUiState.Error -> Lce.Error(deviceListState.error)
+ is DeviceListUiState.Content -> {
+ ManageDevicesUiState(
+ deviceListState.devices.toManageDevicesItemUiState(
+ currentDevice = loggedInState.device
+ )
+ )
+ .toLce()
+ }
+ }
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Lce.Loading)
+
+ fun fetchDevices() = deviceListViewModel.fetchDevices()
+
+ fun removeDevice(deviceIdToRemove: DeviceId) =
+ deviceListViewModel.removeDevice(deviceIdToRemove)
+
+ private fun List<DeviceItemUiState>.toManageDevicesItemUiState(
+ currentDevice: Device
+ ): List<ManageDevicesItemUiState> {
+ // Put the current device first in the list, but otherwise keep the sort order.
+ val devices = toMutableList()
+ devices
+ .indexOfFirst { it.device == currentDevice }
+ .let { index ->
+ if (index > 0) {
+ devices.add(0, devices.removeAt(index))
+ }
+ }
+
+ return devices.map {
+ ManageDevicesItemUiState(
+ device = it.device,
+ isLoading = it.isLoading,
+ isCurrentDevice = it.device == currentDevice,
+ )
+ }
+ }
+}
+
+sealed interface ManageDevicesSideEffect {
+ data object FailedToRemoveDevice : ManageDevicesSideEffect
+}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/utils/AppendTextWithStyledSubstringTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/utils/AppendTextWithStyledSubstringTest.kt
new file mode 100644
index 0000000000..90024be351
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/utils/AppendTextWithStyledSubstringTest.kt
@@ -0,0 +1,96 @@
+package net.mullvad.mullvadvpn.utils
+
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import kotlin.test.assertEquals
+import net.mullvad.mullvadvpn.util.appendTextWithStyledSubstring
+import org.junit.jupiter.api.Test
+
+class AppendTextWithStyledSubstringTest {
+ @Test
+ fun `empty input should result in empty output`() {
+
+ val output = buildAnnotatedString {
+ appendTextWithStyledSubstring(
+ text = "",
+ substring = "abc",
+ substringStyle = SpanStyle(),
+ )
+ }
+
+ assertEquals("", output.text)
+ }
+
+ @Test
+ fun `split only should result in split only`() {
+
+ val split = "abc"
+
+ val output = buildAnnotatedString {
+ appendTextWithStyledSubstring(
+ text = split,
+ substring = split,
+ substringStyle = SpanStyle(),
+ )
+ }
+
+ assertEquals(split, output.text)
+ }
+
+ @Test
+ fun `split twice should result in split twice`() {
+
+ val split = "abcabc"
+
+ val output = buildAnnotatedString {
+ appendTextWithStyledSubstring(
+ text = split,
+ substring = split,
+ substringStyle = SpanStyle(),
+ )
+ }
+
+ assertEquals(split, output.text)
+ }
+
+ @Test
+ fun `split anywhere should return the input text`() {
+
+ val text = "abca longer abc string to split abc"
+ val split = "abc"
+
+ val output = buildAnnotatedString {
+ appendTextWithStyledSubstring(
+ text = text,
+ substring = split,
+ substringStyle = SpanStyle(),
+ )
+ }
+
+ assertEquals(text, output.text)
+ }
+
+ @Test
+ fun `span styles should be applied to all matching substrings`() {
+
+ val text = "Cool Cat: your username is Cool Cat."
+ val split = "Cool Cat"
+
+ val output = buildAnnotatedString {
+ appendTextWithStyledSubstring(
+ text = text,
+ substring = split,
+ substringStyle = SpanStyle(),
+ )
+ }
+
+ assertEquals(text, output.text)
+ assertEquals(2, output.spanStyles.size)
+
+ assertEquals(0, output.spanStyles[0].start)
+ assertEquals(8, output.spanStyles[0].end)
+
+ assertEquals(27, output.spanStyles[1].start)
+ assertEquals(35, output.spanStyles[1].end)
+ }
+}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt
new file mode 100644
index 0000000000..ac5446cc07
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt
@@ -0,0 +1,147 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.viewModelScope
+import app.cash.turbine.test
+import arrow.core.left
+import arrow.core.right
+import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import java.time.ZonedDateTime
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.screen.DeviceListNavArgs
+import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.lib.model.AccountNumber
+import net.mullvad.mullvadvpn.lib.model.Device
+import net.mullvad.mullvadvpn.lib.model.DeviceId
+import net.mullvad.mullvadvpn.lib.model.DeviceState
+import net.mullvad.mullvadvpn.lib.model.GetDeviceListError
+import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
+import net.mullvad.mullvadvpn.util.Lce
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExperimentalCoroutinesApi
+@ExtendWith(TestCoroutineRule::class)
+class ManageDevicesViewModelTest {
+
+ private val mockDeviceRepository: DeviceRepository = mockk()
+ private val mockSavedStateHandle: SavedStateHandle = mockk(relaxed = true)
+
+ private lateinit var viewModel: ManageDevicesViewModel
+
+ @BeforeEach
+ fun setup() {
+ // Mock SavedStateHandle to return the account number
+ every { mockSavedStateHandle.get<AccountNumber>("accountNumber") } returns testAccountNumber
+
+ // Mock successful device list fetch by default
+ coEvery { mockDeviceRepository.deviceList(testAccountNumber) } returns
+ testDeviceList.right()
+
+ every { mockDeviceRepository.deviceState } returns MutableStateFlow(deviceState)
+
+ viewModel =
+ ManageDevicesViewModel(
+ deviceRepository = mockDeviceRepository,
+ deviceListViewModel =
+ DeviceListViewModel(
+ deviceRepository = mockDeviceRepository,
+ dispatcher = UnconfinedTestDispatcher(),
+ savedStateHandle =
+ DeviceListNavArgs(accountNumber = testAccountNumber)
+ .toSavedStateHandle(),
+ ),
+ )
+ }
+
+ @AfterEach
+ fun tearDown() {
+ viewModel.viewModelScope.coroutineContext.cancel()
+ unmockkAll()
+ }
+
+ @Test
+ fun `initial state should be Loading followed by Content`() = runTest {
+ // Initial state is Loading
+ assertIs<Lce.Loading>(viewModel.uiState.value)
+
+ viewModel.uiState.test {
+ val contentState = awaitItem()
+ assertIs<Lce.Content<ManageDevicesUiState>>(contentState)
+ assertEquals(3, contentState.value.devices.size)
+ }
+ }
+
+ @Test
+ fun `fetchDevices should update state to Error on failure`() = runTest {
+ val error = GetDeviceListError.Unknown(RuntimeException("Network failed"))
+ coEvery { mockDeviceRepository.deviceList(testAccountNumber) } returns error.left()
+
+ viewModel.uiState.test {
+ val errorState = awaitItem()
+ assertIs<Lce.Error<GetDeviceListError>>(errorState)
+ assertEquals(error, errorState.error)
+ }
+ }
+
+ @Test
+ fun `the logged in device should appear first in the list`() = runTest {
+ viewModel.uiState.test {
+ val contentState = awaitItem()
+ assertIs<Lce.Content<ManageDevicesUiState>>(contentState)
+
+ val devices = contentState.value.devices
+ assertEquals(testDeviceId2, devices[0].device.id)
+ assertTrue(devices[0].isCurrentDevice)
+ assertFalse(devices[1].isCurrentDevice)
+ assertFalse(devices[2].isCurrentDevice)
+ }
+ }
+
+ companion object {
+ private val testAccountNumber = AccountNumber("1234567890123456")
+ private val testDeviceId1 = DeviceId.fromString("12345678-1234-5678-1234-567812345678")
+ private val testDeviceId2 = DeviceId.fromString("87654321-1234-5678-1234-567812345678")
+ private val testDeviceId3 = DeviceId.fromString("87654321-4321-5678-1234-567812345678")
+
+ private val testDevice1 =
+ Device(
+ id = testDeviceId1,
+ name = "Device 1",
+ creationDate = ZonedDateTime.now().minusSeconds(100),
+ )
+
+ private val testDevice2 =
+ Device(
+ id = testDeviceId2,
+ name = "Device 2",
+ creationDate = ZonedDateTime.now().minusSeconds(200),
+ )
+
+ private val testDevice3 =
+ Device(
+ id = testDeviceId3,
+ name = "Device 3",
+ creationDate = ZonedDateTime.now().minusSeconds(300),
+ )
+ private val testDeviceList = listOf(testDevice1, testDevice2, testDevice3)
+
+ private val deviceState =
+ DeviceState.LoggedIn(accountNumber = testAccountNumber, device = testDevice2)
+ }
+}
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 6c7f7da319..401e57e566 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -148,6 +148,10 @@
<string name="max_devices_confirm_removal_description">
<![CDATA[Are you sure you want to log <b>%s</b> out?]]>
</string>
+ <string name="manage_devices">Manage devices</string>
+ <string name="manage_devices_confirm_removal_description_line1">Remove %s?</string>
+ <string name="manage_devices_confirm_removal_description_line2">The device will be removed from the list and logged out.</string>
+ <string name="manage_devices_description">View and manage all your logged in devices. You can have up to 5 devices on one account at a time. Each device gets a name when logged in to help you tell them apart easily.</string>
<string name="confirm_removal">Yes, log out device</string>
<string name="continue_login">Continue with login</string>
<string name="failed_to_fetch_devices">Failed to fetch list of devices</string>
@@ -305,6 +309,7 @@
<string name="add">Add</string>
<string name="api_access_description">Manage and add custom methods to access the Mullvad API.</string>
<string name="current_method">Current: %s</string>
+ <string name="current_device">Current device</string>
<string name="api_access_method_info_first_line">The app needs to communicate with a Mullvad API server to log you in, fetch server lists, and other critical operations.</string>
<string name="api_access_method_info_second_line">On some networks, where various types of censorship are being used, the API servers might not be directly reachable.</string>
<string name="api_access_method_info_third_line">This feature allows you to circumvent that censorship by adding custom ways to access the API via proxies and similar methods.</string>