summaryrefslogtreecommitdiffhomepage
path: root/android/test/e2e/src
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/test/e2e/src
parent14be3654e0c77fea3741312acc6ac7da126e18bd (diff)
downloadmullvadvpn-74cfcf0546724c1c7ee82597ea28ccd7801fc74d.tar.xz
mullvadvpn-74cfcf0546724c1c7ee82597ea28ccd7801fc74d.zip
Add minimal leak tests for Android
Diffstat (limited to 'android/test/e2e/src')
-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
15 files changed, 569 insertions, 11 deletions
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()
+ }
+ }
+}