diff --git a/.github/workflows/android-app.yml b/.github/workflows/android-app.yml index 00a33cbeacb9..f7fbfba610c9 100644 --- a/.github/workflows/android-app.yml +++ b/.github/workflows/android-app.yml @@ -539,6 +539,7 @@ jobs: VALID_TEST_ACCOUNT_NUMBER: ${{ secrets.ANDROID_PROD_TEST_ACCOUNT }} INVALID_TEST_ACCOUNT_NUMBER: '0000000000000000' ENABLE_HIGHLY_RATE_LIMITED_TESTS: ${{ github.event_name == 'schedule' && 'true' || 'false' }} + ENABLE_ACCESS_TO_LOCAL_API_TESTS: true REPORT_DIR: ${{ steps.prepare-report-dir.outputs.report_dir }} run: ./android/scripts/run-instrumented-tests-repeat.sh ${{ matrix.test-repeat }} @@ -553,8 +554,9 @@ jobs: clearPackageData=true,\ runnerBuilder=de.mannodermaus.junit5.AndroidJUnit5Builder,\ invalid_test_account_number=0000000000000000,\ - enable_highly_rate_limited_tests=${{ github.event_name == 'schedule' && 'true' || 'false' }},\ - partner_auth=${{ secrets.STAGEMOLE_PARTNER_AUTH }}" + ENABLE_HIGHLY_RATE_LIMITED_TESTS=${{ github.event_name == 'schedule' && 'true' || 'false' }},\ + partner_auth=${{ secrets.STAGEMOLE_PARTNER_AUTH }},\ + ENABLE_ACCESS_TO_LOCAL_API_TESTS=false" strategy: fail-fast: false matrix: 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 5b3b828e3daf..e1157eb3bcd0 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 e56db510e7c7..650c882f79f2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt @@ -15,7 +15,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.compose.test.SWITCH_TEST_TAG import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled @@ -55,7 +57,7 @@ fun MullvadSwitch( Switch( checked = checked, onCheckedChange = onCheckedChange, - modifier = modifier, + modifier = modifier.testTag(SWITCH_TEST_TAG), thumbContent = thumbContent, enabled = enabled, colors = colors, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index 371347bcdd88..7aeacd555399 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 4163010c1d9b..d90f14a763fb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt @@ -17,9 +17,12 @@ const val LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG = const val CUSTOM_PORT_DIALOG_INPUT_TEST_TAG = "custom_port_dialog_input_test_tag" const val LAZY_LIST_WIREGUARD_OBFUSCATION_TITLE_TEST_TAG = "lazy_list_wireguard_obfuscation_title_test_tag" +const val SWITCH_TEST_TAG = "switch_test_tag" // SelectLocationScreen, ConnectScreen, CustomListLocationsScreen const val CIRCULAR_PROGRESS_INDICATOR = "circular_progress_indicator" +const val EXPAND_BUTTON_TEST_TAG = "expand_button_test_tag" +const val LOCATION_CELL_TEST_TAG = "location_cell_test_tag" // ConnectScreen const val SCROLLABLE_COLUMN_TEST_TAG = "scrollable_column_test_tag" @@ -27,6 +30,8 @@ const val SELECT_LOCATION_BUTTON_TEST_TAG = "select_location_button_test_tag" const val CONNECT_BUTTON_TEST_TAG = "connect_button_test_tag" const val RECONNECT_BUTTON_TEST_TAG = "reconnect_button_test_tag" const val CONNECT_CARD_HEADER_TEST_TAG = "connect_card_header_test_tag" +const val LOCATION_INFO_TEST_TAG = "location_info_test_tag" +const val LOCATION_INFO_CONNECTION_IN_TEST_TAG = "location_info_connection_in_test_tag" const val LOCATION_INFO_CONNECTION_OUT_TEST_TAG = "location_info_connection_out_test_tag" // ConnectScreen - Notification banner diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index f0d274a24348..de4fe4b1a513 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" + # 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/gradle/verification-keyring.keys b/android/gradle/verification-keyring.keys index e7d1cac8f129..1ef5c490b220 100644 --- a/android/gradle/verification-keyring.keys +++ b/android/gradle/verification-keyring.keys @@ -1976,6 +1976,28 @@ tCdTcXVhcmUgQ2xpcHB5IDxvcGVuc291cmNlQHNxdWFyZXVwLmNvbT4= -----END PGP PUBLIC KEY BLOCK----- +pub 66D68DAA073BE985 +uid Ceki Gulcu + +sub A1766BE5F812AC2E +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: BCPG v@RELEASE_NAME@ + +mJMEYvEGpBMFK4EEACMEIwQA6knc/2gtbqDhPh5EzrymR4Hwi1Xf2S0aqMopA1zg +IeZzBgSfL+4fEfpXL4eAzvrk29jIXSizDEOgFpw3PW3Om1gASxub4Jo6EQrRgOdd +OlJl1bajIRC4pAoZafDzhOb+FkjJ61lEJzJ6pQtG0Yi24QWDBfXHkSiQSbZFvcC/ +FTJpZua0GENla2kgR3VsY3UgPGNla2lAcW9zLmNoPriXBGLxBqQSBSuBBAAjBCME +AdqQOy84O/j7xo1rAaMB3jGHCn42wBJF8nMVZ1oh6WRN8d33JP0ojCpCK9oe3lyx +jZRvBsVkFhOF5lsb72kqR34hALXmZvhwFhzNoQlz4NuDLg6aQjAQEyiS7NqI2SVT +qbGoyIE6yg2ZLuv2svxk1dNlvtqtfOnmoeIZG3pybRRhyuIVAwEKCYi6BBgTCgAg +FiEEYCAKxK52HxYU1sRnZtaNqgc76YUFAmLxBqQCGwwACgkQZtaNqgc76YUkLAIH +aAcCM1niPs/kj3NEmFl3P9ivExlWa6Q45l8qPgitCLO2v932TElX+ux8O+fv0Ax2 +XJezAj+eMV+lYScyvXpmzbwCB36nuPmtsCJ31kYLXhN2WIJWPvPVesreI/GQUq0W +uAngfd6DOtCKYtNKP7xqDu/2bMU23cxGaRj2ToH4RfCClg1B +=rv9Q +-----END PGP PUBLIC KEY BLOCK----- + + pub 6A65176A0FB1CD0B sub EA8543C570FAF804 sub CA890A5FA09CFD80 diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 38b9504a1dc1..b22bf1df7828 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -120,6 +120,7 @@ + @@ -154,7 +155,10 @@ - + + + + @@ -4370,6 +4374,262 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5177,6 +5437,11 @@ + + + + + @@ -5193,6 +5458,14 @@ + + + + + + + + @@ -5251,6 +5524,11 @@ + + + + + @@ -5295,6 +5573,11 @@ + + + + + @@ -5433,6 +5716,19 @@ + + + + + + + + + + + + + @@ -5692,6 +5988,11 @@ + + + + + @@ -5702,6 +6003,11 @@ + + + + + @@ -5712,6 +6018,11 @@ + + + + + @@ -5900,6 +6211,14 @@ + + + + + + + + @@ -5945,6 +6264,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5955,6 +6316,14 @@ + + + + + + + + @@ -5971,6 +6340,14 @@ + + + + + + + + @@ -5981,6 +6358,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -5997,6 +6398,14 @@ + + + + + + + + @@ -6218,6 +6627,11 @@ + + + + + diff --git a/android/scripts/run-instrumented-tests.sh b/android/scripts/run-instrumented-tests.sh index 650e7f0feea4..fb80995739ab 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 b18a10d50405..34690022c9e5 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt @@ -95,6 +95,20 @@ class AppInteractor( .text } + fun extractInIpv4Address(): String { + device.findObjectWithTimeout(By.res("location_info_test_tag")).click() + val inString = + device + .findObjectWithTimeout( + By.res("location_info_connection_in_test_tag"), + VERY_LONG_TIMEOUT, + ) + .text + + val extractedIpAddress = inString.split(" ")[1].split(":")[0] + return extractedIpAddress + } + fun clickSettingsCog() { device.findObjectWithTimeout(By.res("top_bar_settings_button")).click() } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt new file mode 100644 index 000000000000..57e6ab542b89 --- /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 50e8d177a54d..adbcc042cde7 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 50224489d5c8..4a921c810dba 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.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 58798ef1b6d7..5f880bd2c120 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 ba559ffab0bf..dfa050f9bc9d 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 000000000000..df2c3c0e1577 --- /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 { + 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 { + 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 000000000000..9aa876abcd4f --- /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 a923e03b461e..27b139a5a8b7 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 6dbda8f57e7e..baf3dcae3d11 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 000000000000..cab83f243c03 --- /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, rules: List) { + // 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, rules: List) { + for (rule in rules) { + assertTrue(rule.isViolated(streams)) + } + } +} + +interface LeakRule { + fun isViolated(streams: List): Boolean +} + +class NoTrafficToHostRule(private val host: String) : LeakRule { + override fun isViolated(streams: List): 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 000000000000..2bbb5bf787cb --- /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 000000000000..aa167c55b4c0 --- /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 { + val parsedPacketsResponse = client.sendGetCapturedPacketsRequest(session) + return parsedPacketsResponse.body>().also { Logger.v("Captured streams: $it") } + } + + private suspend fun getPcap(): ByteArray { + return client.sendGetPcapFileRequest(session).body() + } + + 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()}" + ) + } + } + 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, 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 000000000000..115b2099d5bb --- /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 000000000000..d59e15d0178f --- /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 000000000000..df5c5a57b93b --- /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 000000000000..38feff34d647 --- /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, +) { + @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 = packets.filterIsInstance() + + fun rxPackets(): List = packets.filterIsInstance() + + 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 000000000000..d49ca6017da2 --- /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 { + 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 000000000000..8ec1a8bed95a --- /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 { + 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 000000000000..60391218b4b2 --- /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::class) { + override fun selectDeserializer(element: JsonElement): KSerializer { + return if (element.jsonObject["from_peer"]?.jsonPrimitive?.booleanOrNull!!) { + TxPacket.serializer() + } else { + RxPacket.serializer() + } + } +}