summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorNiklas Berglund <niklas.berglund@gmail.com>2024-08-26 17:05:00 +0200
committerDavid Göransson <david.goransson@mullvad.net>2024-10-15 14:47:12 +0200
commit74cfcf0546724c1c7ee82597ea28ccd7801fc74d (patch)
treeb88a5b8fc4212d5f3968b55d2159fe785ac14f44 /android
parent14be3654e0c77fea3741312acc6ac7da126e18bd (diff)
downloadmullvadvpn-74cfcf0546724c1c7ee82597ea28ccd7801fc74d.tar.xz
mullvadvpn-74cfcf0546724c1c7ee82597ea28ccd7801fc74d.zip
Add minimal leak tests for Android
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt5
-rw-r--r--android/gradle/libs.versions.toml14
-rwxr-xr-xandroid/scripts/run-instrumented-tests.sh6
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt14
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt34
-rw-r--r--android/test/e2e/README.md2
-rw-r--r--android/test/e2e/build.gradle.kts18
-rw-r--r--android/test/e2e/e2e.properties4
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt6
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LeakTest.kt142
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/annotations/HasDependencyOnLocalAPI.kt33
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/annotations/HighlyRateLimited.kt15
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt1
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/LeakCheck.kt33
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/Networking.kt23
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/PacketCapture.kt131
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/TrafficGenerator.kt46
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Host.kt12
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Packet.kt23
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Stream.kt49
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/NanoSecondsTimestampSerializer.kt23
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/PacketCaptureSessionSerializer.kt22
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/PacketSerializer.kt21
26 files changed, 677 insertions, 15 deletions
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 5b3b828e3d..e1157eb3bc 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
@@ -26,6 +26,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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -33,6 +34,8 @@ import androidx.compose.ui.unit.dp
import net.mullvad.mullvadvpn.compose.component.ExpandChevron
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),
)
}
}
@@ -163,6 +167,7 @@ fun RelayItemCell(
color = MaterialTheme.colorScheme.onSurface,
isExpanded = isExpanded,
onClick = { onToggleExpand(!isExpanded) },
+ modifier = Modifier.testTag(EXPAND_BUTTON_TEST_TAG),
)
}
}
@@ -217,6 +222,7 @@ private fun Name(modifier: Modifier = Modifier, relay: RelayItem) {
@Composable
private fun RowScope.ExpandButton(
+ modifier: Modifier,
color: Color,
isExpanded: Boolean,
onClick: (expand: Boolean) -> Unit,
@@ -229,7 +235,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 e56db510e7..650c882f79 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 371347bcdd..7aeacd5553 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
@@ -91,6 +91,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
@@ -431,6 +432,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 4163010c1d..d90f14a763 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 f0d274a243..74b19e0e51 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -40,12 +40,16 @@ grpc-protobuf = "4.28.2"
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.21"
kotlin-ksp = "2.0.21-1.0.25"
kotlinx = "1.9.0"
+kotlinx-serialization = "2.0.20"
# Protobuf
protobuf = "0.9.4"
@@ -134,6 +138,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" }
@@ -163,6 +174,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/scripts/run-instrumented-tests.sh b/android/scripts/run-instrumented-tests.sh
index 650e7f0fee..fb80995739 100755
--- a/android/scripts/run-instrumented-tests.sh
+++ b/android/scripts/run-instrumented-tests.sh
@@ -17,6 +17,7 @@ PARTNER_AUTH="${PARTNER_AUTH:-}"
VALID_TEST_ACCOUNT_NUMBER="${VALID_TEST_ACCOUNT_NUMBER:-}"
INVALID_TEST_ACCOUNT_NUMBER="${INVALID_TEST_ACCOUNT_NUMBER:-}"
ENABLE_HIGHLY_RATE_LIMITED_TESTS="${ENABLE_HIGHLY_RATE_LIMITED_TESTS:-false}"
+ENABLE_ACCESS_TO_LOCAL_API_TESTS="${ENABLE_ACCESS_TO_LOCAL_API_TESTS:-false}"
REPORT_DIR="${REPORT_DIR:-}"
while [[ "$#" -gt 0 ]]; do
@@ -131,7 +132,8 @@ case "$TEST_TYPE" in
echo "Error: The variable PARTNER_AUTH or VALID_TEST_ACCOUNT_NUMBER must be set."
exit 1
fi
- OPTIONAL_TEST_ARGUMENTS+=" -e enable_highly_rate_limited_tests $ENABLE_HIGHLY_RATE_LIMITED_TESTS"
+ OPTIONAL_TEST_ARGUMENTS+=" -e ENABLE_HIGHLY_RATE_LIMITED_TESTS $ENABLE_HIGHLY_RATE_LIMITED_TESTS"
+ OPTIONAL_TEST_ARGUMENTS+=" -e ENABLE_ACCESS_TO_LOCAL_API_TESTS $ENABLE_ACCESS_TO_LOCAL_API_TESTS"
USE_ORCHESTRATOR="true"
PACKAGE_NAME="net.mullvad.mullvadvpn"
if [[ "$INFRA_FLAVOR" =~ ^(devmole|stagemole)$ ]]; then
@@ -152,12 +154,14 @@ INSTRUMENTATION_LOG_FILE_PATH="$REPORT_DIR/instrumentation-log.txt"
LOGCAT_FILE_PATH="$REPORT_DIR/logcat.txt"
LOCAL_SCREENSHOT_PATH="$REPORT_DIR/screenshots"
DEVICE_SCREENSHOT_PATH="/sdcard/Pictures/mullvad-$TEST_TYPE"
+DEVICE_TEST_ATTACHMENTS_PATH="/sdcard/Download/test-attachments"
echo ""
echo "### Ensure clean report structure ###"
rm -rf "${REPORT_DIR:?}/*"
adb logcat --clear
adb shell rm -rf "$DEVICE_SCREENSHOT_PATH"
+adb shell rm -rf "$DEVICE_TEST_ATTACHMENTS_PATH"
echo ""
if [[ "${USE_ORCHESTRATOR-}" == "true" ]]; then
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 b18a10d504..34690022c9 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 0000000000..57e6ab542b
--- /dev/null
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt
@@ -0,0 +1,34 @@
+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"
+ private val testAttachmentsDirectory =
+ File(
+ Environment.getExternalStorageDirectory(),
+ "${Environment.DIRECTORY_DOWNLOADS}/$DIRECTORY_NAME",
+ )
+
+ fun saveAttachment(fileName: String, data: ByteArray) {
+ createAttachmentsDirectoryIfNotExists()
+
+ 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}")
+ }
+ }
+
+ private fun createAttachmentsDirectoryIfNotExists() {
+ if (!testAttachmentsDirectory.exists() && !testAttachmentsDirectory.mkdirs()) {
+ fail("Failed to create directory ${testAttachmentsDirectory.absolutePath}")
+ }
+ }
+}
diff --git a/android/test/e2e/README.md b/android/test/e2e/README.md
index 50e8d177a5..adbcc042cd 100644
--- a/android/test/e2e/README.md
+++ b/android/test/e2e/README.md
@@ -28,6 +28,8 @@ adb shell 'CLASSPATH=$(pm path androidx.test.services) app_process / \
androidx.test.orchestrator/.AndroidTestOrchestrator'
```
+If you want to run tests that make use of APIs hosted at Mullvad HQ you need to set `ENABLE_ACCESS_TO_LOCAL_API_TESTS=true` in `e2e.properties` or pass it as a command line argument when launching tests.
+
### Firebase Test Lab
Firebase Test Lab can be used to run the tests on vast collection of physical and virtual devices.
diff --git a/android/test/e2e/build.gradle.kts b/android/test/e2e/build.gradle.kts
index 50224489d5..4a921c810d 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
}
@@ -30,6 +31,10 @@ android {
Properties().apply {
load(project.file("e2e.properties").inputStream())
addRequiredPropertyAsBuildConfigField("API_VERSION")
+ addRequiredPropertyAsBuildConfigField("ENABLE_HIGHLY_RATE_LIMITED_TESTS")
+ addRequiredPropertyAsBuildConfigField("ENABLE_ACCESS_TO_LOCAL_API_TESTS")
+ addRequiredPropertyAsBuildConfigField("TRAFFIC_GENERATION_IP_ADDRESS")
+ addRequiredPropertyAsBuildConfigField("PACKET_CAPTURE_API_HOST")
}
fun MutableMap<String, String>.addOptionalPropertyAsArgument(name: String) {
@@ -47,7 +52,6 @@ android {
put("clearPackageData", "true")
addOptionalPropertyAsArgument("valid_test_account_number")
addOptionalPropertyAsArgument("invalid_test_account_number")
- addOptionalPropertyAsArgument("enable_highly_rate_limited_tests")
}
}
@@ -106,6 +110,13 @@ android {
buildFeatures { buildConfig = true }
}
+junitPlatform {
+ instrumentationTests {
+ version.set(Versions.junit5Android)
+ includeExtensions.set(true)
+ }
+}
+
androidComponents {
beforeVariants { variantBuilder ->
variantBuilder.enable =
@@ -143,6 +154,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/e2e.properties b/android/test/e2e/e2e.properties
index 58798ef1b6..5f880bd2c1 100644
--- a/android/test/e2e/e2e.properties
+++ b/android/test/e2e/e2e.properties
@@ -1 +1,5 @@
API_VERSION=v1
+ENABLE_HIGHLY_RATE_LIMITED_TESTS=false
+ENABLE_ACCESS_TO_LOCAL_API_TESTS=false
+TRAFFIC_GENERATION_IP_ADDRESS=45.83.223.209
+PACKET_CAPTURE_API_HOST=192.168.105.1
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 ba559ffab0..dfa050f9bc 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
@@ -52,4 +52,10 @@ abstract class EndToEndTest(private val infra: String) {
app = AppInteractor(device, targetContext, "net.mullvad.mullvadvpn$targetPackageNameSuffix")
}
+
+ companion object {
+ const val DEFAULT_COUNTRY = "Sweden"
+ const val DEFAULT_CITY = "Gothenburg"
+ const val DEFAULT_RELAY = "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 0000000000..df2c3c0e15
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LeakTest.kt
@@ -0,0 +1,142 @@
+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.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.annotations.HasDependencyOnLocalAPI
+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.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+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))
+
+ localNetworkSharingSwitch.click()
+
+ // Only use port 51820 to make packet capture more deterministic
+ device.findObjectWithTimeout(By.text("51820")).click()
+
+ device.pressBack()
+ device.pressBack()
+ }
+
+ @Test
+ @HasDependencyOnLocalAPI
+ fun testNegativeLeak() =
+ runBlocking<Unit> {
+ app.launch()
+ device.findObjectWithTimeout(By.text("DISCONNECTED"))
+
+ val targetIpAddress = BuildConfig.TRAFFIC_GENERATION_IP_ADDRESS
+ val targetPort = 80
+
+ device.findObjectWithTimeout(By.res(SELECT_LOCATION_BUTTON_TEST_TAG)).click()
+ clickLocationExpandButton((EndToEndTest.DEFAULT_COUNTRY))
+ clickLocationExpandButton((EndToEndTest.DEFAULT_CITY))
+ device.findObjectWithTimeout(By.text(EndToEndTest.DEFAULT_RELAY)).click()
+ device.findObjectWithTimeout(By.text("OK")).click()
+ device.findObjectWithTimeout(By.text("CONNECTED"), VERY_LONG_TIMEOUT)
+
+ val captureResult =
+ PacketCapture().capturePackets {
+ TrafficGenerator(targetIpAddress, targetPort).generateTraffic(10.milliseconds) {
+ // Give it some time for generating traffic
+ delay(3000)
+ }
+ }
+
+ 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)
+ }
+
+ @Test
+ @HasDependencyOnLocalAPI
+ fun testShouldHaveNegativeLeak() =
+ runBlocking<Unit> {
+ app.launch()
+ device.findObjectWithTimeout(By.text("DISCONNECTED"))
+
+ val targetIpAddress = BuildConfig.TRAFFIC_GENERATION_IP_ADDRESS
+ val targetPort = 80
+
+ device.findObjectWithTimeout(By.res(SELECT_LOCATION_BUTTON_TEST_TAG)).click()
+ delay(1000.milliseconds)
+ clickLocationExpandButton((EndToEndTest.DEFAULT_COUNTRY))
+ clickLocationExpandButton((EndToEndTest.DEFAULT_CITY))
+ device.findObjectWithTimeout(By.text(EndToEndTest.DEFAULT_RELAY)).click()
+ device.findObjectWithTimeout(By.text("OK")).click()
+ device.findObjectWithTimeout(By.text("CONNECTED"), VERY_LONG_TIMEOUT)
+
+ val captureResult: PacketCaptureResult =
+ PacketCapture().capturePackets {
+ TrafficGenerator(targetIpAddress, targetPort).generateTraffic(10.milliseconds) {
+ 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
+ }
+ }
+
+ 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)
+ }
+
+ 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/annotations/HasDependencyOnLocalAPI.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/annotations/HasDependencyOnLocalAPI.kt
new file mode 100644
index 0000000000..9aa876abcd
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/annotations/HasDependencyOnLocalAPI.kt
@@ -0,0 +1,33 @@
+package net.mullvad.mullvadvpn.test.e2e.annotations
+
+import net.mullvad.mullvadvpn.test.e2e.BuildConfig
+import org.junit.jupiter.api.extension.ConditionEvaluationResult
+import org.junit.jupiter.api.extension.ExecutionCondition
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.extension.ExtensionContext
+
+/**
+ * Annotation for tests making use of local APIs such as the firewall or packet capture APIs, which
+ * can only run in the office environment.
+ */
+@Retention(AnnotationRetention.RUNTIME)
+@ExtendWith(HasDependencyOnLocalAPI.ShouldRunWhenHaveAccessToLocalAPI::class)
+annotation class HasDependencyOnLocalAPI {
+ class ShouldRunWhenHaveAccessToLocalAPI : ExecutionCondition {
+ override fun evaluateExecutionCondition(
+ context: ExtensionContext?
+ ): ConditionEvaluationResult {
+ val enable = BuildConfig.ENABLE_ACCESS_TO_LOCAL_API_TESTS.toBoolean() ?: false
+
+ return if (enable) {
+ ConditionEvaluationResult.enabled(
+ "Running test which requires access to local APIs."
+ )
+ } else {
+ ConditionEvaluationResult.disabled(
+ "Skipping test which requires access to local APIs."
+ )
+ }
+ }
+ }
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/annotations/HighlyRateLimited.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/annotations/HighlyRateLimited.kt
index a923e03b46..27b139a5a8 100644
--- a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/annotations/HighlyRateLimited.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/annotations/HighlyRateLimited.kt
@@ -1,7 +1,6 @@
package net.mullvad.mullvadvpn.test.e2e.annotations
-import androidx.test.platform.app.InstrumentationRegistry
-import net.mullvad.mullvadvpn.test.e2e.constant.ENABLE_HIGHLY_RATE_LIMITED
+import net.mullvad.mullvadvpn.test.e2e.BuildConfig
import org.junit.jupiter.api.extension.ConditionEvaluationResult
import org.junit.jupiter.api.extension.ExecutionCondition
import org.junit.jupiter.api.extension.ExtendWith
@@ -19,16 +18,12 @@ annotation class HighlyRateLimited {
context: ExtensionContext?
): ConditionEvaluationResult {
val enableHighlyRateLimited =
- InstrumentationRegistry.getArguments()
- .getString(ENABLE_HIGHLY_RATE_LIMITED)
- ?.toBoolean() ?: false
+ BuildConfig.ENABLE_HIGHLY_RATE_LIMITED_TESTS.toBoolean() ?: false
- if (enableHighlyRateLimited) {
- return ConditionEvaluationResult.enabled(
- "Running test highly affected by rate limiting."
- )
+ return if (enableHighlyRateLimited) {
+ ConditionEvaluationResult.enabled("Running test highly affected by rate limiting.")
} else {
- return ConditionEvaluationResult.disabled(
+ ConditionEvaluationResult.disabled(
"Skipping test highly affected by rate limiting."
)
}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt
index 6dbda8f57e..baf3dcae3d 100644
--- a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt
@@ -4,4 +4,3 @@ const val LOG_TAG = "mullvad-e2e"
const val PARTNER_AUTH = "partner_auth"
const val VALID_TEST_ACCOUNT_NUMBER_ARGUMENT_KEY = "valid_test_account_number"
const val INVALID_TEST_ACCOUNT_NUMBER_ARGUMENT_KEY = "invalid_test_account_number"
-const val ENABLE_HIGHLY_RATE_LIMITED = "enable_highly_rate_limited_tests"
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 0000000000..cab83f243c
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/LeakCheck.kt
@@ -0,0 +1,33 @@
+package net.mullvad.mullvadvpn.test.e2e.misc
+
+import net.mullvad.mullvadvpn.test.e2e.model.Stream
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+object LeakCheck {
+ fun assertNoLeaks(streams: List<Stream>, rules: List<LeakRule>) {
+ // Assert that there are streams to be analyzed. Stream objects are guaranteed to contain
+ // packets when initialized.
+ assertTrue(streams.isNotEmpty())
+
+ for (rule in rules) {
+ assertFalse(rule.isViolated(streams))
+ }
+ }
+
+ fun assertLeaks(streams: List<Stream>, rules: List<LeakRule>) {
+ for (rule in rules) {
+ assertTrue(rule.isViolated(streams))
+ }
+ }
+}
+
+interface LeakRule {
+ fun isViolated(streams: List<Stream>): Boolean
+}
+
+class NoTrafficToHostRule(private val host: String) : LeakRule {
+ override fun isViolated(streams: List<Stream>): Boolean {
+ return streams.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 0000000000..2bbb5bf787
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/Networking.kt
@@ -0,0 +1,23 @@
+package net.mullvad.mullvadvpn.test.e2e.misc
+
+import java.net.Inet4Address
+import java.net.NetworkInterface
+import org.junit.Assert.fail
+
+object Networking {
+ fun getDeviceIpv4Address(): 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 0000000000..aa167c55b4
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/PacketCapture.kt
@@ -0,0 +1,131 @@
+package net.mullvad.mullvadvpn.test.e2e.misc
+
+import co.touchlab.kermit.Logger
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.plugins.HttpResponseValidator
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.accept
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.put
+import io.ktor.client.request.setBody
+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.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.modules.SerializersModule
+import kotlinx.serialization.modules.contextual
+import net.mullvad.mullvadvpn.test.e2e.BuildConfig
+import net.mullvad.mullvadvpn.test.e2e.model.Stream
+import net.mullvad.mullvadvpn.test.e2e.serializer.NanoSecondsTimestampSerializer
+import net.mullvad.mullvadvpn.test.e2e.serializer.PacketCaptureSessionSerializer
+import org.junit.jupiter.api.fail
+
+@JvmInline
+@Serializable(with = PacketCaptureSessionSerializer::class)
+value class PacketCaptureSession(val value: UUID = UUID.randomUUID())
+
+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<Stream> {
+ val parsedPacketsResponse = client.sendGetCapturedPacketsRequest(session)
+ return parsedPacketsResponse.body<List<Stream>>().also { Logger.v("Captured streams: $it") }
+ }
+
+ private suspend fun getPcap(): ByteArray {
+ return client.sendGetPcapFileRequest(session).body<ByteArray>()
+ }
+
+ suspend fun capturePackets(block: suspend () -> Unit): PacketCaptureResult {
+ startCapture()
+ block()
+ stopCapture()
+ return PacketCaptureResult(getParsedCapture(), getPcap())
+ }
+}
+
+private fun defaultHttpClient(): HttpClient =
+ HttpClient(CIO) {
+ defaultRequest { url("http://${BuildConfig.PACKET_CAPTURE_API_HOST}") }
+
+ install(ContentNegotiation) {
+ json(
+ Json {
+ isLenient = true
+ prettyPrint = true
+
+ serializersModule = SerializersModule {
+ contextual(NanoSecondsTimestampSerializer)
+ }
+ }
+ )
+ }
+
+ HttpResponseValidator {
+ validateResponse { response ->
+ val statusCode = response.status.value
+ if (statusCode >= 400) {
+ fail(
+ "Request failed with response status code $statusCode: ${response.body<String>()}"
+ )
+ }
+ }
+ 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)}")
+
+ httpClient.post("capture") {
+ contentType(ContentType.Application.Json)
+ setBody(Json.encodeToString(jsonObject))
+ }
+ }
+
+ suspend fun sendStopCaptureRequest(session: PacketCaptureSession) {
+ Logger.v("Sending stop capture request for session ${session.value}")
+ httpClient.post("stop-capture/${session.value}")
+ }
+
+ suspend fun sendGetCapturedPacketsRequest(session: PacketCaptureSession): HttpResponse {
+ val testDeviceIpAddress = Networking.getDeviceIpv4Address()
+ return httpClient.put("parse-capture/${session.value}") {
+ contentType(ContentType.Application.Json)
+ accept(ContentType.Application.Json)
+ setBody("[\"$testDeviceIpAddress\"]")
+ }
+ }
+
+ suspend fun sendGetPcapFileRequest(session: PacketCaptureSession): HttpResponse {
+ return httpClient.get("last-capture/${session.value}") {
+ accept(ContentType.parse("application/json"))
+ }
+ }
+}
+
+data class PacketCaptureResult(val streams: List<Stream>, val pcap: ByteArray)
+
+@Serializable data class StartCaptureRequestJson(val label: PacketCaptureSession)
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 0000000000..115b2099d5
--- /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
+
+ suspend fun generateTraffic(interval: Duration, block: suspend () -> Unit) = runBlocking {
+ startGeneratingUDPTraffic(interval)
+ block()
+ stopGeneratingUDPTraffic()
+ return@runBlocking Unit
+ }
+
+ 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()
+ }
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Host.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Host.kt
new file mode 100644
index 0000000000..d59e15d017
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Host.kt
@@ -0,0 +1,12 @@
+package net.mullvad.mullvadvpn.test.e2e.model
+
+data class Host(val ipAddress: String, val port: Int) {
+ companion object {
+ fun fromString(connectionInfo: String): Host {
+ val connectionInfoParts = connectionInfo.split(":")
+ val ipAddress = connectionInfoParts.first()
+ val port = connectionInfoParts.last().toInt()
+ return Host(ipAddress, port)
+ }
+ }
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Packet.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Packet.kt
new file mode 100644
index 0000000000..df5c5a57b9
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Packet.kt
@@ -0,0 +1,23 @@
+package net.mullvad.mullvadvpn.test.e2e.model
+
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.mullvad.mullvadvpn.test.e2e.serializer.PacketSerializer
+import org.joda.time.DateTime
+
+@Serializable(with = PacketSerializer::class)
+sealed interface Packet {
+ @SerialName("timestamp") val date: DateTime
+ val fromPeer: Boolean
+}
+
+@Serializable
+data class RxPacket(@SerialName("timestamp") @Contextual override val date: DateTime) : Packet {
+ @SerialName("from_peer") override val fromPeer: Boolean = false
+}
+
+@Serializable
+data class TxPacket(@SerialName("timestamp") @Contextual override val date: DateTime) : Packet {
+ @SerialName("from_peer") override val fromPeer: Boolean = true
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Stream.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Stream.kt
new file mode 100644
index 0000000000..38feff34d6
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/model/Stream.kt
@@ -0,0 +1,49 @@
+package net.mullvad.mullvadvpn.test.e2e.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+import org.joda.time.DateTime
+import org.joda.time.Interval
+
+@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,
+ val packets: List<Packet>,
+) {
+ @Transient val sourceHost = Host.fromString(sourceAddressAndPort)
+ @Transient val destinationHost = Host.fromString(destinationAddressAndPort)
+
+ @Transient private val startDate: DateTime = packets.first().date
+ @Transient private val endDate: DateTime = packets.last().date
+ @Transient private val txStartDate: DateTime? = txPackets().firstOrNull()?.date
+ @Transient private val txEndDate: DateTime? = txPackets().lastOrNull()?.date
+ @Transient private val rxStartDate: DateTime? = rxPackets().firstOrNull()?.date
+ @Transient private val rxEndDate: DateTime? = rxPackets().lastOrNull()?.date
+
+ @Transient val interval = Interval(startDate, endDate)
+
+ fun txPackets(): List<TxPacket> = packets.filterIsInstance<TxPacket>()
+
+ fun rxPackets(): List<RxPacket> = packets.filterIsInstance<RxPacket>()
+
+ fun txInterval(): Interval? =
+ if (txStartDate != null && txEndDate != null) Interval(txStartDate, txEndDate) else null
+
+ fun rxInterval(): Interval? =
+ if (rxStartDate != null && rxEndDate != null) Interval(rxStartDate, rxEndDate) else null
+
+ init {
+ require(packets.isNotEmpty()) { "Stream must contain at least one packet" }
+ }
+}
+
+@Serializable
+enum class NetworkTransportProtocol {
+ @SerialName("tcp") TCP,
+ @SerialName("udp") UDP,
+ @SerialName("icmp") ICMP,
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/NanoSecondsTimestampSerializer.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/NanoSecondsTimestampSerializer.kt
new file mode 100644
index 0000000000..d49ca6017d
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/NanoSecondsTimestampSerializer.kt
@@ -0,0 +1,23 @@
+package net.mullvad.mullvadvpn.test.e2e.serializer
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import org.joda.time.DateTime
+
+object NanoSecondsTimestampSerializer : KSerializer<DateTime> {
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("DateTime", PrimitiveKind.LONG)
+
+ override fun deserialize(decoder: Decoder): DateTime {
+ val long = decoder.decodeLong()
+ return DateTime(long / 1000)
+ }
+
+ override fun serialize(encoder: Encoder, value: DateTime) {
+ throw NotImplementedError("Only interested in deserialization")
+ }
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/PacketCaptureSessionSerializer.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/PacketCaptureSessionSerializer.kt
new file mode 100644
index 0000000000..8ec1a8bed9
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/PacketCaptureSessionSerializer.kt
@@ -0,0 +1,22 @@
+package net.mullvad.mullvadvpn.test.e2e.serializer
+
+import java.util.UUID
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.mullvad.mullvadvpn.test.e2e.misc.PacketCaptureSession
+
+object PacketCaptureSessionSerializer : KSerializer<PacketCaptureSession> {
+ 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.value.toString())
+ }
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/PacketSerializer.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/PacketSerializer.kt
new file mode 100644
index 0000000000..60391218b4
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/serializer/PacketSerializer.kt
@@ -0,0 +1,21 @@
+package net.mullvad.mullvadvpn.test.e2e.serializer
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.json.JsonContentPolymorphicSerializer
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import net.mullvad.mullvadvpn.test.e2e.model.Packet
+import net.mullvad.mullvadvpn.test.e2e.model.RxPacket
+import net.mullvad.mullvadvpn.test.e2e.model.TxPacket
+
+object PacketSerializer : JsonContentPolymorphicSerializer<Packet>(Packet::class) {
+ override fun selectDeserializer(element: JsonElement): KSerializer<out Packet> {
+ return if (element.jsonObject["from_peer"]?.jsonPrimitive?.booleanOrNull!!) {
+ TxPacket.serializer()
+ } else {
+ RxPacket.serializer()
+ }
+ }
+}