diff options
Diffstat (limited to 'android')
31 files changed, 905 insertions, 5 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 173c9c86b7..451d6f2e9b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -68,6 +68,7 @@ android { assets.srcDirs(extraAssetsDirectory) jniLibs.srcDirs(extraJniDirectory) java.srcDirs("src/main/kotlin/") + java.srcDirs("src/debug/kotlin/") } getByName("test") { @@ -185,7 +186,6 @@ dependencies { debugImplementation(Dependencies.AndroidX.fragmentTestning) androidTestImplementation(Dependencies.AndroidX.espressoContrib) androidTestImplementation(Dependencies.AndroidX.espressoCore) - androidTestImplementation(Dependencies.AndroidX.junit) androidTestImplementation(Dependencies.Koin.test) androidTestImplementation(Dependencies.Kotlin.test) androidTestImplementation(Dependencies.MockK.android) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt index 8bd3cc70b8..a241593c00 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt @@ -8,8 +8,8 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest +import androidx.test.runner.AndroidJUnit4 import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerifyAll diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..0eee767e98 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,22 @@ +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + package="net.mullvad.mullvadvpn"> + <application android:label="@string/app_name" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher" + android:theme="@style/AppTheme" + android:extractNativeLibs="true" + android:allowBackup="false" + android:banner="@drawable/banner" + android:name=".MullvadApplication" + tools:ignore="GoogleAppIndexingWarning"> + <activity android:name="net.mullvad.mullvadvpn.TestActivity" + android:label="@string/app_name" + android:launchMode="singleTask" + android:configChanges="orientation|screenSize|screenLayout" + android:screenOrientation="locked" + android:windowSoftInputMode="adjustPan" + android:exported="true"> + </activity> + </application> +</manifest> diff --git a/android/app/src/debug/kotlin/net.mullvad.mullvadvpn/TestActivity.kt b/android/app/src/debug/kotlin/net.mullvad.mullvadvpn/TestActivity.kt new file mode 100644 index 0000000000..df36947eab --- /dev/null +++ b/android/app/src/debug/kotlin/net.mullvad.mullvadvpn/TestActivity.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn + +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Bundle +import android.webkit.WebView +import android.widget.Toast + +class TestActivity : Activity() { + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_test) + val testWebView: WebView = findViewById(R.id.webview) + testWebView.settings.javaScriptEnabled = true + val url = intent.getStringExtra("url") + if (url != null) { + testWebView.loadUrl(url) + } else { + Toast.makeText(applicationContext, "No url specified!", Toast.LENGTH_SHORT).show() + } + } +} diff --git a/android/app/src/debug/res/layout/activity_test.xml b/android/app/src/debug/res/layout/activity_test.xml new file mode 100644 index 0000000000..18f7eafef6 --- /dev/null +++ b/android/app/src/debug/res/layout/activity_test.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".TestActivity"> + <WebView + android:id="@+id/webview" + android:layout_width="match_parent" + android:layout_height="match_parent" /> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index e4d78ecd73..6d019cadf9 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -1,5 +1,6 @@ object Dependencies { const val androidMaterial = "com.google.android.material:material:${Versions.Android.material}" + const val androidVolley = "com.android.volley:volley:${Versions.Android.volley}" const val commonsValidator = "commons-validator:commons-validator:${Versions.commonsValidator}" const val jodaTime = "joda-time:joda-time:${Versions.jodaTime}" const val junit = "junit:junit:${Versions.junit}" @@ -22,11 +23,20 @@ object Dependencies { "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.AndroidX.lifecycle}" const val recyclerview = "androidx.recyclerview:recyclerview:${Versions.AndroidX.recyclerview}" - const val junit = "androidx.test.ext:junit:${Versions.AndroidX.junit}" const val espressoCore = "androidx.test.espresso:espresso-core:${Versions.AndroidX.espresso}" const val espressoContrib = "androidx.test.espresso:espresso-contrib:${Versions.AndroidX.espresso}" + const val testCore = + "androidx.test:core:${Versions.AndroidX.test}" + const val testRunner = + "androidx.test:runner:${Versions.AndroidX.test}" + const val testRules = + "androidx.test:rules:${Versions.AndroidX.test}" + const val testUiAutomator = + "androidx.test.uiautomator:uiautomator:${Versions.AndroidX.uiautomator}" + const val testOrchestrator = + "androidx.test:orchestrator:${Versions.AndroidX.test}" } object Koin { @@ -59,6 +69,7 @@ object Dependencies { object Plugin { const val android = "com.android.tools.build:gradle:${Versions.Plugin.android}" const val androidApplicationId = "com.android.application" + const val androidTestId = "com.android.test" const val playPublisher = "com.github.triplet.gradle:play-publisher:${Versions.Plugin.playPublisher}" const val playPublisherId = "com.github.triplet.play" diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index 8db39a305f..4101a4f572 100644 --- a/android/buildSrc/src/main/kotlin/Versions.kt +++ b/android/buildSrc/src/main/kotlin/Versions.kt @@ -1,7 +1,7 @@ object Versions { const val commonsValidator = "1.7" const val jodaTime = "2.10.14" - const val junit = "4.13" + const val junit = "4.13.2" const val jvmTarget = "1.8" const val koin = "2.2.3" const val kotlin = "1.5.31" @@ -15,6 +15,7 @@ object Versions { const val material = "1.4.0" const val minSdkVersion = 26 const val targetSdkVersion = 30 + const val volley = "1.2.1" } object AndroidX { @@ -26,7 +27,9 @@ object Versions { const val lifecycle = "2.4.1" const val fragment = "1.3.2" const val recyclerview = "1.2.1" - const val junit = "1.1.3" + const val junit = "1.1.4" + const val test = "1.4.0" + const val uiautomator = "2.2.0" } object Plugin { diff --git a/android/e2e/build.gradle.kts b/android/e2e/build.gradle.kts new file mode 100644 index 0000000000..4804dfba70 --- /dev/null +++ b/android/e2e/build.gradle.kts @@ -0,0 +1,115 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import java.util.Properties + +plugins { + id(Dependencies.Plugin.androidTestId) + id(Dependencies.Plugin.kotlinAndroidId) +} + +android { + compileSdkVersion(Versions.Android.compileSdkVersion) + + defaultConfig { + minSdkVersion(Versions.Android.minSdkVersion) + targetSdkVersion(Versions.Android.targetSdkVersion) + testApplicationId = "net.mullvad.mullvadvpn.e2e" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + targetProjectPath = ":app" + + fun Properties.addRequiredPropertyAsBuildConfigField(name: String) { + val value = getProperty(name) ?: throw GradleException("Missing property: $name") + buildConfigField( + type = "String", + name = name, + value = "\"$value\"" + ) + } + + Properties().apply { + load(project.file("e2e.properties").inputStream()) + addRequiredPropertyAsBuildConfigField("API_BASE_URL") + addRequiredPropertyAsBuildConfigField("API_VERSION") + } + + fun MutableMap<String, String>.addOptionalPropertyAsArgument(name: String) { + val value = rootProject.properties.getOrDefault(name, null) as? String + ?: gradleLocalProperties(rootProject.projectDir).getProperty(name) + + if (value != null) { + put(name, value) + } + } + + testInstrumentationRunnerArguments += mutableMapOf<String, String>().apply { + put("clearPackageData", "true") + addOptionalPropertyAsArgument("valid_test_account_token") + addOptionalPropertyAsArgument("invalid_test_account_token") + } + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = Versions.jvmTarget + } +} + +val localScreenshotPath = "$buildDir/reports/androidTests/connected/screenshots" +val deviceScreenshotPath = "/sdcard/Pictures/Screenshots" + +tasks.register("createDeviceScreenshotDir", Exec::class) { + executable = android.adbExecutable.toString() + args = listOf("shell", "mkdir", "-p", deviceScreenshotPath) +} + +tasks.register("createLocalScreenshotDir", Exec::class) { + executable = "mkdir" + args = listOf("-p", localScreenshotPath) +} + +tasks.register("clearDeviceScreenshots", Exec::class) { + executable = android.adbExecutable.toString() + args = listOf("shell", "rm", "-r", deviceScreenshotPath) +} + +tasks.register("fetchScreenshots", Exec::class) { + executable = android.adbExecutable.toString() + args = listOf("pull", "$deviceScreenshotPath/.", localScreenshotPath) + + dependsOn(tasks.getByName("createLocalScreenshotDir")) + finalizedBy(tasks.getByName("clearDeviceScreenshots")) +} + +tasks.whenTaskAdded { + if (name == "connectedDebugAndroidTest") { + dependsOn(tasks.getByName("createDeviceScreenshotDir")) + finalizedBy(tasks.getByName("fetchScreenshots")) + } +} + +configure<org.owasp.dependencycheck.gradle.extension.DependencyCheckExtension> { + // Skip the lintClassPath configuration, which relies on many dependencies that has been flagged + // to have CVEs, as it's related to the lint tooling rather than the project's compilation class + // path. The alternative would be to suppress specific CVEs, however that could potentially + // result in suppressed CVEs in project compilation class path. + skipConfigurations = listOf("lintClassPath") + suppressionFile = "$projectDir/e2e-suppression.xml" +} + +dependencies { + implementation(Dependencies.AndroidX.testCore) + implementation(Dependencies.AndroidX.testOrchestrator) + implementation(Dependencies.AndroidX.testRunner) + implementation(Dependencies.AndroidX.testRules) + implementation(Dependencies.AndroidX.testUiAutomator) + implementation(Dependencies.androidVolley) + implementation(Dependencies.junit) + implementation(Dependencies.Kotlin.stdlib) +} diff --git a/android/e2e/e2e-suppression.xml b/android/e2e/e2e-suppression.xml new file mode 100644 index 0000000000..a3be14e7b4 --- /dev/null +++ b/android/e2e/e2e-suppression.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd"> + <suppress> + <notes><![CDATA[ + This CVE only affect Multiplatform Gradle Projects, which this project is not. + ]]></notes> + <cve>CVE-2022-24329</cve> + </suppress> + <suppress> + <notes><![CDATA[ + This CVE is a false positive as the description refers to a GO library (github.com/containers/storage). + ]]></notes> + <packageUrl regex="true">^pkg:maven/androidx\.test\.services/storage@.*$</packageUrl> + <cve>CVE-2021-20291</cve> + </suppress> +</suppressions> diff --git a/android/e2e/e2e.properties b/android/e2e/e2e.properties new file mode 100644 index 0000000000..b02f6a9381 --- /dev/null +++ b/android/e2e/e2e.properties @@ -0,0 +1,2 @@ +API_BASE_URL=https://api.mullvad.net +API_VERSION=v1-beta1 diff --git a/android/e2e/src/main/AndroidManifest.xml b/android/e2e/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8f3435f452 --- /dev/null +++ b/android/e2e/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="net.mullvad.mullvadvpn.e2e"> + + <uses-permission android:name="android.permission.INTERNET" /> + <instrumentation + android:name="androidx.test.runner.AndroidJUnitRunner" + android:targetPackage="net.mullvad.mullvadvpn" /> +</manifest> diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/ConnectionTest.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/ConnectionTest.kt new file mode 100644 index 0000000000..4334ae2265 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/ConnectionTest.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.e2e + +import androidx.test.uiautomator.By +import junit.framework.Assert.assertEquals +import net.mullvad.mullvadvpn.e2e.extension.findObjectWithTimeout +import net.mullvad.mullvadvpn.e2e.interactor.WebViewInteractor +import net.mullvad.mullvadvpn.e2e.misc.CleanupAccountTestRule +import org.junit.Rule +import org.junit.Test + +class ConnectionTest : EndToEndTest() { + + @Rule + @JvmField + val cleanupAccountTestRule = CleanupAccountTestRule() + + @Test + fun testConnectAndVerifyWithConnectionCheck() { + // Given + app.launchAndEnsureLoggedIn() + + // When + device.findObjectWithTimeout(By.text("Secure my connection")).click() + device.findObjectWithTimeout(By.text("OK")).click() + device.findObjectWithTimeout(By.text("SECURE CONNECTION")) + val expected = WebViewInteractor.ConnCheckState(true, app.extractIpAddress()) + + // Then + val result = web.launchAndExtractConnCheckState() + assertEquals(expected, result) + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/EndToEndTest.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/EndToEndTest.kt new file mode 100644 index 0000000000..d3f3c564b7 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/EndToEndTest.kt @@ -0,0 +1,53 @@ +package net.mullvad.mullvadvpn.e2e + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.UiDevice +import net.mullvad.mullvadvpn.e2e.constant.INVALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY +import net.mullvad.mullvadvpn.e2e.constant.VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY +import net.mullvad.mullvadvpn.e2e.extension.getRequiredArgument +import net.mullvad.mullvadvpn.e2e.interactor.AppInteractor +import net.mullvad.mullvadvpn.e2e.interactor.WebViewInteractor +import net.mullvad.mullvadvpn.e2e.misc.CaptureScreenshotOnFailedTestRule +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +abstract class EndToEndTest { + + @Rule + @JvmField + val rule = CaptureScreenshotOnFailedTestRule() + + lateinit var device: UiDevice + lateinit var targetContext: Context + lateinit var app: AppInteractor + lateinit var web: WebViewInteractor + lateinit var validTestAccountToken: String + lateinit var invalidTestAccountToken: String + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + targetContext = InstrumentationRegistry.getInstrumentation().targetContext + + validTestAccountToken = InstrumentationRegistry.getArguments() + .getRequiredArgument(VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY) + invalidTestAccountToken = InstrumentationRegistry.getArguments() + .getRequiredArgument(INVALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY) + + app = AppInteractor( + device, + targetContext, + validTestAccountToken, + invalidTestAccountToken + ) + + web = WebViewInteractor( + targetContext, + device + ) + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LaunchAppTest.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LaunchAppTest.kt new file mode 100644 index 0000000000..c873d3452c --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LaunchAppTest.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.e2e + +import androidx.test.runner.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LaunchAppTest : EndToEndTest() { + @Test + fun testLaunchApp() { + app.launch() + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LoginTest.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LoginTest.kt new file mode 100644 index 0000000000..4919fb823f --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LoginTest.kt @@ -0,0 +1,58 @@ +package net.mullvad.mullvadvpn.e2e + +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.By +import junit.framework.Assert.assertNotNull +import net.mullvad.mullvadvpn.e2e.constant.LOGIN_FAILURE_TIMEOUT +import net.mullvad.mullvadvpn.e2e.extension.findObjectWithTimeout +import net.mullvad.mullvadvpn.e2e.misc.CleanupAccountTestRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginTest : EndToEndTest() { + + @Rule + @JvmField + val cleanupAccountTestRule = CleanupAccountTestRule() + + @Test + fun testLoginWithInvalidCredentials() { + // Given + val invalidDummyAccountToken = invalidTestAccountToken + + // When + app.launch() + app.attemptLogin(invalidDummyAccountToken) + + // Then + device.findObjectWithTimeout(By.text("Login failed"), LOGIN_FAILURE_TIMEOUT) + } + + @Test + fun testLoginWithValidCredentials() { + // Given + val token = validTestAccountToken + + // When + app.launchAndEnsureLoggedIn(token) + + // Then + app.ensureLoggedIn() + } + + @Test + fun testLogout() { + // Given + app.launchAndEnsureLoggedIn() + + // When + app.clickSettingsCog() + app.clickListItemByText("Account") + app.clickActionButtonByText("Log out") + + // Then + assertNotNull(device.findObjectWithTimeout(By.text("Login"))) + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/WebLinkTest.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/WebLinkTest.kt new file mode 100644 index 0000000000..aaff57de65 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/WebLinkTest.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.e2e + +import androidx.test.uiautomator.By +import net.mullvad.mullvadvpn.e2e.extension.findObjectWithTimeout +import org.junit.Test + +class WebLinkTest : EndToEndTest() { + @Test + fun testOpenFaqFromApp() { + // Given + app.launch() + + // When + device.findObjectWithTimeout(By.text("Login")) + app.clickSettingsCog() + app.clickListItemByText("FAQs & Guides") + + // Then + device.findObjectWithTimeout(By.text("Mullvad help center")) + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/Constants.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/Constants.kt new file mode 100644 index 0000000000..3b6a04b51e --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/Constants.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.e2e.constant + +const val LOG_TAG = "mullvad-e2e" +const val CONN_CHECK_URL = "https://mullvad.net/en/check/" +const val VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY = "valid_test_account_token" +const val INVALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY = "invalid_test_account_token" diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/PackageConstants.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/PackageConstants.kt new file mode 100644 index 0000000000..47aeaa0237 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/PackageConstants.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.e2e.constant + +const val MULLVAD_PACKAGE = "net.mullvad.mullvadvpn" diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/ResourceConstants.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/ResourceConstants.kt new file mode 100644 index 0000000000..07b2f03311 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/ResourceConstants.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.e2e.constant + +const val SETTINGS_COG_ID = "net.mullvad.mullvadvpn:id/settings" +const val TUNNEL_INFO_ID = "net.mullvad.mullvadvpn:id/tunnel_info" +const val TUNNEL_OUT_ADDRESS_ID = "net.mullvad.mullvadvpn:id/out_address" diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TextConstants.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TextConstants.kt new file mode 100644 index 0000000000..ff8e0088d4 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TextConstants.kt @@ -0,0 +1,4 @@ +package net.mullvad.mullvadvpn.e2e.constant + +const val CONNECTION_CHECK_IS_CONNECTED = "Using Mullvad VPN" +const val CONNECTION_CHECK_IS_NOT_CONNECTED = "Not using Mullvad VPN" diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TimeoutConstants.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TimeoutConstants.kt new file mode 100644 index 0000000000..ecc70c28b1 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TimeoutConstants.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.e2e.constant + +const val APP_LAUNCH_TIMEOUT = 5000L +const val CONNECTION_TIMEOUT = 30000L +const val DEFAULT_INTERACTION_TIMEOUT = 3000L +const val LOGIN_TIMEOUT = 30000L +const val LOGIN_FAILURE_TIMEOUT = 60000L diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/BundleExtensions.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/BundleExtensions.kt new file mode 100644 index 0000000000..275bd0b9c7 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/BundleExtensions.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.e2e.extension + +import android.os.Bundle + +fun Bundle.getRequiredArgument(argument: String): String { + return getString(argument) + ?: throw IllegalArgumentException("Missing required argument: $argument") +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/UiAutomatorExtensions.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/UiAutomatorExtensions.kt new file mode 100644 index 0000000000..5ecc16016d --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/UiAutomatorExtensions.kt @@ -0,0 +1,55 @@ +package net.mullvad.mullvadvpn.e2e.extension + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import java.util.regex.Pattern +import net.mullvad.mullvadvpn.e2e.constant.DEFAULT_INTERACTION_TIMEOUT + +fun UiDevice.findObjectByCaseInsensitiveText(text: String): UiObject2 { + return findObjectWithTimeout(By.text(Pattern.compile(text, Pattern.CASE_INSENSITIVE))) +} + +fun UiObject2.findObjectByCaseInsensitiveText(text: String): UiObject2 { + return findObjectWithTimeout(By.text(Pattern.compile(text, Pattern.CASE_INSENSITIVE))) +} + +fun UiDevice.findObjectWithTimeout( + selector: BySelector, + timeout: Long = DEFAULT_INTERACTION_TIMEOUT +): UiObject2 { + + wait( + Until.hasObject(selector), + timeout + ) + + return try { + findObject(selector) + } catch (e: NullPointerException) { + throw IllegalArgumentException( + "No matches for selector within timeout ($timeout): $selector" + ) + } +} + +fun UiObject2.findObjectWithTimeout( + selector: BySelector, + timeout: Long = DEFAULT_INTERACTION_TIMEOUT +): UiObject2 { + + wait( + Until.hasObject(selector), + timeout + ) + + return try { + findObject(selector) + } catch (e: NullPointerException) { + throw IllegalArgumentException( + "No matches for selector within timeout ($timeout): $selector" + ) + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/AppInteractor.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/AppInteractor.kt new file mode 100644 index 0000000000..680850e718 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/AppInteractor.kt @@ -0,0 +1,83 @@ +package net.mullvad.mullvadvpn.e2e.interactor + +import android.content.Context +import android.content.Intent +import android.widget.ImageButton +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import net.mullvad.mullvadvpn.e2e.constant.APP_LAUNCH_TIMEOUT +import net.mullvad.mullvadvpn.e2e.constant.CONNECTION_TIMEOUT +import net.mullvad.mullvadvpn.e2e.constant.LOGIN_TIMEOUT +import net.mullvad.mullvadvpn.e2e.constant.MULLVAD_PACKAGE +import net.mullvad.mullvadvpn.e2e.constant.SETTINGS_COG_ID +import net.mullvad.mullvadvpn.e2e.constant.TUNNEL_INFO_ID +import net.mullvad.mullvadvpn.e2e.constant.TUNNEL_OUT_ADDRESS_ID +import net.mullvad.mullvadvpn.e2e.extension.findObjectWithTimeout + +class AppInteractor( + private val device: UiDevice, + private val targetContext: Context, + private val validTestAccountToken: String, + private val invalidTestAccountToken: String +) { + fun launch() { + device.pressHome() + // Wait for launcher + device.wait( + Until.hasObject(By.pkg(device.launcherPackageName).depth(0)), + APP_LAUNCH_TIMEOUT + ) + val intent = + targetContext.packageManager.getLaunchIntentForPackage(MULLVAD_PACKAGE)?.apply { + // Clear out any previous instances + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + targetContext.startActivity(intent) + device.wait( + Until.hasObject(By.pkg(MULLVAD_PACKAGE).depth(0)), + APP_LAUNCH_TIMEOUT + ) + } + + fun launchAndEnsureLoggedIn(accountToken: String = validTestAccountToken) { + launch() + attemptLogin(accountToken) + ensureLoggedIn() + } + + fun attemptLogin(accountToken: String = validTestAccountToken) { + device.findObjectWithTimeout(By.text("Login")) + val loginObject = device.findObjectWithTimeout(By.clazz("android.widget.EditText")) + .apply { text = accountToken } + loginObject.parent.findObject(By.clazz(ImageButton::class.java)).click() + } + + fun ensureLoggedIn() { + device.findObjectWithTimeout(By.text("UNSECURED CONNECTION"), LOGIN_TIMEOUT) + } + + fun extractIpAddress(): String { + device.findObjectWithTimeout(By.res(TUNNEL_INFO_ID)).click() + return device.findObjectWithTimeout( + By.res(TUNNEL_OUT_ADDRESS_ID), + CONNECTION_TIMEOUT + ).text.extractIpAddress() + } + + fun clickSettingsCog() { + device.findObjectWithTimeout(By.res(SETTINGS_COG_ID)).click() + } + + fun clickListItemByText(text: String) { + device.findObjectWithTimeout(By.text(text)).click() + } + + fun clickActionButtonByText(text: String) { + device.findObjectWithTimeout(By.text(text)).click() + } + + private fun String.extractIpAddress(): String { + return split(" ")[1].split(" ")[0] + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/MullvadAccountInteractor.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/MullvadAccountInteractor.kt new file mode 100644 index 0000000000..08c5a698c3 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/MullvadAccountInteractor.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.e2e.interactor + +import net.mullvad.mullvadvpn.e2e.misc.SimpleMullvadHttpClient + +class MullvadAccountInteractor( + private val httpClient: SimpleMullvadHttpClient, + private val testAccountToken: String +) { + fun cleanupAccount() { + httpClient.removeAllDevices(testAccountToken) + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/SystemSettingsInteractor.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/SystemSettingsInteractor.kt new file mode 100644 index 0000000000..29cef35b0a --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/SystemSettingsInteractor.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.e2e.interactor + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import net.mullvad.mullvadvpn.e2e.extension.findObjectByCaseInsensitiveText + +class SystemSettingsInteractor( + private val uiDevice: UiDevice, + private val context: Context +) { + fun openVpnSettings() { + val intent = Intent("com.intent.MAIN").apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + intent.component = ComponentName.unflattenFromString( + "com.android.settings/.Settings\$VpnSettingsActivity" + ) + context.startActivity(intent) + Thread.sleep(1000) + } + + fun removeAllVpnPermissions() { + openVpnSettings() + uiDevice.findObjects(By.descContains("Settings")).forEach { + it.click() + Thread.sleep(1000) + uiDevice.findObjectByCaseInsensitiveText("forget vpn").click() + Thread.sleep(1000) + uiDevice.findObjectByCaseInsensitiveText("forget").click() + } + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/WebViewInteractor.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/WebViewInteractor.kt new file mode 100644 index 0000000000..df5afc4605 --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/WebViewInteractor.kt @@ -0,0 +1,47 @@ +package net.mullvad.mullvadvpn.e2e.interactor + +import android.content.Context +import android.content.Intent +import android.view.View +import android.webkit.WebView +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import net.mullvad.mullvadvpn.TestActivity +import net.mullvad.mullvadvpn.e2e.constant.CONNECTION_CHECK_IS_CONNECTED +import net.mullvad.mullvadvpn.e2e.constant.CONN_CHECK_URL +import net.mullvad.mullvadvpn.e2e.extension.findObjectByCaseInsensitiveText +import net.mullvad.mullvadvpn.e2e.extension.findObjectWithTimeout + +class WebViewInteractor( + private val context: Context, + private val device: UiDevice +) { + fun launchWebView(context: Context, url: String) { + val intent = Intent(context, TestActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra("url", url) + } + context.startActivity(intent) + } + + fun launchAndExtractConnCheckState(): ConnCheckState { + launchWebView(context, CONN_CHECK_URL) + val webView = device.findObjectWithTimeout(By.clazz(WebView::class.java)) + val stateText = device.findObjectByCaseInsensitiveText("using Mullvad VPN").apply { + click() + } + + // Wait for view to expand after click. + Thread.sleep(1000) + + val wireGuardIpv4ConnectionRow = webView.findObjects(By.clazz(View::class.java)) + .first { it.text?.endsWith("(WireGuard)") == true } + val wireGuardIpv4Address = wireGuardIpv4ConnectionRow.text.split(" ")[0].trim() + return ConnCheckState(stateText.text == CONNECTION_CHECK_IS_CONNECTED, wireGuardIpv4Address) + } + + data class ConnCheckState( + val isConnected: Boolean, + val ipAddress: String + ) +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CaptureScreenshotOnFailedTestRule.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CaptureScreenshotOnFailedTestRule.kt new file mode 100644 index 0000000000..82c43c958b --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CaptureScreenshotOnFailedTestRule.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.e2e.misc + +import android.util.Log +import androidx.test.runner.screenshot.BasicScreenCaptureProcessor +import androidx.test.runner.screenshot.ScreenCaptureProcessor +import androidx.test.runner.screenshot.Screenshot +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import net.mullvad.mullvadvpn.e2e.constant.LOG_TAG +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class CaptureScreenshotOnFailedTestRule : TestWatcher() { + override fun failed(e: Throwable?, description: Description?) { + Log.d(LOG_TAG, "Capturing screenshot of failed test: " + description?.methodName) + val timestamp = DateTimeFormatter.ISO_DATE_TIME.format(LocalDateTime.now()).replace(":", "") + val screenshotName = "$timestamp-${description?.methodName}" + captureScreenshot(screenshotName) + } + + private fun captureScreenshot(screenShotName: String) { + try { + val screenCapture = Screenshot.capture().apply { name = screenShotName } + val processorSet: MutableSet<ScreenCaptureProcessor> = HashSet() + processorSet.add(BasicScreenCaptureProcessor()) + screenCapture.process(processorSet) + } catch (ex: Exception) { + Log.d(LOG_TAG, "Error capturing screenshot: " + ex.message) + } + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CleanupAccountTestRule.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CleanupAccountTestRule.kt new file mode 100644 index 0000000000..17f7f86f6c --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CleanupAccountTestRule.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.e2e.misc + +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import net.mullvad.mullvadvpn.e2e.constant.LOG_TAG +import net.mullvad.mullvadvpn.e2e.constant.VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY +import net.mullvad.mullvadvpn.e2e.extension.getRequiredArgument +import net.mullvad.mullvadvpn.e2e.interactor.MullvadAccountInteractor +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class CleanupAccountTestRule : TestWatcher() { + override fun starting(description: Description?) { + Log.d(LOG_TAG, "Cleaning up account before test: ${description?.methodName}") + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val validTestAccountToken = InstrumentationRegistry.getArguments() + .getRequiredArgument(VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY) + MullvadAccountInteractor(SimpleMullvadHttpClient(targetContext), validTestAccountToken) + .cleanupAccount() + } +} diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/SimpleMullvadHttpClient.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/SimpleMullvadHttpClient.kt new file mode 100644 index 0000000000..ebd90f1bac --- /dev/null +++ b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/SimpleMullvadHttpClient.kt @@ -0,0 +1,191 @@ +package net.mullvad.mullvadvpn.e2e.misc + +import android.content.Context +import android.util.Log +import androidx.test.services.events.TestEventException +import com.android.volley.Request +import com.android.volley.toolbox.JsonArrayRequest +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.RequestFuture +import com.android.volley.toolbox.StringRequest +import com.android.volley.toolbox.Volley +import net.mullvad.mullvadvpn.e2e.BuildConfig +import net.mullvad.mullvadvpn.e2e.constant.LOG_TAG +import org.json.JSONArray +import org.json.JSONObject + +class SimpleMullvadHttpClient(context: Context) { + + private val queue = Volley.newRequestQueue(context) + + fun removeAllDevices(accountToken: String) { + Log.v(LOG_TAG, "Remove all devices") + val token = login(accountToken) + val devices = getDeviceList(token) + devices.forEach { + removeDevice(token, it) + } + Log.v(LOG_TAG, "All devices removed") + } + + fun login(accountToken: String): String { + Log.v(LOG_TAG, "Attempt login with account token: $accountToken") + val json = JSONObject().apply { + put("account_number", accountToken) + } + return sendSimpleSynchronousRequest(Request.Method.POST, AUTH_URL, json)!!.let { response -> + response.getString("access_token").also { accessToken -> + Log.v(LOG_TAG, "Successfully logged in and received access token: $accessToken") + } + } + } + + fun getDeviceList(accessToken: String): List<String> { + Log.v(LOG_TAG, "Get devices") + + val response = sendSimpleSynchronousRequestArray( + Request.Method.GET, + DEVICE_LIST_URL, + token = accessToken + ) + + return response!!.iterator<JSONObject>().asSequence().toList() + .also { + it + .map { jsonObject -> + jsonObject.getString("name") + } + .also { deviceNames -> + Log.v(LOG_TAG, "Devices received: $deviceNames") + } + } + .map { it.getString("id") } + .toList() + } + + fun removeDevice(token: String, deviceId: String) { + Log.v(LOG_TAG, "Remove device: $deviceId") + sendSimpleSynchronousRequestString( + Request.Method.DELETE, + "$DEVICE_LIST_URL/$deviceId", + token = token + ) + } + + private fun sendSimpleSynchronousRequest( + method: Int, + url: String, + body: JSONObject? = null, + token: String? = null + ): JSONObject? { + val future = RequestFuture.newFuture<JSONObject>() + val request = object : JsonObjectRequest( + method, + url, + body, + future, + future + ) { + override fun getHeaders(): MutableMap<String, String> { + val headers = HashMap<String, String>() + if (body != null) { + headers.put("Content-Type", "application/json") + } + if (token != null) { + headers.put("Authorization", "Bearer $token") + } + return headers + } + } + queue.add(request) + return try { + future.get().also { response -> + Log.v(LOG_TAG, "Json object request response: $response") + } + } catch (e: Exception) { + Log.v(LOG_TAG, "Json object request error: ${e.message}") + throw TestEventException(REQUEST_ERROR_MESSAGE) + } + } + + private fun sendSimpleSynchronousRequestString( + method: Int, + url: String, + body: String? = null, + token: String? = null + ): String? { + val future = RequestFuture.newFuture<String>() + val request = object : StringRequest( + method, + url, + future, + future + ) { + override fun getHeaders(): MutableMap<String, String> { + val headers = HashMap<String, String>() + if (body != null) { + headers.put("Content-Type", "application/json") + } + if (token != null) { + headers.put("Authorization", "Bearer $token") + } + return headers + } + } + queue.add(request) + return try { + future.get().also { response -> + Log.v(LOG_TAG, "String request response: $response") + } + } catch (e: Exception) { + Log.v(LOG_TAG, "String request error: ${e.message}") + throw TestEventException(REQUEST_ERROR_MESSAGE) + } + } + + private fun sendSimpleSynchronousRequestArray( + method: Int, + url: String, + body: JSONArray? = null, + token: String? = null + ): JSONArray? { + val future = RequestFuture.newFuture<JSONArray>() + val request = object : JsonArrayRequest( + method, + url, + null, + future, + future + ) { + override fun getHeaders(): MutableMap<String, String> { + val headers = HashMap<String, String>() + headers.put("Content-Type", "application/json") + if (token != null) { + headers.put("Authorization", "Bearer $token") + } + return headers + } + } + queue.add(request) + return try { + future.get().also { response -> + Log.v(LOG_TAG, "Json array request response: $response") + } + } catch (e: Exception) { + Log.v(LOG_TAG, "Json array request error: ${e.message}") + throw TestEventException(REQUEST_ERROR_MESSAGE) + } + } + + operator fun <T> JSONArray.iterator(): Iterator<T> = + (0 until this.length()).asSequence().map { this.get(it) as T }.iterator() + + companion object { + private const val AUTH_URL = + "${BuildConfig.API_BASE_URL}/auth/${BuildConfig.API_VERSION}/token" + private const val DEVICE_LIST_URL = + "${BuildConfig.API_BASE_URL}/accounts/${BuildConfig.API_VERSION}/devices" + private const val REQUEST_ERROR_MESSAGE = + "Unable to verify account due to invalid account or connectivity issues." + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 15a801b10a..cc8e04d837 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1 +1,2 @@ include(":app") +include(":e2e") |
