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 3ae348752657..ca34ec6923c6 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
@@ -24,6 +24,7 @@ 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.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -33,6 +34,8 @@ import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.Chevron
import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox
import net.mullvad.mullvadvpn.compose.preview.RelayItemCheckableCellPreviewParameterProvider
+import net.mullvad.mullvadvpn.compose.test.EXPAND_BUTTON_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG
import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
@@ -55,6 +58,7 @@ private fun PreviewCheckableRelayLocationCell(
expanded = false,
depth = 0,
onExpand = {},
+ modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG),
)
}
}
@@ -166,6 +170,7 @@ fun RelayItemCell(
color = MaterialTheme.colorScheme.onSurface,
isExpanded = isExpanded,
onClick = { onToggleExpand(!isExpanded) },
+ modifier = Modifier.testTag(EXPAND_BUTTON_TEST_TAG),
)
}
}
@@ -220,6 +225,7 @@ private fun Name(modifier: Modifier = Modifier, relay: RelayItem) {
@Composable
private fun RowScope.ExpandButton(
+ modifier: Modifier,
color: Color,
isExpanded: Boolean,
onClick: (expand: Boolean) -> Unit,
@@ -232,7 +238,8 @@ private fun RowScope.ExpandButton(
color = color,
isExpanded = isExpanded,
modifier =
- Modifier.fillMaxHeight()
+ modifier
+ .fillMaxHeight()
.clickable { onClick(!isExpanded) }
.padding(horizontal = Dimens.largePadding)
.align(Alignment.CenterVertically),
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt
index e56db510e7c7..650c882f79f2 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt
@@ -15,7 +15,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.compose.test.SWITCH_TEST_TAG
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled
@@ -55,7 +57,7 @@ fun MullvadSwitch(
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
- modifier = modifier,
+ modifier = modifier.testTag(SWITCH_TEST_TAG),
thumbContent = thumbContent,
enabled = enabled,
colors = colors,
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 bc3918bddb93..c9b1279998e9 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
@@ -86,6 +86,7 @@ import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowLocationBottom
import net.mullvad.mullvadvpn.compose.state.RelayListItem
import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
+import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG
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
@@ -427,6 +428,7 @@ fun LazyItemScope.RelayLocationItem(
onToggleExpand = { onExpand(it) },
isExpanded = relayItem.expanded,
depth = relayItem.depth,
+ modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG),
)
}
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 4163010c1d9b..d90f14a763fb 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
@@ -17,9 +17,12 @@ const val LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG =
const val CUSTOM_PORT_DIALOG_INPUT_TEST_TAG = "custom_port_dialog_input_test_tag"
const val LAZY_LIST_WIREGUARD_OBFUSCATION_TITLE_TEST_TAG =
"lazy_list_wireguard_obfuscation_title_test_tag"
+const val SWITCH_TEST_TAG = "switch_test_tag"
// SelectLocationScreen, ConnectScreen, CustomListLocationsScreen
const val CIRCULAR_PROGRESS_INDICATOR = "circular_progress_indicator"
+const val EXPAND_BUTTON_TEST_TAG = "expand_button_test_tag"
+const val LOCATION_CELL_TEST_TAG = "location_cell_test_tag"
// ConnectScreen
const val SCROLLABLE_COLUMN_TEST_TAG = "scrollable_column_test_tag"
@@ -27,6 +30,8 @@ const val SELECT_LOCATION_BUTTON_TEST_TAG = "select_location_button_test_tag"
const val CONNECT_BUTTON_TEST_TAG = "connect_button_test_tag"
const val RECONNECT_BUTTON_TEST_TAG = "reconnect_button_test_tag"
const val CONNECT_CARD_HEADER_TEST_TAG = "connect_card_header_test_tag"
+const val LOCATION_INFO_TEST_TAG = "location_info_test_tag"
+const val LOCATION_INFO_CONNECTION_IN_TEST_TAG = "location_info_connection_in_test_tag"
const val LOCATION_INFO_CONNECTION_OUT_TEST_TAG = "location_info_connection_out_test_tag"
// ConnectScreen - Notification banner
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 58de53323ef5..9091961b83be 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -40,12 +40,16 @@ grpc-protobuf = "4.28.1"
koin = "4.0.0"
koin-compose = "4.0.0"
+# Ktor
+ktor = "3.0.0-beta-2"
+
# Kotlin
# Bump kotlin and kotlin-ksp together, find matching release here:
# https://github.com/google/ksp/releases
kotlin = "2.0.20"
kotlin-ksp = "2.0.20-1.0.25"
kotlinx = "1.9.0"
+kotlinx-serialization = "2.0.20"
# Protobuf
protobuf = "0.9.4"
@@ -130,6 +134,13 @@ kotlin-native-prebuilt = { module = "org.jetbrains.kotlin:kotlin-native-prebuilt
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+
+# Ktor
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
+ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
# MockK
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
@@ -159,6 +170,9 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "kotlin-ksp"}
+# Kotlinx
+kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinx-serialization" }
+
# Protobuf
protobuf-core = { id = "com.google.protobuf", version.ref = "protobuf" }
protobuf-protoc = { id = "com.google.protobuf:protoc", version.ref = "grpc-protobuf" }
diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml
deleted file mode 100644
index 7d45a84c6c6d..000000000000
--- a/android/gradle/verification-metadata.xml
+++ /dev/null
@@ -1,5993 +0,0 @@
-
-
-
-
- true
- false
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt
index b18a10d50405..34690022c9e5 100644
--- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt
@@ -95,6 +95,20 @@ class AppInteractor(
.text
}
+ fun extractInIpv4Address(): String {
+ device.findObjectWithTimeout(By.res("location_info_test_tag")).click()
+ val inString =
+ device
+ .findObjectWithTimeout(
+ By.res("location_info_connection_in_test_tag"),
+ VERY_LONG_TIMEOUT,
+ )
+ .text
+
+ val extractedIpAddress = inString.split(" ")[1].split(":")[0]
+ return extractedIpAddress
+ }
+
fun clickSettingsCog() {
device.findObjectWithTimeout(By.res("top_bar_settings_button")).click()
}
diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt
new file mode 100644
index 000000000000..a71b673b6ee8
--- /dev/null
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt
@@ -0,0 +1,32 @@
+package net.mullvad.mullvadvpn.test.common.misc
+
+import android.os.Environment
+import co.touchlab.kermit.Logger
+import java.io.File
+import java.io.IOException
+import org.junit.jupiter.api.fail
+
+object Attachment {
+ private const val DIRECTORY_NAME = "test-attachments"
+ public val testAttachmentsDirectory =
+ File(
+ Environment.getExternalStorageDirectory(),
+ Environment.DIRECTORY_DOWNLOADS + "/$DIRECTORY_NAME",
+ )
+
+ fun saveAttachment(fileName: String, data: ByteArray) {
+ if (testAttachmentsDirectory.exists().not()) {
+ if (testAttachmentsDirectory.mkdirs().not()) {
+ fail("Failed to create directory ${testAttachmentsDirectory.absolutePath}")
+ }
+ }
+
+ val file = File(testAttachmentsDirectory, fileName)
+ try {
+ file.writeBytes(data)
+ Logger.v("Saved attachment ${file.absolutePath}")
+ } catch (e: IOException) {
+ fail("Failed to save attachment $fileName: ${e.message}")
+ }
+ }
+}
diff --git a/android/test/e2e/build.gradle.kts b/android/test/e2e/build.gradle.kts
index 8f4e9c2e0dc5..c9cf7137b811 100644
--- a/android/test/e2e/build.gradle.kts
+++ b/android/test/e2e/build.gradle.kts
@@ -5,6 +5,7 @@ import org.gradle.configurationcache.extensions.capitalized
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlinx.serialization)
id(Dependencies.junit5AndroidPluginId) version Versions.junit5Plugin
}
@@ -103,6 +104,13 @@ android {
buildFeatures { buildConfig = true }
}
+junitPlatform {
+ instrumentationTests {
+ version.set(Versions.junit5Android)
+ includeExtensions.set(true)
+ }
+}
+
androidComponents {
beforeVariants { variantBuilder ->
variantBuilder.enable =
@@ -140,6 +148,11 @@ dependencies {
implementation(Dependencies.junit5AndroidTestExtensions)
implementation(Dependencies.junit5AndroidTestRunner)
implementation(libs.kotlin.stdlib)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.cio)
+ implementation(libs.ktor.serialization.kotlinx.json)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.jodatime)
androidTestUtil(libs.androidx.test.orchestrator)
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt
index 278def0b35c1..4ed9a09b1593 100644
--- a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt
@@ -49,4 +49,10 @@ abstract class EndToEndTest(private val infra: String) {
app = AppInteractor(device, targetContext, "net.mullvad.mullvadvpn$targetPackageNameSuffix")
}
+
+ companion object {
+ val defaultCountry = "Sweden"
+ val defaultCity = "Gothenburg"
+ val defaultRelay = "se-got-wg-001"
+ }
}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LeakTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LeakTest.kt
new file mode 100644
index 000000000000..a01cea07ad5f
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LeakTest.kt
@@ -0,0 +1,153 @@
+package net.mullvad.mullvadvpn.test.e2e
+
+import androidx.test.uiautomator.By
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import net.mullvad.mullvadvpn.BuildConfig
+import net.mullvad.mullvadvpn.compose.test.EXPAND_BUTTON_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.SWITCH_TEST_TAG
+import net.mullvad.mullvadvpn.compose.test.TOP_BAR_SETTINGS_BUTTON
+import net.mullvad.mullvadvpn.test.common.constant.VERY_LONG_TIMEOUT
+import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout
+import net.mullvad.mullvadvpn.test.common.misc.Attachment
+import net.mullvad.mullvadvpn.test.common.rule.ForgetAllVpnAppsInSettingsTestRule
+import net.mullvad.mullvadvpn.test.e2e.misc.AccountTestRule
+import net.mullvad.mullvadvpn.test.e2e.misc.LeakCheck
+import net.mullvad.mullvadvpn.test.e2e.misc.NoTrafficToHostRule
+import net.mullvad.mullvadvpn.test.e2e.misc.PacketCapture
+import net.mullvad.mullvadvpn.test.e2e.misc.PacketCaptureResult
+import net.mullvad.mullvadvpn.test.e2e.misc.TrafficGenerator
+import org.joda.time.DateTime
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInfo
+import org.junit.jupiter.api.extension.RegisterExtension
+
+class LeakTest : EndToEndTest(BuildConfig.FLAVOR_infrastructure) {
+
+ @RegisterExtension @JvmField val accountTestRule = AccountTestRule()
+
+ @RegisterExtension
+ @JvmField
+ val forgetAllVpnAppsInSettingsTestRule = ForgetAllVpnAppsInSettingsTestRule()
+
+ @BeforeEach
+ fun setupVPNSettings() {
+ app.launchAndEnsureLoggedIn(accountTestRule.validAccountNumber)
+ device.findObjectWithTimeout(By.res(TOP_BAR_SETTINGS_BUTTON)).click()
+ device.findObjectWithTimeout(By.text("VPN settings")).click()
+
+ val localNetworkSharingCell =
+ device.findObjectWithTimeout(By.text("Local network sharing")).parent
+ val localNetworkSharingSwitch =
+ localNetworkSharingCell.findObjectWithTimeout(By.res(SWITCH_TEST_TAG))
+
+ if (localNetworkSharingSwitch.isChecked.not()) {
+ localNetworkSharingSwitch.click()
+ }
+
+ // Only use port 51820 to make packet capture more deterministic
+ device.findObjectWithTimeout(By.text("51820")).click()
+
+ device.pressBack()
+ device.pressBack()
+ }
+
+ @Test
+ fun testNegativeLeak(testInfo: TestInfo) =
+ runBlocking {
+ app.launch()
+ device.findObjectWithTimeout(By.text("DISCONNECTED"))
+
+ val targetIpAddress = "45.83.223.209"
+ val targetPort = 80
+
+ device.findObjectWithTimeout(By.res(SELECT_LOCATION_BUTTON_TEST_TAG)).click()
+ clickLocationExpandButton((EndToEndTest.defaultCountry))
+ clickLocationExpandButton((EndToEndTest.defaultCity))
+ device.findObjectWithTimeout(By.text(EndToEndTest.defaultRelay)).click()
+ device.findObjectWithTimeout(By.text("OK")).click()
+ device.findObjectWithTimeout(By.text("CONNECTED"), VERY_LONG_TIMEOUT)
+
+ val connectTime = DateTime.now()
+ val captureResult =
+ PacketCapture().capturePackets {
+ TrafficGenerator(targetIpAddress, targetPort).generateTraffic(10.milliseconds) {
+ // Give it some time for generating traffic
+ delay(3000)
+ }
+ }
+
+ val disconnectTime = DateTime.now()
+ device.findObjectWithTimeout(By.text("Disconnect")).click()
+
+ val capturedStreams = captureResult.streams
+ val capturedPcap = captureResult.pcap
+
+ val timestamp = System.currentTimeMillis()
+ Attachment.saveAttachment("capture-testNegativeLeak-$timestamp.pcap", capturedPcap)
+
+ val leakRules = listOf(NoTrafficToHostRule(targetIpAddress))
+
+ LeakCheck.assertNoLeaks(capturedStreams, leakRules, connectTime, disconnectTime)
+ }
+
+ @Test
+ fun testShouldHaveNegativeLeak(testInfo: TestInfo) =
+ runBlocking {
+ app.launch()
+ device.findObjectWithTimeout(By.text("DISCONNECTED"))
+
+ val targetIpAddress = "45.83.223.209"
+ val targetPort = 80
+
+ device.findObjectWithTimeout(By.res(SELECT_LOCATION_BUTTON_TEST_TAG)).click()
+ val countryCell = device.findObjectWithTimeout(By.text(EndToEndTest.defaultCountry))
+ delay(1000.milliseconds)
+ clickLocationExpandButton((EndToEndTest.defaultCountry))
+ clickLocationExpandButton((EndToEndTest.defaultCity))
+ device.findObjectWithTimeout(By.text(EndToEndTest.defaultRelay)).click()
+ device.findObjectWithTimeout(By.text("OK")).click()
+ device.findObjectWithTimeout(By.text("CONNECTED"), VERY_LONG_TIMEOUT)
+
+ lateinit var captureResult: PacketCaptureResult
+ val connectTime = DateTime.now()
+
+ TrafficGenerator(targetIpAddress, targetPort).generateTraffic(10.milliseconds) {
+ captureResult =
+ PacketCapture().capturePackets {
+ delay(
+ 3000.milliseconds
+ ) // Give it some time for generating traffic in tunnel
+ device.findObjectWithTimeout(By.text("Disconnect")).click()
+ delay(
+ 2000.milliseconds
+ ) // Give it some time to leak traffic outside of tunnel
+ device.findObjectWithTimeout(By.text("Connect")).click()
+ delay(
+ 3000.milliseconds
+ ) // Give it some time for generating traffic in tunnel
+ }
+ }
+
+ val disconnectTime = DateTime.now()
+ device.findObjectWithTimeout(By.text("Disconnect")).click()
+
+ val capturedStreams = captureResult.streams
+ val capturedPcap = captureResult.pcap
+ val timestamp = System.currentTimeMillis()
+ Attachment.saveAttachment("capture-testShouldHaveLeak-$timestamp.pcap", capturedPcap)
+
+ val leakRules = listOf(NoTrafficToHostRule(targetIpAddress))
+
+ LeakCheck.assertLeaks(capturedStreams, leakRules, connectTime, disconnectTime)
+ }
+
+ private fun clickLocationExpandButton(locationName: String) {
+ val locationCell = device.findObjectWithTimeout(By.text(locationName)).parent.parent
+ val expandButton = locationCell.findObjectWithTimeout(By.res(EXPAND_BUTTON_TEST_TAG))
+ expandButton.click()
+ }
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/LeakCheck.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/LeakCheck.kt
new file mode 100644
index 000000000000..9e57da964f94
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/LeakCheck.kt
@@ -0,0 +1,43 @@
+package net.mullvad.mullvadvpn.test.e2e.misc
+
+import org.joda.time.DateTime
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class LeakCheck {
+ companion object {
+ fun assertNoLeaks(
+ streams: List,
+ rules: List,
+ start: DateTime,
+ end: DateTime,
+ ) {
+ for (rule in rules) {
+ assertFalse(rule.isViolated(streams, start, end))
+ }
+ }
+
+ fun assertLeaks(
+ streams: List,
+ rules: List,
+ start: DateTime,
+ end: DateTime,
+ ) {
+ for (rule in rules) {
+ assertTrue(rule.isViolated(streams, start, end))
+ }
+ }
+ }
+}
+
+interface LeakRule {
+ fun isViolated(streams: List, start: DateTime, end: DateTime): Boolean
+}
+
+class NoTrafficToHostRule(private val host: String) : LeakRule {
+ override fun isViolated(streams: List, start: DateTime, end: DateTime): Boolean {
+ return streams
+ .filter { it.startDate.isAfter(start) && it.endDate.isBefore(end) }
+ .any { it.destinationHost.ipAddress == host }
+ }
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/Networking.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/Networking.kt
new file mode 100644
index 000000000000..66cbfb2de91d
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/Networking.kt
@@ -0,0 +1,25 @@
+package net.mullvad.mullvadvpn.test.e2e.misc
+
+import java.net.Inet4Address
+import java.net.NetworkInterface
+import org.junit.Assert.fail
+
+class Networking {
+ companion object {
+ fun getIpAddress(): String {
+ NetworkInterface.getNetworkInterfaces()!!.toList().map { networkInterface ->
+ val address =
+ networkInterface.inetAddresses.toList().find {
+ !it.isLoopbackAddress && it is Inet4Address
+ }
+
+ if (address != null && address.hostAddress != null) {
+ return address.hostAddress!!
+ }
+ }
+
+ fail("Failed to get test device IP address")
+ return ""
+ }
+ }
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/PacketCapture.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/PacketCapture.kt
new file mode 100644
index 000000000000..06986e4d2748
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/PacketCapture.kt
@@ -0,0 +1,237 @@
+package net.mullvad.mullvadvpn.test.e2e.misc
+
+import co.touchlab.kermit.Logger
+import io.ktor.client.*
+import io.ktor.client.call.body
+import io.ktor.client.engine.cio.*
+import io.ktor.client.plugins.HttpResponseValidator
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.request.*
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import io.ktor.serialization.kotlinx.json.json
+import java.util.UUID
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.*
+import org.joda.time.DateTime
+import org.junit.jupiter.api.fail
+
+@JvmInline
+@Serializable(with = PacketCaptureSessionAsStringSerializer::class)
+value class PacketCaptureSession(val uuid: UUID = UUID.randomUUID())
+
+object PacketCaptureSessionAsStringSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = String.serializer().descriptor
+
+ override fun deserialize(decoder: Decoder): PacketCaptureSession {
+ val string = decoder.decodeString()
+ return PacketCaptureSession(UUID.fromString(string))
+ }
+
+ override fun serialize(encoder: Encoder, value: PacketCaptureSession) {
+ encoder.encodeString(value.uuid.toString())
+ }
+}
+
+class PacketCapture {
+ private val client = PacketCaptureClient()
+ private val session = PacketCaptureSession()
+
+ private suspend fun startCapture() {
+ client.sendStartCaptureRequest(session)
+ }
+
+ private suspend fun stopCapture() {
+ client.sendStopCaptureRequest(session)
+ }
+
+ private suspend fun getParsedCapture(): List {
+ val parsedPacketsResponse = client.sendGetCapturedPacketsRequest(session)
+ val capturedStreams = parsedPacketsResponse.body>()
+ Logger.v("Captured streams: $capturedStreams")
+ return capturedStreams
+ }
+
+ private suspend fun getPcap(): ByteArray {
+ return client.sendGetPcapFileRequest(session).body()
+ }
+
+ suspend fun capturePackets(block: suspend () -> Unit): PacketCaptureResult {
+ startCapture()
+ block()
+ stopCapture()
+ val parsedCapture = getParsedCapture()
+ val pcap = getPcap()
+ return PacketCaptureResult(parsedCapture, pcap)
+ }
+}
+
+private fun defaultHttpClient(): HttpClient =
+ HttpClient(CIO) {
+ install(ContentNegotiation) {
+ json(
+ Json {
+ isLenient = true
+ prettyPrint = true
+ }
+ )
+ }
+
+ HttpResponseValidator {
+ validateResponse { response ->
+ val statusCode = response.status.value
+ if (statusCode >= 400) {
+ fail(
+ "Request failed with response status code $statusCode: ${response.body()}"
+ )
+ }
+ }
+ handleResponseExceptionWithRequest { exception, _ ->
+ fail("Request failed to be sent with exception: ${exception.message}")
+ }
+ }
+ }
+
+class PacketCaptureClient(private val httpClient: HttpClient = defaultHttpClient()) {
+ suspend fun sendStartCaptureRequest(session: PacketCaptureSession) {
+ val jsonObject = StartCaptureRequestJson(session)
+
+ Logger.v("Sending start capture request with body: ${Json.encodeToString(jsonObject)}")
+
+ val response =
+ httpClient.post("$BASE_URL/capture") {
+ contentType(ContentType.Application.Json)
+ setBody(Json.encodeToString(jsonObject))
+ }
+ }
+
+ suspend fun sendStopCaptureRequest(session: PacketCaptureSession) {
+ Logger.v("Sending stop capture request for session ${session.uuid}")
+ httpClient.post("$BASE_URL/stop-capture/${session.uuid.toString()}")
+ }
+
+ suspend fun sendGetCapturedPacketsRequest(session: PacketCaptureSession): HttpResponse {
+ val testDeviceIpAddress = Networking.getIpAddress()
+ return httpClient.put("$BASE_URL/parse-capture/${session.uuid.toString()}") {
+ contentType(ContentType.Application.Json)
+ accept(ContentType.Application.Json)
+ setBody("[\"$testDeviceIpAddress\"]")
+ }
+ }
+
+ suspend fun sendGetPcapFileRequest(session: PacketCaptureSession): HttpResponse {
+ return httpClient.get("$BASE_URL/last-capture/${session.uuid.toString()}") {
+ // contentType(ContentType.parse("application/pcap"))
+ accept(ContentType.parse("application/json"))
+ }
+ }
+
+ companion object {
+ const val BASE_URL = "http://192.168.105.1"
+ }
+}
+
+data class PacketCaptureResult(val streams: List, val pcap: ByteArray)
+
+@Serializable data class StartCaptureRequestJson(val label: PacketCaptureSession)
+
+@Serializable
+enum class NetworkTransportProtocol() {
+ @SerialName("tcp") TCP,
+ @SerialName("udp") UDP,
+ @SerialName("icmp") ICMP,
+}
+
+data class Host(val ipAddress: String, val port: Int) {
+ companion object {
+ fun fromString(connectionInfo: String): Host {
+ val ipAddress = connectionInfo.split(":").first()
+ val port = connectionInfo.split(":").last().toInt()
+ return Host(ipAddress, port)
+ }
+ }
+}
+
+object PacketSerializer : KSerializer> {
+ override val descriptor: SerialDescriptor = ListSerializer(Packet.serializer()).descriptor
+
+ override fun deserialize(decoder: Decoder): List {
+ val jsonDecoder = decoder as? JsonDecoder ?: error("Can only be deserialized from JSON")
+ val elements = jsonDecoder.decodeJsonElement().jsonArray
+
+ return elements.map { element: JsonElement ->
+ val jsonObject = element.jsonObject
+ val fromPeer =
+ jsonObject["from_peer"]?.jsonPrimitive?.booleanOrNull
+ ?: error("Missing from_peer field")
+
+ if (fromPeer) {
+ jsonDecoder.json.decodeFromJsonElement(TxPacket.serializer(), element)
+ } else {
+ jsonDecoder.json.decodeFromJsonElement(RxPacket.serializer(), element)
+ }
+ }
+ }
+
+ override fun serialize(encoder: Encoder, value: List) {
+ throw NotImplementedError("Only interested in deserialization")
+ }
+}
+
+@Serializable
+data class Stream(
+ @SerialName("peer_addr") private val sourceAddressAndPort: String,
+ @SerialName("other_addr") private val destinationAddressAndPort: String,
+ @SerialName("flow_id") val flowId: String?,
+ @SerialName("transport_protocol") val transportProtocol: NetworkTransportProtocol,
+ @Serializable(with = PacketSerializer::class) val packets: List,
+) {
+ @Contextual public val sourceHost = Host.fromString(sourceAddressAndPort)
+ @Contextual public val destinationHost = Host.fromString(destinationAddressAndPort)
+
+ @Contextual val startDate: DateTime = packets.first().date
+ @Contextual val endDate: DateTime = packets.last().date
+ @Contextual val txStartDate: DateTime? = packets.firstOrNull { it.fromPeer }?.date
+ @Contextual val txEndDate: DateTime? = packets.lastOrNull { it.fromPeer }?.date
+ @Contextual val rxStartDate: DateTime? = packets.firstOrNull { !it.fromPeer }?.date
+ @Contextual val rxEndDate: DateTime? = packets.lastOrNull { !it.fromPeer }?.date
+
+ fun txPackets(): List = packets.filterIsInstance()
+
+ fun rxPackets(): List = packets.filterIsInstance()
+}
+
+@Serializable
+sealed interface Packet {
+ abstract val timestamp: String
+ abstract val fromPeer: Boolean
+ abstract val date: DateTime
+}
+
+@Serializable
+@SerialName("RxPacket")
+data class RxPacket(
+ @SerialName("timestamp") override val timestamp: String,
+ @SerialName("from_peer") override val fromPeer: Boolean,
+) : Packet {
+ @Contextual override val date = DateTime(timestamp.toLong() / 1000)
+}
+
+@Serializable
+@SerialName("TxPacket")
+data class TxPacket(
+ @SerialName("timestamp") override val timestamp: String,
+ @SerialName("from_peer") override val fromPeer: Boolean,
+) : Packet {
+ @Contextual override val date = DateTime(timestamp.toLong() / 1000)
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/TrafficGenerator.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/TrafficGenerator.kt
new file mode 100644
index 000000000000..1c7bbe0ddc24
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/TrafficGenerator.kt
@@ -0,0 +1,46 @@
+package net.mullvad.mullvadvpn.test.e2e.misc
+
+import co.touchlab.kermit.Logger
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.InetAddress
+import kotlin.time.Duration
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+class TrafficGenerator(val destinationHost: String, val destinationPort: Int) {
+ private var sendTrafficJob: Job? = null
+
+ private fun startGeneratingUDPTraffic(interval: Duration) {
+ val socket = DatagramSocket()
+ val address = InetAddress.getByName(destinationHost)
+ val data = ByteArray(1024)
+ val packet = DatagramPacket(data, data.size, address, destinationPort)
+
+ sendTrafficJob =
+ CoroutineScope(Dispatchers.IO).launch {
+ while (true) {
+ socket.send(packet)
+ Logger.v(
+ "TrafficGenerator sending UDP packet to $destinationHost:$destinationPort"
+ )
+ delay(interval)
+ }
+ }
+ }
+
+ private fun stopGeneratingUDPTraffic() {
+ sendTrafficJob!!.cancel()
+ }
+
+ suspend fun generateTraffic(interval: Duration, block: suspend () -> Unit) = runBlocking {
+ startGeneratingUDPTraffic(interval)
+ block()
+ stopGeneratingUDPTraffic()
+ return@runBlocking Unit
+ }
+}