Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add negative leak test for Android #6691

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -55,6 +58,7 @@ private fun PreviewCheckableRelayLocationCell(
expanded = false,
depth = 0,
onExpand = {},
modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG),
)
}
}
Expand Down Expand Up @@ -159,6 +163,7 @@ fun RelayItemCell(
color = MaterialTheme.colorScheme.onSurface,
isExpanded = isExpanded,
onClick = { onToggleExpand(!isExpanded) },
modifier = Modifier.testTag(EXPAND_BUTTON_TEST_TAG),
)
}
}
Expand Down Expand Up @@ -213,6 +218,7 @@ private fun Name(modifier: Modifier = Modifier, relay: RelayItem) {

@Composable
private fun RowScope.ExpandButton(
modifier: Modifier,
color: Color,
isExpanded: Boolean,
onClick: (expand: Boolean) -> Unit,
Expand All @@ -225,7 +231,8 @@ private fun RowScope.ExpandButton(
color = color,
isExpanded = isExpanded,
modifier =
Modifier.fillMaxHeight()
modifier
.fillMaxHeight()
.clickable { onClick(!isExpanded) }
.padding(horizontal = Dimens.largePadding)
.align(Alignment.CenterVertically),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_CONNECTION_IN_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_CONNECTION_OUT_TEST_TAG
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
import net.mullvad.mullvadvpn.lib.model.TransportProtocol
Expand Down Expand Up @@ -104,7 +105,9 @@ fun LocationInfo(
text = "${stringResource(id = R.string.in_address)} $textInAddress",
color = colorExpanded,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.alpha(if (isExpanded) AlphaVisible else AlphaInvisible),
modifier =
Modifier.testTag(LOCATION_INFO_CONNECTION_IN_TEST_TAG)
.alpha(if (isExpanded) AlphaVisible else AlphaInvisible),
)
Text(
text = "${stringResource(id = R.string.out_address)} $outAddress",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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
Expand Down Expand Up @@ -429,6 +430,7 @@ fun LazyItemScope.RelayLocationItem(
onToggleExpand = { onExpand(it) },
isExpanded = relayItem.expanded,
depth = relayItem.depth,
modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,20 @@ const val LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_AUTOMATIC_TEST_TAG =
"lazy_list_udp_over_tcp_item_automatic_test_tag"
const val LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_X_TEST_TAG = "lazy_list_udp_over_tcp_item_%d_test_tag"
const val CUSTOM_PORT_DIALOG_INPUT_TEST_TAG = "custom_port_dialog_input_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"
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 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
Expand Down
14 changes: 14 additions & 0 deletions android/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,16 @@ grpc-protobuf = "4.27.2"
koin = "3.5.6"
koin-compose = "3.5.6"

# 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.0"
kotlin-ksp = "2.0.0-1.0.22"
kotlinx = "1.8.1"
kotlinx-serialization = "2.0.20"

# Protobuf
protobuf = "0.9.4"
Expand Down Expand Up @@ -129,6 +133,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" }
Expand Down Expand Up @@ -158,6 +169,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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,32 @@ class AppInteractor(
device.findObjectWithTimeout(By.text("Account"))
}

fun extractIpAddress(): String {
fun extractOutIpAddress(): String {
device.findObjectWithTimeout(By.res("location_info_test_tag")).click()
return device
.findObjectWithTimeout(
// Text exist and contains IP address
By.res("location_info_connection_out_test_tag").textContains("."),
CONNECTION_TIMEOUT,
)
.text
.extractIpAddress()
val outString =
device
.findObjectWithTimeout(
By.res("location_info_connection_out_test_tag"),
CONNECTION_TIMEOUT,
)
.text

val extractedIpAddress = outString.split(" ")[1]
return extractedIpAddress
}

fun extractInIpAddress(): String {
device.findObjectWithTimeout(By.res("location_info_test_tag")).click()
val inString =
device
.findObjectWithTimeout(
By.res("location_info_connection_in_test_tag"),
CONNECTION_TIMEOUT,
)
.text

val extractedIpAddress = inString.split(" ")[1].split(":")[0]
return extractedIpAddress
}

fun clickSettingsCog() {
Expand All @@ -125,8 +141,4 @@ class AppInteractor(
device.findObjectWithTimeout(By.desc("Remove")).click()
clickActionButtonByText("Yes, log out device")
}

private fun String.extractIpAddress(): String {
return split(" ")[1].split(" ")[0]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package net.mullvad.mullvadvpn.test.common.misc

import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import co.touchlab.kermit.Logger
import java.io.File
import java.io.IOException

class Attachment {
companion object {
private const val DIRECTORY_NAME = "test-attachments"

fun clearAttachmentsDirectory() {
val contentResolver =
getInstrumentation().targetContext.applicationContext.contentResolver
val directory = testAttachmentsDirectory()

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (directory.exists()) {
contentResolver.delete(Uri.fromFile(directory), null, null)
Logger.v("Cleared attachments directory")
}
} else {
if (directory.exists()) {
directory.delete()
Logger.v("Cleared attachments directory")
}
}

if (!directory.exists()) {
directory.mkdirs()
Logger.v("Created attachments directory")
}
}

fun saveAttachment(fileName: String, baseDir: String, data: ByteArray) {
val directory = testAttachmentsDirectory()
val contentResolver =
getInstrumentation().targetContext.applicationContext.contentResolver
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/octet-stream")
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS + "/$baseDir/$DIRECTORY_NAME",
)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val uri = Uri.fromFile(testAttachmentsDirectory())
if (uri != null) {
contentResolver.openOutputStream(uri).use { outputStream ->
outputStream?.write(data)
outputStream?.close()
contentResolver.update(uri, contentValues, null, null)

Logger.v("Saved attachment ${uri.toString()}")
}
Logger.v("Saved attachment ${uri.toString()}")
} else {
Logger.e("Failed to save attachment $fileName")
}
} else {
if (!directory.exists()) {
directory.mkdirs()
}

val file = File(directory, fileName)
try {
file.writeBytes(data)
Logger.v("Saved attachment ${file.absolutePath}")
} catch (e: IOException) {
Logger.e("Failed to save attachment $fileName: ${e.message}")
}
}
}

private fun testAttachmentsDirectory(): File {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val uri =
MediaStore.Downloads.EXTERNAL_CONTENT_URI.buildUpon()
.appendPath(DIRECTORY_NAME)
.build()
return File(uri.path!!)
} else {
return File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
DIRECTORY_NAME,
)
}
}
}
}
6 changes: 6 additions & 0 deletions android/test/e2e/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -141,6 +142,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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class ConnectionTest : EndToEndTest(BuildConfig.FLAVOR_infrastructure) {
device.findObjectWithTimeout(By.text("Secure my connection")).click()
device.findObjectWithTimeout(By.text("OK")).click()
device.findObjectWithTimeout(By.text("SECURE CONNECTION"), CONNECTION_TIMEOUT)
val expected = ConnCheckState(true, app.extractIpAddress())
val expected = ConnCheckState(true, app.extractOutIpAddress())

// Then
val result = SimpleMullvadHttpClient(targetContext).runConnectionCheck()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import androidx.test.uiautomator.UiDevice
import co.touchlab.kermit.Logger
import de.mannodermaus.junit5.extensions.GrantPermissionExtension
import net.mullvad.mullvadvpn.test.common.interactor.AppInteractor
import net.mullvad.mullvadvpn.test.common.misc.Attachment
import net.mullvad.mullvadvpn.test.common.rule.CaptureScreenshotOnFailedTestRule
import net.mullvad.mullvadvpn.test.e2e.constant.LOG_TAG
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.extension.RegisterExtension

Expand All @@ -33,10 +35,7 @@ abstract class EndToEndTest(private val infra: String) {
lateinit var targetContext: Context
lateinit var app: AppInteractor

@BeforeEach
fun setup() {
Logger.setTag(LOG_TAG)

init {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
targetContext = InstrumentationRegistry.getInstrumentation().targetContext

Expand All @@ -49,4 +48,21 @@ abstract class EndToEndTest(private val infra: String) {

app = AppInteractor(device, targetContext, "net.mullvad.mullvadvpn$targetPackageNameSuffix")
}

@BeforeEach
fun setup() {
Logger.setTag(LOG_TAG)
}

companion object {
val defaultCountry = "Sweden"
val defaultCity = "Gothenburg"
val defaultRelay = "se-got-wg-001"

@JvmStatic
@BeforeAll
fun setupOnce(): Unit {
Attachment.clearAttachmentsDirectory()
}
}
}
Loading
Loading