summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--android/app/build.gradle.kts2
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/ui/fragments/SplitTunnelingFragmentTest.kt2
-rw-r--r--android/app/src/debug/AndroidManifest.xml22
-rw-r--r--android/app/src/debug/kotlin/net.mullvad.mullvadvpn/TestActivity.kt23
-rw-r--r--android/app/src/debug/res/layout/activity_test.xml13
-rw-r--r--android/buildSrc/src/main/kotlin/Dependencies.kt13
-rw-r--r--android/buildSrc/src/main/kotlin/Versions.kt7
-rw-r--r--android/e2e/build.gradle.kts115
-rw-r--r--android/e2e/e2e-suppression.xml16
-rw-r--r--android/e2e/e2e.properties2
-rw-r--r--android/e2e/src/main/AndroidManifest.xml8
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/ConnectionTest.kt32
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/EndToEndTest.kt53
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LaunchAppTest.kt13
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LoginTest.kt58
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/WebLinkTest.kt21
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/Constants.kt6
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/PackageConstants.kt3
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/ResourceConstants.kt5
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TextConstants.kt4
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TimeoutConstants.kt7
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/BundleExtensions.kt8
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/UiAutomatorExtensions.kt55
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/AppInteractor.kt83
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/MullvadAccountInteractor.kt12
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/SystemSettingsInteractor.kt36
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/WebViewInteractor.kt47
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CaptureScreenshotOnFailedTestRule.kt31
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CleanupAccountTestRule.kt21
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/SimpleMullvadHttpClient.kt191
-rw-r--r--android/settings.gradle.kts1
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")