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