diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-05-28 12:56:18 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2025-05-28 13:20:00 +0200 |
| commit | a36b4c5178aa8c1eda4a63e048d2e00979384c60 (patch) | |
| tree | 209fac2888a05abed04492dff3c75ed138ec60cd /android/test | |
| parent | 6bb379b0d18d13aa198229dad51068d84b317192 (diff) | |
| download | mullvadvpn-android-throughput-tests.tar.xz mullvadvpn-android-throughput-tests.zip | |
Add speedtest benchmarkandroid-throughput-tests
Diffstat (limited to 'android/test')
24 files changed, 999 insertions, 0 deletions
diff --git a/android/test/benchmark/README.md b/android/test/benchmark/README.md new file mode 100644 index 0000000000..750e27da45 --- /dev/null +++ b/android/test/benchmark/README.md @@ -0,0 +1,20 @@ +# Benchmarks + +## Setup + +Setup the e2e & benchmark properties in your local `gradle.properties`. + +Don't forget to specify the IP and port for your target iPerf testing server. +`mullvad.test.benchmark.target.ip=<IP>` +`mullvad.test.benchmark.target.port=<PORT>` + +## Run tests + +Start the test server and is reachable: +``` +iperf3 -s -p <PORT> +``` +Make sure the ip and port matches what you've previously configured. + +Then start the tests by running the SpeedTests class. The output will be printed in the logs. + diff --git a/android/test/benchmark/build.gradle.kts b/android/test/benchmark/build.gradle.kts new file mode 100644 index 0000000000..4eff073fbf --- /dev/null +++ b/android/test/benchmark/build.gradle.kts @@ -0,0 +1,129 @@ +plugins { + alias(libs.plugins.android.test) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.junit5.android) +} + +android { + namespace = "net.mullvad.mullvadvpn.test.benchmark" + compileSdk = libs.versions.compile.sdk.get().toInt() + buildToolsVersion = libs.versions.build.tools.get() + + defaultConfig { + minSdk = libs.versions.min.sdk.get().toInt() + testApplicationId = "net.mullvad.mullvadvpn.test.benchmark" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["runnerBuilder"] = + "de.mannodermaus.junit5.AndroidJUnit5Builder" + targetProjectPath = ":app" + + missingDimensionStrategy(FlavorDimensions.BILLING, Flavors.PLAY) + + testInstrumentationRunnerArguments += buildMap { + put("clearPackageData", "true") + + // Add all properties starting with "test.e2e" to the testInstrumentationRunnerArguments + properties.forEach { + if (it.key.startsWith("mullvad.test.e2e") || it.key.startsWith("mullvad.test.benchmark")) { + put(it.key, it.value.toString()) + } + } + } + } + + flavorDimensions += FlavorDimensions.INFRASTRUCTURE + + productFlavors { + create(Flavors.PROD) { + dimension = FlavorDimensions.INFRASTRUCTURE + buildConfigField( + type = "String", + name = "INFRASTRUCTURE_BASE_DOMAIN", + value = "\"mullvad.net\"", + ) + } + create(Flavors.STAGEMOLE) { + dimension = FlavorDimensions.INFRASTRUCTURE + buildConfigField( + type = "String", + name = "INFRASTRUCTURE_BASE_DOMAIN", + value = "\"stagemole.eu\"", + ) + } + } + + + testOptions { execution = "ANDROIDX_TEST_ORCHESTRATOR" } + + buildFeatures { buildConfig = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = libs.versions.jvm.target.get() + allWarningsAsErrors = true + } + + lint { + lintConfig = file("${rootProject.projectDir}/config/lint.xml") + abortOnError = true + warningsAsErrors = true + } + + packaging { + resources { + pickFirsts += + setOf( + // Fixes packaging error caused by: jetified-junit-* + "META-INF/LICENSE.md", + "META-INF/LICENSE-notice.md", + ) + } + } + kotlin { + compilerOptions { + freeCompilerArgs.add("-Xjvm-default=all-compatibility") + } + } +} + + +dependencies { + implementation(projects.lib.endpoint) + implementation(projects.test.common) + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + implementation(libs.androidx.test.core) + // Fixes: https://github.com/android/android-test/issues/1589 + implementation(libs.androidx.test.monitor) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.rules) + implementation(libs.androidx.test.uiautomator) + implementation(libs.kermit) + implementation(libs.junit.jupiter.api) + implementation(libs.junit5.android.test.extensions) + implementation(libs.junit5.android.test.runner) + implementation(libs.mockkWebserver) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.auth) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.resources) + implementation(libs.ktor.client.json) + + androidTestUtil(libs.androidx.test.orchestrator) + + // Needed or else the app crashes when launched + implementation(libs.junit5.android.test.compose) + implementation(libs.compose.material3) + + // Need these for forcing later versions of dependencies + implementation(libs.compose.ui) + implementation(libs.androidx.activity.compose) +} diff --git a/android/test/benchmark/lib/arm64-v8a/iperf3.18 b/android/test/benchmark/lib/arm64-v8a/iperf3.18 Binary files differnew file mode 100755 index 0000000000..88b3d782e0 --- /dev/null +++ b/android/test/benchmark/lib/arm64-v8a/iperf3.18 diff --git a/android/test/benchmark/lib/armeabi-v7a/iperf3.18 b/android/test/benchmark/lib/armeabi-v7a/iperf3.18 Binary files differnew file mode 100755 index 0000000000..e89b4c24d3 --- /dev/null +++ b/android/test/benchmark/lib/armeabi-v7a/iperf3.18 diff --git a/android/test/benchmark/lib/riscv64/iperf3.18 b/android/test/benchmark/lib/riscv64/iperf3.18 Binary files differnew file mode 100755 index 0000000000..68f874238c --- /dev/null +++ b/android/test/benchmark/lib/riscv64/iperf3.18 diff --git a/android/test/benchmark/lib/x86/iperf3.18 b/android/test/benchmark/lib/x86/iperf3.18 Binary files differnew file mode 100755 index 0000000000..e0c5e6d306 --- /dev/null +++ b/android/test/benchmark/lib/x86/iperf3.18 diff --git a/android/test/benchmark/lib/x86_64/iperf3.18 b/android/test/benchmark/lib/x86_64/iperf3.18 Binary files differnew file mode 100755 index 0000000000..3773b8ce6d --- /dev/null +++ b/android/test/benchmark/lib/x86_64/iperf3.18 diff --git a/android/test/benchmark/libs/iperf3.jar b/android/test/benchmark/libs/iperf3.jar Binary files differnew file mode 100644 index 0000000000..b6d174cdae --- /dev/null +++ b/android/test/benchmark/libs/iperf3.jar diff --git a/android/test/benchmark/src/main/AndroidManifest.xml b/android/test/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..bc5d11c5d4 --- /dev/null +++ b/android/test/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.INTERNET" /> + <instrumentation + android:name="androidx.test.runner.AndroidJUnitRunner" + android:targetPackage="net.mullvad.mullvadvpn" /> + + <application + android:extractNativeLibs="true" + android:icon="@android:color/background_light" /> +</manifest> diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/BenchmarkTest.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/BenchmarkTest.kt new file mode 100644 index 0000000000..da8c8c6c8c --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/BenchmarkTest.kt @@ -0,0 +1,43 @@ +package net.mullvad.mullvadvpn.test.benchmark + +import android.Manifest.permission.READ_EXTERNAL_STORAGE +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import co.touchlab.kermit.Logger +import de.mannodermaus.junit5.extensions.GrantPermissionExtension +import net.mullvad.mullvadvpn.test.benchmark.constant.LOG_TAG +import net.mullvad.mullvadvpn.test.common.interactor.AppInteractor +import net.mullvad.mullvadvpn.test.common.rule.CaptureScreenshotOnFailedTestRule +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.RegisterExtension + +abstract class BenchmarkTest { + + @RegisterExtension @JvmField val rule = CaptureScreenshotOnFailedTestRule(LOG_TAG) + + @RegisterExtension + @JvmField + val permissionRule: GrantPermissionExtension = + GrantPermissionExtension.grant(WRITE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE) + + lateinit var device: UiDevice + lateinit var context: Context + lateinit var targetContext: Context + lateinit var app: AppInteractor + + @BeforeEach + open fun setup() { + Logger.setTag(LOG_TAG) + + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + context = InstrumentationRegistry.getInstrumentation().context + targetContext = InstrumentationRegistry.getInstrumentation().targetContext + + app = AppInteractor(device, targetContext) + } + + @AfterEach open fun teardown() {} +} diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/IPerf.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/IPerf.kt new file mode 100644 index 0000000000..cf93b8c3af --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/IPerf.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.test.benchmark + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import net.mullvad.mullvadvpn.test.benchmark.constant.getTargetIp +import net.mullvad.mullvadvpn.test.benchmark.constant.getTargetPort +import net.mullvad.mullvadvpn.test.benchmark.model.IperfResult + +@OptIn(ExperimentalSerializationApi::class) +fun runIperf3(context: Context): IperfResult { + val iperf = File(context.applicationInfo.nativeLibraryDir + "/iperf3.18") + iperf.setExecutable(true) + val process = + ProcessBuilder(iperf.absolutePath, "-c", InstrumentationRegistry.getArguments().getTargetIp(), "-p", + InstrumentationRegistry.getArguments().getTargetPort(), "--json").start() + return Json.decodeFromStream<IperfResult>(process.inputStream) +} + +fun IperfResult.summarize(): String = buildString { + appendLine("# Speed #") + appendLine("Receive: ${end.sumReceived.bitsPerSecond / 1000000} Mbit/s") + appendLine("Send: ${end.sumSent.bitsPerSecond / 1000000} Mbit/s") + appendLine("# CPU #") + appendLine("User: ${end.cpuUtilizationPercent.hostUser}") + appendLine("System: ${end.cpuUtilizationPercent.hostSystem}") + appendLine("Total: ${end.cpuUtilizationPercent.hostTotal}") +} diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/IperfResultTest.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/IperfResultTest.kt new file mode 100644 index 0000000000..ab2f76cb7f --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/IperfResultTest.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn.test.benchmark + +import co.touchlab.kermit.Logger +import java.io.BufferedReader +import java.io.File +import java.io.IOException +import java.io.InputStreamReader +import kotlinx.serialization.json.Json +import net.mullvad.mullvadvpn.test.benchmark.model.IperfResult +import org.junit.jupiter.api.Test + +class IperfResultTest : BenchmarkTest() { + + @Test + fun testIPerf() { + val result = runIperf3(context) + Logger.d("!!! TESTING !!!") + Logger.d(result.summarize()) + } +} diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/SpeedTest.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/SpeedTest.kt new file mode 100644 index 0000000000..7e9a67bea2 --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/SpeedTest.kt @@ -0,0 +1,118 @@ +package net.mullvad.mullvadvpn.test.benchmark + +import co.touchlab.kermit.Logger +import net.mullvad.mullvadvpn.test.benchmark.rule.AccountTestRule +import net.mullvad.mullvadvpn.test.common.extension.acceptVpnPermissionDialog +import net.mullvad.mullvadvpn.test.common.page.ConnectPage +import net.mullvad.mullvadvpn.test.common.page.SelectLocationPage +import net.mullvad.mullvadvpn.test.common.page.SettingsPage +import net.mullvad.mullvadvpn.test.common.page.VpnSettingsPage +import net.mullvad.mullvadvpn.test.common.page.on +import net.mullvad.mullvadvpn.test.common.rule.ForgetAllVpnAppsInSettingsTestRule +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class SpeedTest : BenchmarkTest() { + + @RegisterExtension @JvmField val accountTestRule = AccountTestRule() + + @RegisterExtension + @JvmField + val forgetAllVpnAppsInSettingsTestRule = ForgetAllVpnAppsInSettingsTestRule() + + @Test + fun noVpn() { + val result = runIperf3(context) + + Logger.d("# No VPN result #") + Logger.d(result.summarize()) + } + + @Test + fun testNoObfuscation() { + // Given + app.launchAndLogIn(accountTestRule.validAccountNumber) + + on<ConnectPage> { clickSettings() } + on<SettingsPage> { clickVpnSettings() } + on<VpnSettingsPage> { + scrollUntilWireGuardObfuscationOffCell() + clickWireGuardObfuscationOffCell() + } + device.pressBack() + device.pressBack() + + on<ConnectPage> { clickSelectLocation() } + + on<SelectLocationPage> { + clickLocationExpandButton(DEFAULT_COUNTRY) + clickLocationExpandButton(DEFAULT_CITY) + clickLocationCell(DEFAULT_RELAY) + } + + device.acceptVpnPermissionDialog() + + on<ConnectPage> { waitForConnectedLabel() } + + val result = runIperf3(context) + + Logger.d("# No obfuscation #") + Logger.d(result.summarize()) + } + + @Test + fun testUdpOverTcpObfuscation() { + // Given + app.launchAndLogIn(accountTestRule.validAccountNumber) + + on<ConnectPage> { clickSettings() } + on<SettingsPage> { clickVpnSettings() } + on<VpnSettingsPage> { + scrollUntilWireGuardObfuscationUdpOverTcpCell() + clickWireguardObfuscationUdpOverTcpCell() + } + device.pressBack() + device.pressBack() + + on<ConnectPage> { clickConnect() } + + device.acceptVpnPermissionDialog() + + on<ConnectPage> { waitForConnectedLabel() } + + val result = runIperf3(context) + + Logger.d("# Udp-over-Tcp #") + Logger.d(result.summarize()) + } + + @Test + fun testShadowsocks() { + // Given + app.launchAndLogIn(accountTestRule.validAccountNumber) + + on<ConnectPage> { clickSettings() } + on<SettingsPage> { clickVpnSettings() } + on<VpnSettingsPage> { + scrollUntilWireGuardObfuscationShadowsocksCell() + clickWireGuardObfuscationShadowsocksCell() + } + device.pressBack() + device.pressBack() + + on<ConnectPage> { clickConnect() } + + device.acceptVpnPermissionDialog() + + on<ConnectPage> { waitForConnectedLabel() } + + val result = runIperf3(context) + + Logger.d("# Shadowsocks #") + Logger.d(result.summarize()) + } +} + +const val DEFAULT_COUNTRY = "Sweden" +const val DEFAULT_CITY = "Gothenburg" +const val DEFAULT_RELAY = "se-got-wg-001" diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/connectioncheck/ConnCheckResult.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/connectioncheck/ConnCheckResult.kt new file mode 100644 index 0000000000..18f5417314 --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/connectioncheck/ConnCheckResult.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.test.benchmark.api.connectioncheck + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ConnCheckResult( + @SerialName("mullvad_exit_ip") val mullvadExitIp: Boolean, + val ip: String, + val organization: String, + val country: String, + val city: String, + val longitude: Double, + val latitude: Double, +) diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/connectioncheck/ConnectionCheckApi.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/connectioncheck/ConnectionCheckApi.kt new file mode 100644 index 0000000000..a434010462 --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/connectioncheck/ConnectionCheckApi.kt @@ -0,0 +1,48 @@ +package net.mullvad.mullvadvpn.test.benchmark.api.connectioncheck + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.get +import io.ktor.http.ContentType +import io.ktor.http.URLProtocol.Companion.HTTPS +import io.ktor.http.contentType +import io.ktor.http.path +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import net.mullvad.mullvadvpn.test.benchmark.BuildConfig + +class ConnectionCheckApi { + private val client: HttpClient = + HttpClient(CIO) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + install(Logging) { + level = LogLevel.INFO + } + defaultRequest { + url { + protocol = HTTPS + host = BASE_URL + } + contentType(ContentType.Application.Json) + } + expectSuccess = true + } + + suspend fun connectionCheck(): ConnCheckResult = + withContext(Dispatchers.IO) { + client.get { url { path(JSON_PATH) } }.body<ConnCheckResult>() + } + + companion object { + // Connection check + private const val BASE_URL = "am.i.${BuildConfig.INFRASTRUCTURE_BASE_DOMAIN}" + private const val JSON_PATH = "json" + } +} diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/connectioncheck/ConnectionCheckApiTest.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/connectioncheck/ConnectionCheckApiTest.kt new file mode 100644 index 0000000000..39622c25c4 --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/connectioncheck/ConnectionCheckApiTest.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.test.benchmark.api.connectioncheck + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull + +@Disabled("Only used developing the ConnectionCheckApi") +class ConnectionCheckApiTest { + private val connCheckApi = ConnectionCheckApi() + + @Test + fun testConnCheck() = runTest { + val result = connCheckApi.connectionCheck() + assertNotNull(result) + assertFalse(result.mullvadExitIp) + } +} diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/mullvad/MullvadApi.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/mullvad/MullvadApi.kt new file mode 100644 index 0000000000..6d9f75c938 --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/mullvad/MullvadApi.kt @@ -0,0 +1,120 @@ +package net.mullvad.mullvadvpn.test.benchmark.api.mullvad + +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.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.resources.Resources +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.URLProtocol.Companion.HTTPS +import io.ktor.http.contentType +import io.ktor.http.path +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.InternalAPI +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import net.mullvad.mullvadvpn.test.benchmark.BuildConfig + +class MullvadApi { + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + } + private val client: HttpClient = + HttpClient(CIO) { + install(ContentNegotiation) { json(json) } + install(Resources) + install(Logging) { + level = LogLevel.INFO + sanitizeHeader { header -> header == HttpHeaders.Authorization } + } + + defaultRequest { + url { + protocol = HTTPS + host = BASE_URL + } + contentType(ContentType.Application.Json) + } + expectSuccess = true + } + + @OptIn(InternalAPI::class) + suspend fun login(accountNumber: String): String = + withContext(Dispatchers.IO) { + val test = + client + .post { + url { path(AUTH_PATH) } + setBody(LoginRequest(accountNumber)) + } + .also { + Logger.d("Login response: ${it}") + Logger.d("Login response: ${it.bodyAsText()}") + } + .bodyAsText() + json.decodeFromString<LoginResponse>(test). + also { + Logger.d { "Login response: $it" } + }.accessToken + } + + @Serializable data class Device(val name: String, val id: String) + + suspend fun getDeviceList(accessToken: String): List<String> = + withContext(Dispatchers.IO) { + client + .get { + url { path(DEVICES_PATH) } + bearerAuth(accessToken) + } + .body<List<Device>>() + .map { it.id } + } + + suspend fun removeDevice(accessToken: String, deviceId: String) = + withContext(Dispatchers.IO) { + client.delete { + url { path("$DEVICES_PATH/$deviceId") } + bearerAuth(accessToken) + } + } + + companion object { + private const val BASE_URL = "api-app.${BuildConfig.INFRASTRUCTURE_BASE_DOMAIN}" + private const val AUTH_PATH = "auth/v1/token" + private const val DEVICES_PATH = "accounts/v1/devices" + } +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class LoginRequest(@SerialName("account_number") val accountNumber: String) + +@Serializable data class LoginResponse(@SerialName("access_token") val accessToken: String) + +suspend fun MullvadApi.removeAllDevices(accessToken: String) = + withContext(Dispatchers.IO) { + val token = login(accessToken) + val devices = getDeviceList(token) + + devices.map { async { removeDevice(token, it) } }.awaitAll() + } diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/mullvad/MullvadApiTest.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/mullvad/MullvadApiTest.kt new file mode 100644 index 0000000000..fec9e4e4a7 --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/mullvad/MullvadApiTest.kt @@ -0,0 +1,28 @@ +package net.mullvad.mullvadvpn.test.benchmark.api.mullvad + +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.test.benchmark.rule.AccountProvider +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow + +@Disabled("Only used developing the MullvadApi") +class MullvadApiTest { + private val mullvadApi = MullvadApi() + + @Test + fun testLogin() = runTest { + val validAccountNumber = AccountProvider.getValidAccountNumber() + val accessToken = assertDoesNotThrow { mullvadApi.login(validAccountNumber) } + assertTrue(accessToken.isNotBlank()) + } + + @Test + fun testGetDeviceList() = runTest { + val validAccountNumber = AccountProvider.getValidAccountNumber() + val accessToken = assertDoesNotThrow { mullvadApi.login(validAccountNumber) } + assertTrue(accessToken.isNotBlank()) + assertDoesNotThrow { mullvadApi.getDeviceList(accessToken) } + } +} diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/partner/PartnerApi.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/partner/PartnerApi.kt new file mode 100644 index 0000000000..f9210574d3 --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/partner/PartnerApi.kt @@ -0,0 +1,72 @@ +package net.mullvad.mullvadvpn.test.benchmark.api.partner + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.URLProtocol.Companion.HTTPS +import io.ktor.http.contentType +import io.ktor.http.path +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import net.mullvad.mullvadvpn.test.benchmark.BuildConfig + +class PartnerApi(base64AuthCredentials: String) { + private val client: HttpClient = + HttpClient(CIO) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + + install(Logging) { + level = LogLevel.INFO + sanitizeHeader { header -> header == HttpHeaders.Authorization } + } + + defaultRequest { + url { + protocol = HTTPS + host = BASE_URL + } + contentType(ContentType.Application.Json) + + headers { append("Authorization", "Basic $base64AuthCredentials") } + } + expectSuccess = true + } + + suspend fun createAccount(): String = + withContext(Dispatchers.IO) { + client.post { url { path(ACCOUNT_PATH) } }.body<CreateAccountResponse>().id + } + + suspend fun addTime(accountNumber: String, daysToAdd: Int) = + withContext(Dispatchers.IO) { + val request = AddTimeRequest(daysToAdd) + client + .post { + url { path("$ACCOUNT_PATH/$accountNumber/extend") } + setBody(request) + } + .bodyAsText() + } + + companion object { + private const val BASE_URL = "partner.${BuildConfig.INFRASTRUCTURE_BASE_DOMAIN}" + private const val ACCOUNT_PATH = "v1/accounts" + } +} + +@Serializable data class CreateAccountResponse(val id: String) + +@Serializable data class AddTimeRequest(val days: Int) diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/partner/PartnerApiTest.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/partner/PartnerApiTest.kt new file mode 100644 index 0000000000..d709a1331e --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/api/partner/PartnerApiTest.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.test.benchmark.api.partner + +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.test.benchmark.constant.getPartnerAuth +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow + +@Disabled("Only used developing the PartnerApi") +class PartnerApiTest { + private val partnerApi = PartnerApi(InstrumentationRegistry.getArguments().getPartnerAuth()!!) + + @Test + fun testCreateAccount() = runTest { + val accessToken = assertDoesNotThrow { partnerApi.createAccount() } + assertTrue(accessToken.isNotBlank()) + } + + @Test + fun testAddTime() = runTest { + val accessToken = assertDoesNotThrow { partnerApi.createAccount() } + assertDoesNotThrow { partnerApi.addTime(accessToken, 1) } + } +} diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/constant/Constants.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/constant/Constants.kt new file mode 100644 index 0000000000..6d7859386c --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/constant/Constants.kt @@ -0,0 +1,37 @@ +package net.mullvad.mullvadvpn.test.benchmark.constant + +import android.os.Bundle +import net.mullvad.mullvadvpn.test.benchmark.BuildConfig + +const val LOG_TAG = "mullvad-benchmark" + +fun Bundle.getRequiredArgument(argument: String): String { + return getString(argument) + ?: throw IllegalArgumentException("Missing required argument: $argument") +} + +fun Bundle.getPartnerAuth() = getString("mullvad.test.e2e.${BuildConfig.FLAVOR}.partnerAuth") + +fun Bundle.getValidAccountNumber() = + getRequiredArgument("mullvad.test.e2e.${BuildConfig.FLAVOR}.accountNumber.valid") + +fun Bundle.getTargetIp() = getRequiredArgument("mullvad.test.benchmark.target.ip") + +fun Bundle.getTargetPort() = getRequiredArgument("mullvad.test.benchmark.target.port") + +fun Bundle.getInvalidAccountNumber() = + getRequiredArgument("mullvad.test.e2e.${BuildConfig.FLAVOR}.accountNumber.invalid") + +fun Bundle.isRaasEnabled(): Boolean = + getRequiredArgument("mullvad.test.e2e.config.raas.enable").toBoolean() + +fun Bundle.isHighlyRateLimitedTestsEnabled(): Boolean = + getRequiredArgument("mullvad.test.e2e.config.runHighlyRateLimitedTests").toBoolean() + +fun Bundle.getRaasHost() = getRequiredArgument("mullvad.test.e2e.config.raas.host") + +fun Bundle.getTrafficGeneratorHost(): String = + getRequiredArgument("mullvad.test.e2e.config.raas.trafficGenerator.target.host") + +fun Bundle.getTrafficGeneratorPort(): Int = + getRequiredArgument("mullvad.test.e2e.config.raas.trafficGenerator.target.port").toInt() diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/model/IperfResult.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/model/IperfResult.kt new file mode 100644 index 0000000000..a7d6aba7d5 --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/model/IperfResult.kt @@ -0,0 +1,217 @@ +package net.mullvad.mullvadvpn.test.benchmark.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class IperfResult( + val start: Start, + val intervals: List<Interval>, + val end: End, +) + +@Serializable +data class Start( + val connected: List<Connected>, + val version: String, + + @SerialName("system_info") + val systemInfo: String, + val timestamp: Timestamp, + @SerialName("connecting_to") + val connectingTo: ConnectingTo, + val cookie: String, + @SerialName("tcp_mss_default") + val tcpMssDefault: Long, + @SerialName("target_bitrate") + val targetBitrate: Long, + @SerialName("fq_rate") + val fqRate: Long, + @SerialName("sock_bufsize") + val sockBufsize: Long, + @SerialName("sndbuf_actual") + val sndbufActual: Long, + @SerialName("rcvbuf_actual") + val rcvbufActual: Long, + @SerialName("test_start") + val testStart: TestStart, +) + +@Serializable +data class Connected( + val socket: Long, + @SerialName("local_host") + val localHost: String, + @SerialName("local_port") + val localPort: Long, + @SerialName("remote_host") + val remoteHost: String, + @SerialName("remote_port") + val remotePort: Long, +) + +@Serializable +data class Timestamp( + val time: String, + val timesecs: Long, +) + +@Serializable +data class ConnectingTo( + val host: String, + val port: Long, +) + +@Serializable +data class TestStart( + val protocol: String, + @SerialName("num_streams") + val numStreams: Long, + val blksize: Long, + val omit: Long, + val duration: Long, + val bytes: Long, + val blocks: Long, + val reverse: Long, + val tos: Long, + @SerialName("target_bitrate") + val targetBitrate: Long, + val bidir: Long, + val fqrate: Long, + val interval: Long, +) + +@Serializable +data class Interval( + val streams: List<Stream>, + val sum: Sum, +) + +@Serializable +data class Stream( + val socket: Long, + val start: Double, + val end: Double, + val seconds: Double, + val bytes: Long, + @SerialName("bits_per_second") + val bitsPerSecond: Double, + val retransmits: Long, + @SerialName("snd_cwnd") + val sndCwnd: Long, + @SerialName("snd_wnd") + val sndWnd: Long, + val rtt: Long, + val rttvar: Long, + val pmtu: Long, + val omitted: Boolean, + val sender: Boolean, +) + +@Serializable +data class Sum( + val start: Double, + val end: Double, + val seconds: Double, + val bytes: Long, + @SerialName("bits_per_second") + val bitsPerSecond: Double, + val retransmits: Long, + val omitted: Boolean, + val sender: Boolean, +) + +@Serializable +data class End( + val streams: List<Stream2>, + @SerialName("sum_sent") + val sumSent: SumSent, + @SerialName("sum_received") + val sumReceived: SumReceived, + @SerialName("cpu_utilization_percent") + val cpuUtilizationPercent: CpuUtilizationPercent, + @SerialName("sender_tcp_congestion") + val senderTcpCongestion: String, + @SerialName("receiver_tcp_congestion") + val receiverTcpCongestion: String, +) + +@Serializable +data class Stream2( + val sender: Sender, + val receiver: Receiver, +) + +@Serializable +data class Sender( + val socket: Long, + val start: Long, + val end: Double, + val seconds: Double, + val bytes: Long, + @SerialName("bits_per_second") + val bitsPerSecond: Double, + val retransmits: Long, + @SerialName("max_snd_cwnd") + val maxSndCwnd: Long, + @SerialName("max_snd_wnd") + val maxSndWnd: Long, + @SerialName("max_rtt") + val maxRtt: Long, + @SerialName("min_rtt") + val minRtt: Long, + @SerialName("mean_rtt") + val meanRtt: Long, + val sender: Boolean, +) + +@Serializable +data class Receiver( + val socket: Long, + val start: Long, + val end: Double, + val seconds: Double, + val bytes: Long, + @SerialName("bits_per_second") + val bitsPerSecond: Double, + val sender: Boolean, +) + +@Serializable +data class SumSent( + val start: Long, + val end: Double, + val seconds: Double, + val bytes: Long, + @SerialName("bits_per_second") + val bitsPerSecond: Double, + val retransmits: Long, + val sender: Boolean, +) + +@Serializable +data class SumReceived( + val start: Long, + val end: Double, + val seconds: Double, + val bytes: Long, + @SerialName("bits_per_second") + val bitsPerSecond: Double, + val sender: Boolean, +) + +@Serializable +data class CpuUtilizationPercent( + @SerialName("host_total") + val hostTotal: Double, + @SerialName("host_user") + val hostUser: Double, + @SerialName("host_system") + val hostSystem: Double, + @SerialName("remote_total") + val remoteTotal: Double, + @SerialName("remote_user") + val remoteUser: Double, + @SerialName("remote_system") + val remoteSystem: Double, +) diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/rule/AccountProvider.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/rule/AccountProvider.kt new file mode 100644 index 0000000000..64e7a8b224 --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/rule/AccountProvider.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.test.benchmark.rule + +import androidx.test.platform.app.InstrumentationRegistry +import net.mullvad.mullvadvpn.test.benchmark.api.mullvad.MullvadApi +import net.mullvad.mullvadvpn.test.benchmark.api.mullvad.removeAllDevices +import net.mullvad.mullvadvpn.test.benchmark.api.partner.PartnerApi +import net.mullvad.mullvadvpn.test.benchmark.constant.getInvalidAccountNumber +import net.mullvad.mullvadvpn.test.benchmark.constant.getPartnerAuth +import net.mullvad.mullvadvpn.test.benchmark.constant.getValidAccountNumber + +object AccountProvider { + private val mullvadClient = MullvadApi() + private val partnerAuth: String? = InstrumentationRegistry.getArguments().getPartnerAuth() + private val partnerClient: PartnerApi by lazy { PartnerApi(partnerAuth!!) } + + suspend fun getValidAccountNumber() = + // If partner auth is provided, create a new account using the partner API. Otherwise we + // expect and account with time to be provided. + if (partnerAuth != null) { + val accountNumber = partnerClient.createAccount() + partnerClient.addTime(accountNumber = accountNumber, daysToAdd = 1) + accountNumber + } else { + val validAccountNumber = InstrumentationRegistry.getArguments().getValidAccountNumber() + mullvadClient.removeAllDevices(validAccountNumber) + validAccountNumber + } + + fun getInvalidAccountNumber() = InstrumentationRegistry.getArguments().getInvalidAccountNumber() +} diff --git a/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/rule/AccountTestRule.kt b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/rule/AccountTestRule.kt new file mode 100644 index 0000000000..4859b92f2e --- /dev/null +++ b/android/test/benchmark/src/main/kotlin/net/mullvad/mullvadvpn/test/benchmark/rule/AccountTestRule.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.test.benchmark.rule + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class AccountTestRule : BeforeEachCallback { + lateinit var validAccountNumber: String + lateinit var invalidAccountNumber: String + + override fun beforeEach(context: ExtensionContext): Unit = runBlocking { + validAccountNumber = AccountProvider.getValidAccountNumber() + invalidAccountNumber = AccountProvider.getInvalidAccountNumber() + } +} |
