summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorAlbin <albin@mullvad.net>2023-01-10 15:58:10 +0100
committerAlbin <albin@mullvad.net>2023-01-10 15:58:10 +0100
commit7ea38a3881b92b17658a5be4f0e627601212db6c (patch)
tree96c12212b3f95c7ea588099aec7cb5fdb22942ed /android
parentfee3b5804555b3287c9c59aecd3682f118735ba8 (diff)
parent57008a509342f547f5e56ef781a7f2e8a18298c0 (diff)
downloadmullvadvpn-7ea38a3881b92b17658a5be4f0e627601212db6c.tar.xz
mullvadvpn-7ea38a3881b92b17658a5be4f0e627601212db6c.zip
Merge branch 'add-instrumented-tests-using-mocked-api'
Diffstat (limited to 'android')
-rw-r--r--android/app/build.gradle.kts17
-rw-r--r--android/app/src/debug/AndroidManifest.xml4
-rw-r--r--android/app/src/debug/kotlin/net/mullvad/mullvadvpn/TestActivity.kt (renamed from android/app/src/debug/kotlin/net.mullvad.mullvadvpn/TestActivity.kt)0
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt56
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt33
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt13
-rw-r--r--android/buildSrc/src/main/kotlin/Dependencies.kt6
-rw-r--r--android/buildSrc/src/main/kotlin/Projects.kt3
-rw-r--r--android/buildSrc/src/main/kotlin/Versions.kt1
-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/WebLinkTest.kt21
-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/interactor/WebViewInteractor.kt47
-rw-r--r--android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CaptureScreenshotOnFailedTestRule.kt31
-rw-r--r--android/gradle/verification-metadata.xml120
-rw-r--r--android/lib/build.gradle.kts0
-rw-r--r--android/lib/endpoint/build.gradle.kts28
-rw-r--r--android/lib/endpoint/src/debug/kotlin/net/mullvad/mullvadvpn/lib/endpoint/CustomApiEndpointConfiguration.kt10
-rw-r--r--android/lib/endpoint/src/main/AndroidManifest.xml1
-rw-r--r--android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpoint.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/ApiEndpoint.kt)2
-rw-r--r--android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointConfiguration.kt7
-rw-r--r--android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointIntentExtensions.kt18
-rw-r--r--android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/DefaultApiEndpointConfiguration.kt8
-rwxr-xr-xandroid/scripts/run-instrumented-tests.sh118
-rwxr-xr-xandroid/scripts/update-lockfile.sh2
-rw-r--r--android/settings.gradle.kts4
-rw-r--r--android/test/build.gradle.kts0
-rw-r--r--android/test/common/build.gradle.kts45
-rw-r--r--android/test/common/src/main/AndroidManifest.xml1
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/ResourceConstants.kt)3
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TimeoutConstants.kt)3
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/UiAutomatorExtensions.kt)29
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/AppInteractor.kt)36
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt114
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt37
-rw-r--r--android/test/e2e/README.md (renamed from android/e2e/README.md)8
-rw-r--r--android/test/e2e/build.gradle.kts (renamed from android/e2e/build.gradle.kts)47
-rw-r--r--android/test/e2e/e2e.properties (renamed from android/e2e/e2e.properties)2
-rw-r--r--android/test/e2e/src/main/AndroidManifest.xml10
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt38
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/EndToEndTest.kt)35
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LaunchAppTest.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LaunchAppTest.kt)2
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LoginTest.kt)12
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/WebLinkTest.kt27
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/ConnCheckConstants.kt3
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/Constants.kt)3
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/TextConstants.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TextConstants.kt)2
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/extension/BundleExtensions.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/BundleExtensions.kt)2
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/MullvadAccountInteractor.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/MullvadAccountInteractor.kt)4
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/SystemSettingsInteractor.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/SystemSettingsInteractor.kt)4
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/CleanupAccountTestRule.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CleanupAccountTestRule.kt)14
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/ConnCheckState.kt6
-rw-r--r--android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/SimpleMullvadHttpClient.kt (renamed from android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/SimpleMullvadHttpClient.kt)23
-rw-r--r--android/test/mockapi/build.gradle.kts62
-rw-r--r--android/test/mockapi/src/main/AndroidManifest.xml (renamed from android/e2e/src/main/AndroidManifest.xml)0
-rw-r--r--android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/Extensions.kt26
-rw-r--r--android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/JsonUtils.kt41
-rw-r--r--android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/LoginMockApiTest.kt70
-rw-r--r--android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiDispatcher.kt130
-rw-r--r--android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiTest.kt80
-rw-r--r--android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/constant/Constants.kt14
-rw-r--r--android/test/test-suppression.xml (renamed from android/e2e/e2e-suppression.xml)0
64 files changed, 1244 insertions, 296 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index eecf52c85b..d267e0cd6a 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -67,10 +67,12 @@ android {
initWith(buildTypes.getByName("release"))
isMinifyEnabled = false
signingConfig = null
+ matchingFallbacks += "release"
}
create("leakCanary") {
initWith(buildTypes.getByName("debug"))
+ matchingFallbacks += "debug"
}
}
@@ -83,19 +85,6 @@ android {
assets.srcDirs(extraAssetsDirectory, changelogDir)
jniLibs.srcDirs(extraJniDirectory)
- java.srcDirs("src/main/kotlin/")
- }
-
- getByName("debug") {
- java.srcDirs("src/debug/kotlin/")
- }
-
- getByName("test") {
- java.srcDirs("src/test/kotlin/")
- }
-
- getByName("androidTest") {
- java.srcDirs("src/androidTest/kotlin/")
}
}
@@ -189,6 +178,8 @@ play {
}
dependencies {
+ implementation(project(Dependencies.Mullvad.endpointLib))
+
implementation(Dependencies.androidMaterial)
implementation(Dependencies.commonsValidator)
implementation(Dependencies.AndroidX.appcompat)
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 2a866ff601..e1a8f8be40 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -1,5 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
+
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
+
<application android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
diff --git a/android/app/src/debug/kotlin/net.mullvad.mullvadvpn/TestActivity.kt b/android/app/src/debug/kotlin/net/mullvad/mullvadvpn/TestActivity.kt
index df36947eab..df36947eab 100644
--- a/android/app/src/debug/kotlin/net.mullvad.mullvadvpn/TestActivity.kt
+++ b/android/app/src/debug/kotlin/net/mullvad/mullvadvpn/TestActivity.kt
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt
index 0d1750f625..67ec721a52 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt
@@ -2,6 +2,8 @@ package net.mullvad.mullvadvpn.service
import java.io.File
import kotlin.properties.Delegates.observable
+import kotlin.reflect.KClass
+import kotlin.reflect.safeCast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
@@ -9,14 +11,17 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
import net.mullvad.mullvadvpn.util.Intermittent
private const val RELAYS_FILE = "relays.json"
-class DaemonInstance(val vpnService: MullvadVpnService) {
- private enum class Command {
- START,
- STOP,
+class DaemonInstance(
+ val vpnService: MullvadVpnService
+) {
+ sealed class Command {
+ data class Start(val apiEndpointConfiguration: ApiEndpointConfiguration) : Command()
+ object Stop : Command()
}
private val commandChannel = spawnActor()
@@ -27,12 +32,12 @@ class DaemonInstance(val vpnService: MullvadVpnService) {
val intermittentDaemon = Intermittent<MullvadDaemon>()
- fun start() {
- commandChannel.trySendBlocking(Command.START)
+ fun start(apiEndpointConfiguration: ApiEndpointConfiguration) {
+ commandChannel.trySendBlocking(Command.Start(apiEndpointConfiguration))
}
fun stop() {
- commandChannel.trySendBlocking(Command.STOP)
+ commandChannel.trySendBlocking(Command.Stop)
}
fun onDestroy() {
@@ -46,30 +51,25 @@ class DaemonInstance(val vpnService: MullvadVpnService) {
prepareFiles()
while (isRunning) {
- if (!waitForCommand(channel, Command.START)) {
- break
- }
-
- startDaemon()
-
- isRunning = waitForCommand(channel, Command.STOP)
-
+ val startCommand = waitForCommand(channel, Command.Start::class) ?: break
+ startDaemon(startCommand.apiEndpointConfiguration)
+ isRunning = waitForCommand(channel, Command.Stop::class) is Command.Stop
stopDaemon()
}
}
- private suspend fun waitForCommand(
+ private suspend fun <T : Command> waitForCommand(
channel: ReceiveChannel<Command>,
- command: Command
- ): Boolean {
- try {
- while (channel.receive() != command) {
- // Wait for command
- }
-
- return true
+ command: KClass<T>
+ ): T? {
+ return try {
+ var receivedCommand: T?
+ do {
+ receivedCommand = command.safeCast(channel.receive())
+ } while (receivedCommand == null)
+ receivedCommand
} catch (exception: ClosedReceiveChannelException) {
- return false
+ null
}
}
@@ -91,8 +91,10 @@ class DaemonInstance(val vpnService: MullvadVpnService) {
}
}
- private suspend fun startDaemon() {
- val newDaemon = MullvadDaemon(vpnService).apply {
+ private suspend fun startDaemon(
+ apiEndpointConfiguration: ApiEndpointConfiguration
+ ) {
+ val newDaemon = MullvadDaemon(vpnService, apiEndpointConfiguration).apply {
onDaemonStopped = {
intermittentDaemon.spawnUpdate(null)
daemon = null
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt
index aac23cee25..6d82ee617c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt
@@ -2,7 +2,8 @@ package net.mullvad.mullvadvpn.service
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
-import net.mullvad.mullvadvpn.model.ApiEndpoint
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpoint
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
import net.mullvad.mullvadvpn.model.AppVersionInfo
import net.mullvad.mullvadvpn.model.Device
import net.mullvad.mullvadvpn.model.DeviceEvent
@@ -21,7 +22,10 @@ import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.model.VoucherSubmissionResult
import net.mullvad.talpid.util.EventNotifier
-class MullvadDaemon(vpnService: MullvadVpnService) {
+class MullvadDaemon(
+ vpnService: MullvadVpnService,
+ apiEndpointConfiguration: ApiEndpointConfiguration
+) {
protected var daemonInterfaceAddress = 0L
val onSettingsChange = EventNotifier<Settings?>(null)
@@ -39,8 +43,12 @@ class MullvadDaemon(vpnService: MullvadVpnService) {
init {
System.loadLibrary("mullvad_jni")
+
initialize(
- vpnService, vpnService.cacheDir.absolutePath, vpnService.filesDir.absolutePath, null
+ vpnService = vpnService,
+ cacheDirectory = vpnService.cacheDir.absolutePath,
+ resourceDirectory = vpnService.filesDir.absolutePath,
+ apiEndpoint = apiEndpointConfiguration.apiEndpoint()
)
onSettingsChange.notify(getSettings())
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt
index 4753294376..c35125c326 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt
@@ -12,7 +12,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.BuildConfig
import net.mullvad.mullvadvpn.di.vpnServiceModule
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
+import net.mullvad.mullvadvpn.lib.endpoint.DefaultApiEndpointConfiguration
+import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras
import net.mullvad.mullvadvpn.model.Settings
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.service.endpoint.ServiceEndpoint
@@ -64,11 +68,15 @@ class MullvadVpnService : TalpidVpnService() {
}
}
+ private var apiEndpointConfiguration: ApiEndpointConfiguration =
+ DefaultApiEndpointConfiguration()
+
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Initializing service")
loadKoinModules(vpnServiceModule)
+
daemonInstance = DaemonInstance(this)
keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
@@ -97,14 +105,6 @@ class MullvadVpnService : TalpidVpnService() {
endpoint.accountCache
)
- daemonInstance.apply {
- intermittentDaemon.registerListener(this@MullvadVpnService) { daemon ->
- handleDaemonInstance(daemon)
- }
-
- start()
- }
-
// Remove any leftover tunnel state persistence data
getSharedPreferences("tunnel_state", MODE_PRIVATE)
.edit()
@@ -114,6 +114,21 @@ class MullvadVpnService : TalpidVpnService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "Starting service")
+
+ if (BuildConfig.DEBUG) {
+ intent?.getApiEndpointConfigurationExtras()?.let {
+ apiEndpointConfiguration = it
+ }
+ }
+
+ daemonInstance.apply {
+ intermittentDaemon.registerListener(this@MullvadVpnService) { daemon ->
+ handleDaemonInstance(daemon)
+ }
+
+ start(apiEndpointConfiguration)
+ }
+
val startResult = super.onStartCommand(intent, flags, startId)
var quitCommand = false
@@ -231,7 +246,7 @@ class MullvadVpnService : TalpidVpnService() {
daemonInstance.apply {
stop()
- start()
+ start(apiEndpointConfiguration)
}
} else {
Log.d(TAG, "Ignoring restart because onDestroy has executed")
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
index becf82a69e..fc53d94aaf 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
@@ -22,7 +22,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@@ -34,6 +33,7 @@ import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.ChangelogDialog
import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport
import net.mullvad.mullvadvpn.di.uiModule
+import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.DeviceState
import net.mullvad.mullvadvpn.ui.fragments.DeviceRevokedFragment
@@ -103,7 +103,11 @@ open class MainActivity : FragmentActivity() {
override fun onStart() {
Log.d("mullvad", "Starting main activity")
super.onStart()
- serviceConnectionManager.bind(vpnPermissionRequestHandler = ::requestVpnPermission)
+
+ serviceConnectionManager.bind(
+ vpnPermissionRequestHandler = ::requestVpnPermission,
+ apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras()
+ )
}
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt
index e1654476b8..f912f765a8 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt
@@ -8,6 +8,9 @@ import android.os.Messenger
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
+import net.mullvad.mullvadvpn.lib.endpoint.BuildConfig
+import net.mullvad.mullvadvpn.lib.endpoint.putApiEndpointConfigurationExtra
import net.mullvad.mullvadvpn.service.MullvadVpnService
import net.mullvad.talpid.util.EventNotifier
@@ -47,9 +50,17 @@ class ServiceConnectionManager(
}
}
- fun bind(vpnPermissionRequestHandler: () -> Unit) {
+ fun bind(
+ vpnPermissionRequestHandler: () -> Unit,
+ apiEndpointConfiguration: ApiEndpointConfiguration?
+ ) {
this.vpnPermissionRequestHandler = vpnPermissionRequestHandler
val intent = Intent(context, MullvadVpnService::class.java)
+
+ if (BuildConfig.DEBUG && apiEndpointConfiguration != null) {
+ intent.putApiEndpointConfigurationExtra(apiEndpointConfiguration)
+ }
+
context.startService(intent)
context.bindService(intent, serviceConnection, 0)
}
diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt
index 777cea97bc..9832268b5e 100644
--- a/android/buildSrc/src/main/kotlin/Dependencies.kt
+++ b/android/buildSrc/src/main/kotlin/Dependencies.kt
@@ -6,6 +6,7 @@ object Dependencies {
const val junit = "junit:junit:${Versions.junit}"
const val leakCanary = "com.squareup.leakcanary:leakcanary-android:${Versions.leakCanary}"
const val turbine = "app.cash.turbine:turbine:${Versions.turbine}"
+ const val mockkWebserver = "com.squareup.okhttp3:mockwebserver:${Versions.mockWebserver}"
object AndroidX {
const val appcompat = "androidx.appcompat:appcompat:${Versions.AndroidX.appcompat}"
@@ -86,6 +87,10 @@ object Dependencies {
const val android = "io.mockk:mockk-android:${Versions.mockk}"
}
+ object Mullvad {
+ const val endpointLib = ":lib:endpoint"
+ }
+
object Plugin {
const val aaptLinux = "com.android.tools.build:aapt2:${Versions.Plugin.androidAapt}:linux"
const val aaptOsx = "com.android.tools.build:aapt2:${Versions.Plugin.androidAapt}:osx"
@@ -93,6 +98,7 @@ object Dependencies {
"com.android.tools.build:aapt2:${Versions.Plugin.androidAapt}:windows"
const val android = "com.android.tools.build:gradle:${Versions.Plugin.android}"
const val androidApplicationId = "com.android.application"
+ const val androidLibraryId = "com.android.library"
const val androidTestId = "com.android.test"
const val playPublisher =
"com.github.triplet.gradle:play-publisher:${Versions.Plugin.playPublisher}"
diff --git a/android/buildSrc/src/main/kotlin/Projects.kt b/android/buildSrc/src/main/kotlin/Projects.kt
new file mode 100644
index 0000000000..7558674654
--- /dev/null
+++ b/android/buildSrc/src/main/kotlin/Projects.kt
@@ -0,0 +1,3 @@
+object Projects {
+ const val testCommon = ":test:common"
+}
diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt
index f895c0045c..e8bbceed96 100644
--- a/android/buildSrc/src/main/kotlin/Versions.kt
+++ b/android/buildSrc/src/main/kotlin/Versions.kt
@@ -9,6 +9,7 @@ object Versions {
const val kotlinx = "1.6.4"
const val leakCanary = "2.10"
const val mockk = "1.13.3"
+ const val mockWebserver = "4.10.0"
const val turbine = "0.12.1"
object Android {
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
deleted file mode 100644
index 4334ae2265..0000000000
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/ConnectionTest.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-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/WebLinkTest.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/WebLinkTest.kt
deleted file mode 100644
index aaff57de65..0000000000
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/WebLinkTest.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-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/PackageConstants.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/PackageConstants.kt
deleted file mode 100644
index 47aeaa0237..0000000000
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/PackageConstants.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-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/interactor/WebViewInteractor.kt b/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/WebViewInteractor.kt
deleted file mode 100644
index df5afc4605..0000000000
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/WebViewInteractor.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-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
deleted file mode 100644
index 82c43c958b..0000000000
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CaptureScreenshotOnFailedTestRule.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-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/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml
index 8496d3b855..c1771f0b4b 100644
--- a/android/gradle/verification-metadata.xml
+++ b/android/gradle/verification-metadata.xml
@@ -1014,6 +1014,14 @@
<sha256 value="64fd82365fbac35a456a2bdd5fdcd41772b3f09e904ff4a43d1f40e61641d950" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="com.android.tools.build" name="aapt2-proto" version="7.0.0-beta04-7396180">
+ <artifact name="aapt2-proto-7.0.0-beta04-7396180.jar">
+ <sha256 value="1ca4f1b0f550c6c25f63c1916da84f6e7a92c66b7ad38ab1d5d49a20552a5984" origin="Generated by Gradle"/>
+ </artifact>
+ <artifact name="aapt2-proto-7.0.0-beta04-7396180.module">
+ <sha256 value="22b5d69296c06b99261c2b5485994af1e6e7e02beb6a8d213ce88dae76660755" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="com.android.tools.build" name="aapt2-proto" version="7.3.1-8691043">
<artifact name="aapt2-proto-7.3.1-8691043.jar">
<sha256 value="d5e2f3e1e1eb06224b6875f5e513c72a65182342745718160caf191d46a96664" origin="Generated by Gradle"/>
@@ -1124,11 +1132,46 @@
<sha256 value="a0f464cbd7f528aa15846611626c7afcd08649804ca72782c6e3f291a065a0c1" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="com.android.tools.external.com-intellij" name="intellij-core" version="30.3.1">
+ <artifact name="intellij-core-30.3.1.jar">
+ <sha256 value="8bb2aecfb8dd2208fe341a731ac44a9ec83f989c12449b82dff1eab493de0408" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
+ <component group="com.android.tools.external.com-intellij" name="kotlin-compiler" version="30.3.1">
+ <artifact name="kotlin-compiler-30.3.1.jar">
+ <sha256 value="ccdc9e1abfafdec71f4f93a3a2ad6230a1384925b06fc277d4ed921de5bb6fd8" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
+ <component group="com.android.tools.external.org-jetbrains" name="uast" version="30.3.1">
+ <artifact name="uast-30.3.1.jar">
+ <sha256 value="47bf3fc2a6a9aa09f728788e869033341abd9d8cdb9fc61087dfb7cd576076d8" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="com.android.tools.layoutlib" name="layoutlib-api" version="30.3.1">
<artifact name="layoutlib-api-30.3.1.jar">
<sha256 value="7ffb2c13ac92e2d2d454c617ec537ad3b8868a987118c4d3c62018125d656707" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="com.android.tools.lint" name="lint" version="30.3.1">
+ <artifact name="lint-30.3.1.jar">
+ <sha256 value="685080782f6368cc68ac74db065b4c5c59d9c82599dc34be2ec39e425251b864" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
+ <component group="com.android.tools.lint" name="lint-api" version="30.3.1">
+ <artifact name="lint-api-30.3.1.jar">
+ <sha256 value="cc6ebd7a146226363aa38bbb4a10d3e329079ac3201dbfeb562605be691d482c" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
+ <component group="com.android.tools.lint" name="lint-checks" version="30.3.1">
+ <artifact name="lint-checks-30.3.1.jar">
+ <sha256 value="a6a728be66b4e1cb61333f77f519f8213450989be0fbe125ff77e6241d07be8e" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
+ <component group="com.android.tools.lint" name="lint-gradle" version="30.3.1">
+ <artifact name="lint-gradle-30.3.1.jar">
+ <sha256 value="331c4ac1769c26bf90657db219dcc639ca63fe2fbae9db1ff674dd0a62c74676" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="com.android.tools.lint" name="lint-model" version="30.3.1">
<artifact name="lint-model-30.3.1.jar">
<sha256 value="4b6fc00a29c8dc716fb3a53b200af711318d2a8db5fa6c3d874b6d6d65e44541" origin="Generated by Gradle"/>
@@ -1782,6 +1825,22 @@
<sha256 value="2a7bca66d6ccf16855cb5a0aa7c95158f688925a263efe4c204b237254861fa1" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="com.squareup.okhttp3" name="mockwebserver" version="4.10.0">
+ <artifact name="mockwebserver-4.10.0.jar">
+ <sha256 value="af29da234e63159d6e0dea43bf8288eea97d71cdf1651a5ee2d6c0d0d4adbf8f" origin="Generated by Gradle"/>
+ </artifact>
+ <artifact name="mockwebserver-4.10.0.module">
+ <sha256 value="75c72158aa5a0e052610d149b2746f6e290be8f338f835c69eb4589c4888bba5" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
+ <component group="com.squareup.okhttp3" name="okhttp" version="4.10.0">
+ <artifact name="okhttp-4.10.0.jar">
+ <sha256 value="7580f14fa1691206e37081ad3f92063b1603b328da0bb316f2fef02e0562e7ec" origin="Generated by Gradle"/>
+ </artifact>
+ <artifact name="okhttp-4.10.0.module">
+ <sha256 value="6c3070820b591f5ec8c2948497b5a6b742f492b715bcacf4b75115b3a8ffab15" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="com.squareup.okhttp3" name="okhttp" version="4.9.3">
<artifact name="okhttp-4.9.3.jar">
<sha256 value="93ecd6cba19d87dccfe566ec848d91aae799e3cf16c00709358ea69bd9227219" origin="Generated by Gradle"/>
@@ -1808,6 +1867,22 @@
<sha256 value="17baab7270389a5fa63ab12811864d0a00f381611bc4eb042fa1bd5918ed0965" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="com.squareup.okio" name="okio" version="3.0.0">
+ <artifact name="okio-3.0.0.module">
+ <sha256 value="6f9e3a797831e75c5b562d946c075183f9b2be846791e9f88bde45491daed987" origin="Generated by Gradle"/>
+ </artifact>
+ <artifact name="okio-metadata-3.0.0.jar">
+ <sha256 value="dcbe63ed43b2c90c325e9e6a0863e2e7605980bff5e728c6de1088be5574979e" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
+ <component group="com.squareup.okio" name="okio-jvm" version="3.0.0">
+ <artifact name="okio-jvm-3.0.0.jar">
+ <sha256 value="be64a0cc1f28ea9cd5c970dd7e7557af72c808d738c495b397bf897c9921e907" origin="Generated by Gradle"/>
+ </artifact>
+ <artifact name="okio-jvm-3.0.0.module">
+ <sha256 value="17f48d41775bd84dea78e9dfed8dfbcc66af80567a5c9ec9d9608785ec820cde" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="com.sun.activation" name="javax.activation" version="1.2.0">
<artifact name="javax.activation-1.2.0.jar">
<sha256 value="993302b16cd7056f21e779cc577d175a810bb4900ef73cd8fbf2b50f928ba9ce" origin="Generated by Gradle"/>
@@ -1838,6 +1913,11 @@
<sha256 value="7d938c81789028045c08c065e94be75fc280527620d5bd62b519d5838532368a" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="commons-codec" name="commons-codec" version="1.10">
+ <artifact name="commons-codec-1.10.jar">
+ <sha256 value="4241dfa94e711d435f29a4604a3e2de5c4aa3c165e23bd066be6fc1fc4309569" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="commons-codec" name="commons-codec" version="1.11">
<artifact name="commons-codec-1.11.jar">
<sha256 value="e599d5318e97aa48f42136a2927e6dfa4e8881dff0e6c8e3109ddbbff51d7b7d" origin="Generated by Gradle"/>
@@ -2246,6 +2326,11 @@
<sha256 value="1df8b9430b5c8ed143d7815e403e33ef5371b2400aadbe9bda0883762e0846d1" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="org.apache.commons" name="commons-compress" version="1.20">
+ <artifact name="commons-compress-1.20.jar">
+ <sha256 value="0aeb625c948c697ea7b205156e112363b59ed5e2551212cd4e460bdb72c7c06e" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="org.apache.commons" name="commons-compress" version="1.22">
<artifact name="commons-compress-1.22.jar">
<sha256 value="53d04a0efc7223baecaa303bd5d298eb0600e6b82b4076f9cecd558b97ba760b" origin="Generated by Gradle"/>
@@ -2281,6 +2366,16 @@
<sha256 value="6fe9026a566c6a5001608cf3fc32196641f6c1e5e1986d1037ccdbd5f31ef743" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="org.apache.httpcomponents" name="httpclient" version="4.5.6">
+ <artifact name="httpclient-4.5.6.jar">
+ <sha256 value="c03f813195e7a80e3608d0ddd8da80b21696a4c92a6a2298865bf149071551c7" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
+ <component group="org.apache.httpcomponents" name="httpcore" version="4.4.13">
+ <artifact name="httpcore-4.4.13.jar">
+ <sha256 value="e06e89d40943245fcfa39ec537cdbfce3762aecde8f9c597780d2b00c2b43424" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="org.apache.httpcomponents" name="httpcore" version="4.4.14">
<artifact name="httpcore-4.4.14.jar">
<sha256 value="f956209e450cb1d0c51776dfbd23e53e9dd8db9a1298ed62b70bf0944ba63b28" origin="Generated by Gradle"/>
@@ -2374,6 +2469,11 @@
<sha256 value="729990b3f18a95606fc2573836b6958bcdb44cb52bfbd1b7aa9c339cff35a5a4" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="org.codehaus.groovy" name="groovy" version="3.0.9">
+ <artifact name="groovy-3.0.9.jar">
+ <sha256 value="77bf86897f295f8cae2e1f46b1eca109f487ba81b66ef24a2b6dcba1eb7d6ce7" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="org.codehaus.mojo" name="animal-sniffer-annotations" version="1.19">
<artifact name="animal-sniffer-annotations-1.19.jar">
<sha256 value="e67ec27ceeaf13ab5d54cf5fdbcc544c41b4db8d02d9f006678cca2c7c13ee9d" origin="Generated by Gradle"/>
@@ -2472,6 +2572,11 @@
<sha256 value="ace2a10dc8e2d5fd34925ecac03e4988b2c0f851650c94b8cef49ba1bd111478" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="org.jetbrains.intellij.deps" name="trove4j" version="1.0.20181211">
+ <artifact name="trove4j-1.0.20181211.jar">
+ <sha256 value="affb7c85a3c87bdcf69ff1dbb84de11f63dc931293934bc08cd7ab18de083601" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="org.jetbrains.intellij.deps" name="trove4j" version="1.0.20200330">
<artifact name="trove4j-1.0.20200330.jar">
<sha256 value="c5fd725bffab51846bf3c77db1383c60aaaebfe1b7fe2f00d23fe1b7df0a439d" origin="Generated by Gradle"/>
@@ -2674,6 +2779,11 @@
<sha256 value="67a2665d697e9e9416a29a4b6083e355a287920f337694995fbe880a664c2bff" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="org.jetbrains.kotlin" name="kotlin-reflect" version="1.5.31">
+ <artifact name="kotlin-reflect-1.5.31.jar">
+ <sha256 value="6e0f5490e6b9649ddd2670534e4d3a03bd283c3358b8eef5d1304fd5f8a5a4fb" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="org.jetbrains.kotlin" name="kotlin-reflect" version="1.7.10">
<artifact name="kotlin-reflect-1.7.10.jar">
<sha256 value="187c5e5a588a6ed18c3a41b54df138a5944121bdb396be1c3fa4abee67397955" origin="Generated by Gradle"/>
@@ -2777,6 +2887,11 @@
<sha256 value="a25bf47353ce899d843cbddee516d621a73473e7fba97f8d0301e7b4aed7c15f" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.6.10">
+ <artifact name="kotlin-stdlib-jdk7-1.6.10.jar">
+ <sha256 value="2aedcdc6b69b33bdf5cc235bcea88e7cf6601146bb6bcdffdb312bbacd7be261" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.6.21">
<artifact name="kotlin-stdlib-jdk7-1.6.21.jar">
<sha256 value="f1b0634dbb94172038463020bb2dd45ca26849f8ce29d625acb0f1569d11dbee" origin="Generated by Gradle"/>
@@ -2797,6 +2912,11 @@
<sha256 value="b548f7767aacf029d2417e47440742bd6d3ebede19b60386e23554ce5c4c5fdc" origin="Generated by Gradle"/>
</artifact>
</component>
+ <component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk8" version="1.6.10">
+ <artifact name="kotlin-stdlib-jdk8-1.6.10.jar">
+ <sha256 value="1456d82d039ea30d8485b032901f52bbf07e7cdbe8bb1f8708ad32a8574c41ce" origin="Generated by Gradle"/>
+ </artifact>
+ </component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk8" version="1.6.21">
<artifact name="kotlin-stdlib-jdk8-1.6.21.jar">
<sha256 value="dab45489b47736d59fce44b80676f1947a9b6bcab10fd60e878a83bd82a6954c" origin="Generated by Gradle"/>
diff --git a/android/lib/build.gradle.kts b/android/lib/build.gradle.kts
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/android/lib/build.gradle.kts
diff --git a/android/lib/endpoint/build.gradle.kts b/android/lib/endpoint/build.gradle.kts
new file mode 100644
index 0000000000..d289749704
--- /dev/null
+++ b/android/lib/endpoint/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ id(Dependencies.Plugin.androidLibraryId)
+ id(Dependencies.Plugin.kotlinAndroidId)
+ id(Dependencies.Plugin.kotlinParcelizeId)
+}
+
+android {
+ namespace = "net.mullvad.mullvadvpn.lib.endpoint"
+ compileSdk = Versions.Android.compileSdkVersion
+
+ defaultConfig {
+ minSdk = Versions.Android.minSdkVersion
+ targetSdk = Versions.Android.targetSdkVersion
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = Versions.jvmTarget
+ }
+}
+
+dependencies {
+ implementation(Dependencies.Kotlin.stdlib)
+}
diff --git a/android/lib/endpoint/src/debug/kotlin/net/mullvad/mullvadvpn/lib/endpoint/CustomApiEndpointConfiguration.kt b/android/lib/endpoint/src/debug/kotlin/net/mullvad/mullvadvpn/lib/endpoint/CustomApiEndpointConfiguration.kt
new file mode 100644
index 0000000000..b3a00c809c
--- /dev/null
+++ b/android/lib/endpoint/src/debug/kotlin/net/mullvad/mullvadvpn/lib/endpoint/CustomApiEndpointConfiguration.kt
@@ -0,0 +1,10 @@
+package net.mullvad.mullvadvpn.lib.endpoint
+
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class CustomApiEndpointConfiguration(
+ val apiEndpoint: ApiEndpoint
+) : ApiEndpointConfiguration {
+ override fun apiEndpoint() = apiEndpoint
+}
diff --git a/android/lib/endpoint/src/main/AndroidManifest.xml b/android/lib/endpoint/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..cc947c5679
--- /dev/null
+++ b/android/lib/endpoint/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest />
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/ApiEndpoint.kt b/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpoint.kt
index df40bfac4d..7325e3f61b 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/ApiEndpoint.kt
+++ b/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpoint.kt
@@ -1,4 +1,4 @@
-package net.mullvad.mullvadvpn.model
+package net.mullvad.mullvadvpn.lib.endpoint
import android.os.Parcelable
import java.net.InetSocketAddress
diff --git a/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointConfiguration.kt b/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointConfiguration.kt
new file mode 100644
index 0000000000..164a9fffa7
--- /dev/null
+++ b/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointConfiguration.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.lib.endpoint
+
+import android.os.Parcelable
+
+interface ApiEndpointConfiguration : Parcelable {
+ fun apiEndpoint(): ApiEndpoint?
+}
diff --git a/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointIntentExtensions.kt b/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointIntentExtensions.kt
new file mode 100644
index 0000000000..cf2f2fb0dd
--- /dev/null
+++ b/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointIntentExtensions.kt
@@ -0,0 +1,18 @@
+package net.mullvad.mullvadvpn.lib.endpoint
+
+import android.content.Intent
+import android.os.Build
+
+private const val OVERRIDE_API_EXTRA_NAME = "override_api"
+
+fun Intent.putApiEndpointConfigurationExtra(apiEndpointConfiguration: ApiEndpointConfiguration) {
+ putExtra(OVERRIDE_API_EXTRA_NAME, apiEndpointConfiguration)
+}
+
+fun Intent.getApiEndpointConfigurationExtras(): ApiEndpointConfiguration? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableExtra(OVERRIDE_API_EXTRA_NAME, ApiEndpointConfiguration::class.java)
+ } else {
+ getParcelableExtra(OVERRIDE_API_EXTRA_NAME)
+ }
+}
diff --git a/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/DefaultApiEndpointConfiguration.kt b/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/DefaultApiEndpointConfiguration.kt
new file mode 100644
index 0000000000..90b9bc7896
--- /dev/null
+++ b/android/lib/endpoint/src/main/kotlin/net/mullvad/mullvadvpn/lib/endpoint/DefaultApiEndpointConfiguration.kt
@@ -0,0 +1,8 @@
+package net.mullvad.mullvadvpn.lib.endpoint
+
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+class DefaultApiEndpointConfiguration : ApiEndpointConfiguration {
+ override fun apiEndpoint(): ApiEndpoint? = null
+}
diff --git a/android/scripts/run-instrumented-tests.sh b/android/scripts/run-instrumented-tests.sh
new file mode 100755
index 0000000000..27eb8a9be8
--- /dev/null
+++ b/android/scripts/run-instrumented-tests.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+APK_BASE_DIR=${APK_BASE_DIR:-"$SCRIPT_DIR/.."}
+LOG_FAILURE_MESSAGE="FAILURES!!!"
+
+while [[ "$#" -gt 0 ]]; do
+ case $1 in
+ app)
+ TEST_TYPE="app"
+ USE_ORCHESTRATOR="false"
+ TEST_PACKAGE="net.mullvad.mullvadvpn.test"
+ TEST_APK="$APK_BASE_DIR/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"
+ ;;
+ e2e)
+ TEST_TYPE="e2e"
+ USE_ORCHESTRATOR="true"
+ TEST_PACKAGE="net.mullvad.mullvadvpn.test.$TEST_TYPE"
+ TEST_APK="$APK_BASE_DIR/test/$TEST_TYPE/build/outputs/apk/debug/$TEST_TYPE-debug.apk"
+ if [[ -z ${VALID_TEST_ACCOUNT_TOKEN-} ]]; then
+ echo "The variable VALID_TEST_ACCOUNT_TOKEN is not set."
+ exit 1
+ fi
+ if [[ -z ${INVALID_TEST_ACCOUNT_TOKEN-} ]]; then
+ echo "The variable INVALID_TEST_ACCOUNT_TOKEN is not set."
+ exit 1
+ fi
+ OPTIONAL_TEST_ARGUMENTS="\
+ -e valid_test_account_token $VALID_TEST_ACCOUNT_TOKEN \
+ -e invalid_test_account_token $INVALID_TEST_ACCOUNT_TOKEN"
+ ;;
+ mockapi)
+ TEST_TYPE="mockapi"
+ USE_ORCHESTRATOR="true"
+ TEST_PACKAGE="net.mullvad.mullvadvpn.test.$TEST_TYPE"
+ TEST_APK="$APK_BASE_DIR/test/$TEST_TYPE/build/outputs/apk/debug/$TEST_TYPE-debug.apk"
+ ;;
+ *)
+ echo "Unknown argument: $1"
+ exit 1
+ ;;
+ esac
+ shift
+done
+
+if [[ -z ${TEST_TYPE-} ]]; then
+ echo "Missing test type argument. Should be one of: app, e2e, mockapi"
+ exit 1
+fi
+
+if [[ "${USE_ORCHESTRATOR-}" == "true" ]]; then
+ if [[ -z ${ORCHESTRATOR_APK_PATH-} ]]; then
+ echo "The variable ORCHESTRATOR_APK_PATH is not set."
+ exit 1
+ fi
+ if [[ -z ${TEST_SERVICES_APK_PATH-} ]]; then
+ echo "The variable TEST_SERVICES_APK_PATH is not set."
+ exit 1
+ fi
+fi
+
+LOG_FILE_NAME="mullvad-$TEST_TYPE.txt"
+LOG_FILE_PATH="/tmp/$LOG_FILE_NAME"
+
+echo "Starting instrumented tests of type: $TEST_TYPE"
+echo ""
+
+echo "### Ensure that packages are not previously installed ###"
+adb uninstall net.mullvad.mullvadvpn || echo "App package not installed"
+adb uninstall "$TEST_PACKAGE" || echo "Test package not installed"
+adb uninstall androidx.test.services || echo "Test services package not installed"
+adb uninstall androidx.test.orchestrator || echo "Test orchestrator package not installed"
+echo ""
+
+echo "### Install packages ###"
+adb install -t "$APK_BASE_DIR/app/build/outputs/apk/debug/app-debug.apk"
+adb install "$TEST_APK"
+if [[ "$USE_ORCHESTRATOR" == "true" ]]; then
+ adb install "$ORCHESTRATOR_APK_PATH"
+ adb install "$TEST_SERVICES_APK_PATH"
+fi
+echo ""
+
+echo "### Run instrumented test command ###"
+if [[ "$USE_ORCHESTRATOR" == "true" ]]; then
+ INSTRUMENTATION_COMMAND="\
+ CLASSPATH=\$(pm path androidx.test.services) app_process / androidx.test.services.shellexecutor.ShellMain \
+ am instrument -r -w \
+ -e targetInstrumentation $TEST_PACKAGE/androidx.test.runner.AndroidJUnitRunner \
+ -e clearPackageData true \
+ ${OPTIONAL_TEST_ARGUMENTS:-""} \
+ androidx.test.orchestrator/androidx.test.orchestrator.AndroidTestOrchestrator"
+else
+ INSTRUMENTATION_COMMAND="\
+ am instrument -w \
+ $TEST_PACKAGE/androidx.test.runner.AndroidJUnitRunner"
+fi
+adb shell "$INSTRUMENTATION_COMMAND" | tee "$LOG_FILE_PATH"
+echo ""
+
+echo "### Ensure that packages are uninstalled ###"
+adb uninstall net.mullvad.mullvadvpn || echo "App package not installed"
+adb uninstall "$TEST_PACKAGE" || echo "Test package not installed"
+adb uninstall androidx.test.services || echo "Test services package not installed"
+adb uninstall androidx.test.orchestrator || echo "Test orchestrator package not installed"
+echo ""
+
+echo "### Checking logs for failures ###"
+if grep -q "$LOG_FAILURE_MESSAGE" "$LOG_FILE_PATH"; then
+ echo "One or more tests failed, see logs for more details."
+ exit 1
+else
+ echo "No failures!"
+fi
diff --git a/android/scripts/update-lockfile.sh b/android/scripts/update-lockfile.sh
index c30e3ca77c..9cbf81c92c 100755
--- a/android/scripts/update-lockfile.sh
+++ b/android/scripts/update-lockfile.sh
@@ -16,4 +16,4 @@ android_container_image_name=$(cat "../../building/android-container-image.txt")
podman run --rm -it \
-v ../..:/build:Z \
"$android_container_image_name" \
- android/gradlew -q -p android -M sha256 assembleAndroidTest
+ android/gradlew -q -p android -M sha256 assemble assembleAndroidTest
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index cc8e04d837..d847a97691 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -1,2 +1,2 @@
-include(":app")
-include(":e2e")
+include(":app", ":lib:endpoint")
+include(":test", ":test:common", ":test:e2e", ":test:mockapi")
diff --git a/android/test/build.gradle.kts b/android/test/build.gradle.kts
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/android/test/build.gradle.kts
diff --git a/android/test/common/build.gradle.kts b/android/test/common/build.gradle.kts
new file mode 100644
index 0000000000..78d0124aa9
--- /dev/null
+++ b/android/test/common/build.gradle.kts
@@ -0,0 +1,45 @@
+plugins {
+ id(Dependencies.Plugin.androidLibraryId)
+ id(Dependencies.Plugin.kotlinAndroidId)
+ id(Dependencies.Plugin.kotlinParcelizeId)
+}
+
+android {
+ namespace = "net.mullvad.mullvadvpn.test.common"
+ compileSdk = Versions.Android.compileSdkVersion
+
+ defaultConfig {
+ minSdk = Versions.Android.minSdkVersion
+ targetSdk = Versions.Android.targetSdkVersion
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = Versions.jvmTarget
+ }
+}
+
+androidComponents {
+ beforeVariants { variantBuilder ->
+ variantBuilder.apply {
+ enable = name != "release"
+ }
+ }
+}
+
+dependencies {
+ implementation(project(Dependencies.Mullvad.endpointLib))
+
+ implementation(Dependencies.AndroidX.testCore)
+ implementation(Dependencies.AndroidX.testRunner)
+ implementation(Dependencies.AndroidX.testRules)
+ implementation(Dependencies.AndroidX.testUiAutomator)
+ implementation(Dependencies.junit)
+ implementation(Dependencies.Kotlin.stdlib)
+
+ androidTestUtil(Dependencies.AndroidX.testOrchestrator)
+}
diff --git a/android/test/common/src/main/AndroidManifest.xml b/android/test/common/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..cc947c5679
--- /dev/null
+++ b/android/test/common/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest />
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/ResourceConstants.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt
index 07b2f03311..05b47ef99b 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/ResourceConstants.kt
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt
@@ -1,5 +1,6 @@
-package net.mullvad.mullvadvpn.e2e.constant
+package net.mullvad.mullvadvpn.test.common.constant
+const val MULLVAD_PACKAGE = "net.mullvad.mullvadvpn"
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/TimeoutConstants.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt
index ecc70c28b1..0da1d02aaf 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TimeoutConstants.kt
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt
@@ -1,7 +1,8 @@
-package net.mullvad.mullvadvpn.e2e.constant
+package net.mullvad.mullvadvpn.test.common.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
+const val WEB_TIMEOUT = 30000L
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/UiAutomatorExtensions.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt
index 5ecc16016d..cb953b920e 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/UiAutomatorExtensions.kt
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt
@@ -1,12 +1,13 @@
-package net.mullvad.mullvadvpn.e2e.extension
+package net.mullvad.mullvadvpn.test.common.extension
+import android.os.Build
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
+import net.mullvad.mullvadvpn.test.common.constant.DEFAULT_INTERACTION_TIMEOUT
fun UiDevice.findObjectByCaseInsensitiveText(text: String): UiObject2 {
return findObjectWithTimeout(By.text(Pattern.compile(text, Pattern.CASE_INSENSITIVE)))
@@ -35,6 +36,30 @@ fun UiDevice.findObjectWithTimeout(
}
}
+fun UiDevice.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove(
+ timeout: Long = DEFAULT_INTERACTION_TIMEOUT
+) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ // Skipping as notification permissions are not shown.
+ return
+ }
+
+ val selector = By.text("Allow")
+
+ wait(
+ Until.hasObject(selector),
+ timeout
+ )
+
+ try {
+ findObjectWithTimeout(selector).click()
+ } catch (e: IllegalArgumentException) {
+ throw IllegalArgumentException(
+ "Failed to allow notification permission within timeout ($timeout)"
+ )
+ }
+}
+
fun UiObject2.findObjectWithTimeout(
selector: BySelector,
timeout: Long = DEFAULT_INTERACTION_TIMEOUT
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/AppInteractor.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt
index 680850e718..1d6e9358a8 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/AppInteractor.kt
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt
@@ -1,4 +1,4 @@
-package net.mullvad.mullvadvpn.e2e.interactor
+package net.mullvad.mullvadvpn.test.common.interactor
import android.content.Context
import android.content.Intent
@@ -6,32 +6,37 @@ 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
+import net.mullvad.mullvadvpn.lib.endpoint.CustomApiEndpointConfiguration
+import net.mullvad.mullvadvpn.lib.endpoint.putApiEndpointConfigurationExtra
+import net.mullvad.mullvadvpn.test.common.constant.APP_LAUNCH_TIMEOUT
+import net.mullvad.mullvadvpn.test.common.constant.CONNECTION_TIMEOUT
+import net.mullvad.mullvadvpn.test.common.constant.LOGIN_TIMEOUT
+import net.mullvad.mullvadvpn.test.common.constant.MULLVAD_PACKAGE
+import net.mullvad.mullvadvpn.test.common.constant.SETTINGS_COG_ID
+import net.mullvad.mullvadvpn.test.common.constant.TUNNEL_INFO_ID
+import net.mullvad.mullvadvpn.test.common.constant.TUNNEL_OUT_ADDRESS_ID
+import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove
+import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout
class AppInteractor(
private val device: UiDevice,
- private val targetContext: Context,
- private val validTestAccountToken: String,
- private val invalidTestAccountToken: String
+ private val targetContext: Context
) {
- fun launch() {
+ fun launch(customApiEndpointConfiguration: CustomApiEndpointConfiguration? = null) {
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)
+ if (customApiEndpointConfiguration != null) {
+ putApiEndpointConfigurationExtra(customApiEndpointConfiguration)
+ }
}
targetContext.startActivity(intent)
device.wait(
@@ -40,13 +45,14 @@ class AppInteractor(
)
}
- fun launchAndEnsureLoggedIn(accountToken: String = validTestAccountToken) {
+ fun launchAndEnsureLoggedIn(accountToken: String) {
launch()
+ device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove()
attemptLogin(accountToken)
ensureLoggedIn()
}
- fun attemptLogin(accountToken: String = validTestAccountToken) {
+ fun attemptLogin(accountToken: String) {
device.findObjectWithTimeout(By.text("Login"))
val loginObject = device.findObjectWithTimeout(By.clazz("android.widget.EditText"))
.apply { text = accountToken }
diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt
new file mode 100644
index 0000000000..138d09cc28
--- /dev/null
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt
@@ -0,0 +1,114 @@
+package net.mullvad.mullvadvpn.test.common.rule
+
+import android.content.ContentResolver
+import android.content.ContentValues
+import android.graphics.Bitmap
+import android.os.Build
+import android.os.Environment
+import android.os.Environment.DIRECTORY_PICTURES
+import android.provider.MediaStore
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.nio.file.Paths
+import java.time.OffsetDateTime
+import java.time.temporal.ChronoUnit
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+class CaptureScreenshotOnFailedTestRule(private val testTag: String) : TestWatcher() {
+
+ override fun failed(e: Throwable?, description: Description) {
+ Log.d(testTag, "Capturing screenshot of failed test: " + description.methodName)
+ val timestamp = OffsetDateTime.now().truncatedTo(ChronoUnit.MILLIS)
+ val screenshotName = "$timestamp-${description.methodName}.jpeg"
+ captureScreenshot(testTag, screenshotName)
+ }
+
+ private fun captureScreenshot(baseDir: String, filename: String) {
+ val contentResolver = getInstrumentation().targetContext.applicationContext.contentResolver
+ val contentValues = createBaseScreenshotContentValues()
+
+ getInstrumentation().uiAutomation.takeScreenshot().apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ writeToMediaStore(
+ contentValues = contentValues,
+ contentResolver = contentResolver,
+ baseDir = baseDir,
+ filename = filename
+ )
+ } else {
+ writeToExternalStorage(
+ contentValues = contentValues,
+ contentResolver = contentResolver,
+ baseDir = baseDir,
+ filename = filename
+ )
+ }
+ }
+ }
+
+ @RequiresApi(29)
+ private fun Bitmap.writeToMediaStore(
+ contentValues: ContentValues,
+ contentResolver: ContentResolver,
+ baseDir: String,
+ filename: String
+ ) {
+ contentValues.apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
+ put(
+ MediaStore.Images.Media.RELATIVE_PATH,
+ "$DIRECTORY_PICTURES/$baseDir"
+ )
+ }
+
+ val uri =
+ contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
+
+ if (uri != null) {
+ contentResolver.openOutputStream(uri).use {
+ try {
+ this.compress(Bitmap.CompressFormat.JPEG, 50, it)
+ } catch (e: IOException) {
+ Log.e(testTag, "Unable to store screenshot: ${e.message}")
+ }
+ }
+ contentResolver.update(uri, contentValues, null, null)
+ } else {
+ Log.e(testTag, "Unable to store screenshot")
+ }
+ }
+
+ private fun Bitmap.writeToExternalStorage(
+ contentValues: ContentValues,
+ contentResolver: ContentResolver,
+ baseDir: String,
+ filename: String
+ ) {
+ val screenshotBaseDirectory = Paths.get(
+ Environment.getExternalStoragePublicDirectory(DIRECTORY_PICTURES).path,
+ baseDir,
+ ).toFile().apply {
+ if (exists().not()) {
+ mkdirs()
+ }
+ }
+ FileOutputStream(File(screenshotBaseDirectory, filename)).use { outputStream ->
+ try {
+ this.compress(Bitmap.CompressFormat.JPEG, 50, outputStream)
+ } catch (e: IOException) {
+ Log.e(testTag, "Unable to store screenshot: ${e.message}")
+ }
+ }
+ contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
+ }
+
+ private fun createBaseScreenshotContentValues() = ContentValues().apply {
+ put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
+ put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
+ }
+}
diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt
new file mode 100644
index 0000000000..eebdb291ab
--- /dev/null
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt
@@ -0,0 +1,37 @@
+package net.mullvad.mullvadvpn.test.common.rule
+
+import android.content.Intent
+import android.provider.Settings
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import net.mullvad.mullvadvpn.test.common.extension.findObjectByCaseInsensitiveText
+import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+class ForgetAllVpnAppsInSettingsTestRule : TestWatcher() {
+ override fun starting(description: Description) {
+ val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+ val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
+ targetContext.startActivity(
+ Intent(Settings.ACTION_VPN_SETTINGS).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ )
+ val vpnSettingsButtons =
+ device.findObjects(By.res(SETTINGS_PACKAGE, VPN_SETTINGS_BUTTON_ID))
+ vpnSettingsButtons?.forEach { button ->
+ button.click()
+ device.findObjectWithTimeout(By.text(FORGET_VPN_VPN_BUTTON_TEXT)).click()
+ device.findObjectByCaseInsensitiveText(FORGET_VPN_VPN_CONFIRM_BUTTON_TEXT).click()
+ }
+ }
+
+ companion object {
+ private const val FORGET_VPN_VPN_BUTTON_TEXT = "Forget VPN"
+ private const val FORGET_VPN_VPN_CONFIRM_BUTTON_TEXT = "Forget"
+ private const val SETTINGS_PACKAGE = "com.android.settings"
+ private const val VPN_SETTINGS_BUTTON_ID = "settings_button"
+ }
+}
diff --git a/android/e2e/README.md b/android/test/e2e/README.md
index 2eb3663300..7c1271ad97 100644
--- a/android/e2e/README.md
+++ b/android/test/e2e/README.md
@@ -6,7 +6,7 @@ The tests in this module are end-to-end tests that rely on the publicly accessib
### Locally
Set tokens in the below command and then execute the command in the `android` directory to run the tests on a local device:
```
-./gradlew :e2e:connectedDebugAndroidTest \
+./gradlew :test:e2e:connectedDebugAndroidTest \
-Pvalid_test_account_token=XXXX \
-Pinvalid_test_account_token=XXXX
```
@@ -24,7 +24,7 @@ adb shell 'CLASSPATH=$(pm path androidx.test.services) app_process / \
-e clearPackageData true \
-e valid_test_account_token XXXX \
-e invalid_test_account_token XXXX \
- -e targetInstrumentation net.mullvad.mullvadvpn.e2e/androidx.test.runner.AndroidJUnitRunner \
+ -e targetInstrumentation net.mullvad.mullvadvpn.test.e2e/androidx.test.runner.AndroidJUnitRunner \
androidx.test.orchestrator/.AndroidTestOrchestrator'
```
@@ -38,7 +38,7 @@ Firebase Test Lab can be used to run the tests on vast collection of physical an
gcloud firebase test android run \
--type instrumentation \
--app ./android/app/build/outputs/apk/debug/app-debug.apk \
- --test ./android/e2e/build/outputs/apk/debug/e2e-debug.apk \
+ --test ./android/test/e2e/build/outputs/apk/debug/e2e-debug.apk \
--device model=redfin,version=30,locale=en,orientation=portrait \
--use-orchestrator \
--environment-variables clearPackageData=true,valid_test_account_token=XXXX,invalid_test_account_token=XXXX
@@ -49,7 +49,7 @@ If using gcloud via the docker image, the following can be executed in the `andr
docker run --rm --volumes-from gcloud-config -v ${PWD}:/android gcr.io/google.com/cloudsdktool/google-cloud-cli gcloud firebase test android run \
--type instrumentation \
--app ./android/app/build/outputs/apk/debug/app-debug.apk \
- --test ./android/e2e/build/outputs/apk/debug/e2e-debug.apk \
+ --test ./android/test/e2e/build/outputs/apk/debug/e2e-debug.apk \
--device model=redfin,version=30,locale=en,orientation=portrait \
--use-orchestrator \
--environment-variables clearPackageData=true,valid_test_account_token=XXXX,invalid_test_account_token=XXXX
diff --git a/android/e2e/build.gradle.kts b/android/test/e2e/build.gradle.kts
index 1ea4f94058..6310ca5b12 100644
--- a/android/e2e/build.gradle.kts
+++ b/android/test/e2e/build.gradle.kts
@@ -7,13 +7,13 @@ plugins {
}
android {
- namespace = "net.mullvad.mullvadvpn.e2e"
+ namespace = "net.mullvad.mullvadvpn.test.e2e"
compileSdk = Versions.Android.compileSdkVersion
defaultConfig {
minSdk = Versions.Android.minSdkVersion
targetSdk = Versions.Android.targetSdkVersion
- testApplicationId = "net.mullvad.mullvadvpn.e2e"
+ testApplicationId = "net.mullvad.mullvadvpn.test.e2e"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
targetProjectPath = ":app"
@@ -43,6 +43,7 @@ android {
testInstrumentationRunnerArguments += mutableMapOf<String, String>().apply {
put("clearPackageData", "true")
+ put("useTestStorageService", "true")
addOptionalPropertyAsArgument("valid_test_account_token")
addOptionalPropertyAsArgument("invalid_test_account_token")
}
@@ -62,57 +63,27 @@ android {
}
}
-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"
+ suppressionFile = "$projectDir/../test-suppression.xml"
}
dependencies {
+ implementation(project(Projects.testCommon))
+ implementation(project(Dependencies.Mullvad.endpointLib))
+
implementation(Dependencies.AndroidX.testCore)
// Fixes: https://github.com/android/android-test/issues/1589
implementation(Dependencies.AndroidX.testMonitor)
- 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)
+
+ androidTestUtil(Dependencies.AndroidX.testOrchestrator)
}
diff --git a/android/e2e/e2e.properties b/android/test/e2e/e2e.properties
index b02f6a9381..f9420786c8 100644
--- a/android/e2e/e2e.properties
+++ b/android/test/e2e/e2e.properties
@@ -1,2 +1,2 @@
API_BASE_URL=https://api.mullvad.net
-API_VERSION=v1-beta1
+API_VERSION=v1
diff --git a/android/test/e2e/src/main/AndroidManifest.xml b/android/test/e2e/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9ba07f4905
--- /dev/null
+++ b/android/test/e2e/src/main/AndroidManifest.xml
@@ -0,0 +1,10 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <instrumentation
+ android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="net.mullvad.mullvadvpn" />
+</manifest>
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt
new file mode 100644
index 0000000000..8f72fef0be
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt
@@ -0,0 +1,38 @@
+package net.mullvad.mullvadvpn.test.e2e
+
+import androidx.test.uiautomator.By
+import junit.framework.Assert.assertEquals
+import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout
+import net.mullvad.mullvadvpn.test.common.rule.ForgetAllVpnAppsInSettingsTestRule
+import net.mullvad.mullvadvpn.test.e2e.misc.CleanupAccountTestRule
+import net.mullvad.mullvadvpn.test.e2e.misc.ConnCheckState
+import net.mullvad.mullvadvpn.test.e2e.misc.SimpleMullvadHttpClient
+import org.junit.Rule
+import org.junit.Test
+
+class ConnectionTest : EndToEndTest() {
+
+ @Rule
+ @JvmField
+ val cleanupAccountTestRule = CleanupAccountTestRule()
+
+ @Rule
+ @JvmField
+ val forgetAllVpnAppsInSettingsTestRule = ForgetAllVpnAppsInSettingsTestRule()
+
+ @Test
+ fun testConnectAndVerifyWithConnectionCheck() {
+ // Given
+ app.launchAndEnsureLoggedIn(validTestAccountToken)
+
+ // When
+ device.findObjectWithTimeout(By.text("Secure my connection")).click()
+ device.findObjectWithTimeout(By.text("OK")).click()
+ device.findObjectWithTimeout(By.text("SECURE CONNECTION"))
+ val expected = ConnCheckState(true, app.extractIpAddress())
+
+ // Then
+ val result = SimpleMullvadHttpClient(targetContext).runConnectionCheck()
+ assertEquals(expected, result)
+ }
+}
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/EndToEndTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt
index d3f3c564b7..f8f5bb8f6c 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/EndToEndTest.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt
@@ -1,15 +1,17 @@
-package net.mullvad.mullvadvpn.e2e
+package net.mullvad.mullvadvpn.test.e2e
+import android.Manifest
import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.GrantPermissionRule
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 net.mullvad.mullvadvpn.test.common.interactor.AppInteractor
+import net.mullvad.mullvadvpn.test.common.rule.CaptureScreenshotOnFailedTestRule
+import net.mullvad.mullvadvpn.test.e2e.constant.INVALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY
+import net.mullvad.mullvadvpn.test.e2e.constant.LOG_TAG
+import net.mullvad.mullvadvpn.test.e2e.constant.VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY
+import net.mullvad.mullvadvpn.test.e2e.extension.getRequiredArgument
import org.junit.Before
import org.junit.Rule
import org.junit.runner.RunWith
@@ -19,12 +21,18 @@ abstract class EndToEndTest {
@Rule
@JvmField
- val rule = CaptureScreenshotOnFailedTestRule()
+ val rule = CaptureScreenshotOnFailedTestRule(LOG_TAG)
+
+ @Rule
+ @JvmField
+ val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ )
lateinit var device: UiDevice
lateinit var targetContext: Context
lateinit var app: AppInteractor
- lateinit var web: WebViewInteractor
lateinit var validTestAccountToken: String
lateinit var invalidTestAccountToken: String
@@ -40,14 +48,7 @@ abstract class EndToEndTest {
app = AppInteractor(
device,
- targetContext,
- validTestAccountToken,
- invalidTestAccountToken
- )
-
- web = WebViewInteractor(
- targetContext,
- device
+ targetContext
)
}
}
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LaunchAppTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LaunchAppTest.kt
index c873d3452c..64f534990c 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LaunchAppTest.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LaunchAppTest.kt
@@ -1,4 +1,4 @@
-package net.mullvad.mullvadvpn.e2e
+package net.mullvad.mullvadvpn.test.e2e
import androidx.test.runner.AndroidJUnit4
import org.junit.Test
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LoginTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt
index 4919fb823f..9e106d45a2 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/LoginTest.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LoginTest.kt
@@ -1,11 +1,12 @@
-package net.mullvad.mullvadvpn.e2e
+package net.mullvad.mullvadvpn.test.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 net.mullvad.mullvadvpn.test.common.constant.LOGIN_FAILURE_TIMEOUT
+import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove
+import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout
+import net.mullvad.mullvadvpn.test.e2e.misc.CleanupAccountTestRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -24,6 +25,7 @@ class LoginTest : EndToEndTest() {
// When
app.launch()
+ device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove()
app.attemptLogin(invalidDummyAccountToken)
// Then
@@ -45,7 +47,7 @@ class LoginTest : EndToEndTest() {
@Test
fun testLogout() {
// Given
- app.launchAndEnsureLoggedIn()
+ app.launchAndEnsureLoggedIn(validTestAccountToken)
// When
app.clickSettingsCog()
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/WebLinkTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/WebLinkTest.kt
new file mode 100644
index 0000000000..9893074a88
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/WebLinkTest.kt
@@ -0,0 +1,27 @@
+package net.mullvad.mullvadvpn.test.e2e
+
+import androidx.test.uiautomator.By
+import net.mullvad.mullvadvpn.test.common.constant.WEB_TIMEOUT
+import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove
+import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout
+import org.junit.Test
+
+class WebLinkTest : EndToEndTest() {
+ @Test
+ fun testOpenFaqFromApp() {
+ // Given
+ app.launch()
+
+ // When
+ device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove()
+ device.findObjectWithTimeout(By.text("Login"))
+ app.clickSettingsCog()
+ app.clickListItemByText("FAQs & Guides")
+
+ // Then
+ device.findObjectWithTimeout(
+ selector = By.text("Mullvad help center"),
+ timeout = WEB_TIMEOUT
+ )
+ }
+}
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/ConnCheckConstants.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/ConnCheckConstants.kt
new file mode 100644
index 0000000000..5357ce0e75
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/ConnCheckConstants.kt
@@ -0,0 +1,3 @@
+package net.mullvad.mullvadvpn.test.e2e.constant
+
+const val CONN_CHECK_URL = "https://am.i.mullvad.net/json"
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/Constants.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt
index 3b6a04b51e..98fd52a333 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/Constants.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/Constants.kt
@@ -1,6 +1,5 @@
-package net.mullvad.mullvadvpn.e2e.constant
+package net.mullvad.mullvadvpn.test.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/TextConstants.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/TextConstants.kt
index ff8e0088d4..cfc4080ea4 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/constant/TextConstants.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/constant/TextConstants.kt
@@ -1,4 +1,4 @@
-package net.mullvad.mullvadvpn.e2e.constant
+package net.mullvad.mullvadvpn.test.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/extension/BundleExtensions.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/extension/BundleExtensions.kt
index 275bd0b9c7..c96c28bb09 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/extension/BundleExtensions.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/extension/BundleExtensions.kt
@@ -1,4 +1,4 @@
-package net.mullvad.mullvadvpn.e2e.extension
+package net.mullvad.mullvadvpn.test.e2e.extension
import android.os.Bundle
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/MullvadAccountInteractor.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/MullvadAccountInteractor.kt
index 08c5a698c3..8f3f55166f 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/MullvadAccountInteractor.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/MullvadAccountInteractor.kt
@@ -1,6 +1,6 @@
-package net.mullvad.mullvadvpn.e2e.interactor
+package net.mullvad.mullvadvpn.test.e2e.interactor
-import net.mullvad.mullvadvpn.e2e.misc.SimpleMullvadHttpClient
+import net.mullvad.mullvadvpn.test.e2e.misc.SimpleMullvadHttpClient
class MullvadAccountInteractor(
private val httpClient: SimpleMullvadHttpClient,
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/SystemSettingsInteractor.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/SystemSettingsInteractor.kt
index 29cef35b0a..9e5f0aa665 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/interactor/SystemSettingsInteractor.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/interactor/SystemSettingsInteractor.kt
@@ -1,11 +1,11 @@
-package net.mullvad.mullvadvpn.e2e.interactor
+package net.mullvad.mullvadvpn.test.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
+import net.mullvad.mullvadvpn.test.common.extension.findObjectByCaseInsensitiveText
class SystemSettingsInteractor(
private val uiDevice: UiDevice,
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CleanupAccountTestRule.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/CleanupAccountTestRule.kt
index 17f7f86f6c..2b69436f6d 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/CleanupAccountTestRule.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/CleanupAccountTestRule.kt
@@ -1,17 +1,17 @@
-package net.mullvad.mullvadvpn.e2e.misc
+package net.mullvad.mullvadvpn.test.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 net.mullvad.mullvadvpn.test.e2e.constant.LOG_TAG
+import net.mullvad.mullvadvpn.test.e2e.constant.VALID_TEST_ACCOUNT_TOKEN_ARGUMENT_KEY
+import net.mullvad.mullvadvpn.test.e2e.extension.getRequiredArgument
+import net.mullvad.mullvadvpn.test.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}")
+ 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)
diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/ConnCheckState.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/ConnCheckState.kt
new file mode 100644
index 0000000000..744e80124e
--- /dev/null
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/ConnCheckState.kt
@@ -0,0 +1,6 @@
+package net.mullvad.mullvadvpn.test.e2e.misc
+
+data class ConnCheckState(
+ val isConnected: Boolean,
+ val ipAddress: String
+)
diff --git a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/SimpleMullvadHttpClient.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/SimpleMullvadHttpClient.kt
index ebd90f1bac..bc56737b04 100644
--- a/android/e2e/src/main/java/net/mullvad/mullvadvpn/e2e/misc/SimpleMullvadHttpClient.kt
+++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/SimpleMullvadHttpClient.kt
@@ -1,4 +1,4 @@
-package net.mullvad.mullvadvpn.e2e.misc
+package net.mullvad.mullvadvpn.test.e2e.misc
import android.content.Context
import android.util.Log
@@ -9,8 +9,9 @@ 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 net.mullvad.mullvadvpn.test.e2e.BuildConfig
+import net.mullvad.mullvadvpn.test.e2e.constant.CONN_CHECK_URL
+import net.mullvad.mullvadvpn.test.e2e.constant.LOG_TAG
import org.json.JSONArray
import org.json.JSONObject
@@ -72,6 +73,22 @@ class SimpleMullvadHttpClient(context: Context) {
)
}
+ fun runConnectionCheck(): ConnCheckState? {
+ return sendSimpleSynchronousRequestString(
+ Request.Method.GET,
+ CONN_CHECK_URL
+ )
+ ?.let { respose ->
+ JSONObject(respose)
+ }
+ ?.let { json ->
+ ConnCheckState(
+ isConnected = json.getBoolean("mullvad_exit_ip"),
+ ipAddress = json.getString("ip")
+ )
+ }
+ }
+
private fun sendSimpleSynchronousRequest(
method: Int,
url: String,
diff --git a/android/test/mockapi/build.gradle.kts b/android/test/mockapi/build.gradle.kts
new file mode 100644
index 0000000000..7fd7634e63
--- /dev/null
+++ b/android/test/mockapi/build.gradle.kts
@@ -0,0 +1,62 @@
+plugins {
+ id(Dependencies.Plugin.androidTestId)
+ id(Dependencies.Plugin.kotlinAndroidId)
+}
+
+android {
+ namespace = "net.mullvad.mullvadvpn.test.mockapi"
+ compileSdk = Versions.Android.compileSdkVersion
+
+ defaultConfig {
+ minSdk = Versions.Android.minSdkVersion
+ targetSdk = Versions.Android.targetSdkVersion
+ testApplicationId = "net.mullvad.mullvadvpn.test.mockapi"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ targetProjectPath = ":app"
+
+ testInstrumentationRunnerArguments.putAll(
+ mapOf(
+ "clearPackageData" to "true",
+ "useTestStorageService" to "true"
+ )
+ )
+ }
+
+ testOptions {
+ execution = "ANDROIDX_TEST_ORCHESTRATOR"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = Versions.jvmTarget
+ }
+}
+
+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/../test-suppression.xml"
+}
+
+dependencies {
+ implementation(project(Projects.testCommon))
+ implementation(project(Dependencies.Mullvad.endpointLib))
+
+ implementation(Dependencies.AndroidX.testCore)
+ // Fixes: https://github.com/android/android-test/issues/1589
+ implementation(Dependencies.AndroidX.testMonitor)
+ implementation(Dependencies.AndroidX.testRunner)
+ implementation(Dependencies.AndroidX.testRules)
+ implementation(Dependencies.AndroidX.testUiAutomator)
+ implementation(Dependencies.Kotlin.stdlib)
+ implementation(Dependencies.mockkWebserver)
+
+ androidTestUtil(Dependencies.AndroidX.testOrchestrator)
+}
diff --git a/android/e2e/src/main/AndroidManifest.xml b/android/test/mockapi/src/main/AndroidManifest.xml
index 931f79d291..931f79d291 100644
--- a/android/e2e/src/main/AndroidManifest.xml
+++ b/android/test/mockapi/src/main/AndroidManifest.xml
diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/Extensions.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/Extensions.kt
new file mode 100644
index 0000000000..60bd0e293a
--- /dev/null
+++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/Extensions.kt
@@ -0,0 +1,26 @@
+package net.mullvad.mullvadvpn.test.mockapi
+
+import okhttp3.mockwebserver.MockResponse
+import okio.Buffer
+import org.json.JSONException
+import org.json.JSONObject
+
+fun MockResponse.addJsonHeader(): MockResponse {
+ return addHeader("Content-Type", "application/json")
+}
+
+fun Buffer.getAccountToken(): String? {
+ return try {
+ JSONObject(readUtf8()).getString("account_number")
+ } catch (ex: JSONException) {
+ null
+ }
+}
+
+fun Buffer.getPubKey(): String? {
+ return try {
+ JSONObject(readUtf8()).getString("pubkey")
+ } catch (ex: JSONException) {
+ null
+ }
+}
diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/JsonUtils.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/JsonUtils.kt
new file mode 100644
index 0000000000..d6ee2bc6cb
--- /dev/null
+++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/JsonUtils.kt
@@ -0,0 +1,41 @@
+package net.mullvad.mullvadvpn.test.mockapi
+
+import java.time.OffsetDateTime
+import org.json.JSONArray
+import org.json.JSONObject
+
+fun accountInfoJson(
+ id: String,
+ expiry: OffsetDateTime
+) = JSONObject().apply {
+ put("id", id)
+ put("expiry", expiry.toString())
+ put("max_ports", 5)
+ put("can_add_ports", true)
+ put("max_devices", 5)
+ put("can_add_devices", true)
+}
+
+fun deviceJson(
+ id: String,
+ name: String,
+ publicKey: String,
+ creationDate: OffsetDateTime
+) = JSONObject().apply {
+ put("id", id)
+ put("name", name)
+ put("pubkey", publicKey)
+ put("hijack_dns", true)
+ put("created", creationDate.toString())
+ put("ipv4_address", "127.0.0.1/32")
+ put("ipv6_address", "fc00::1/128")
+ put("ports", JSONArray())
+}
+
+fun accessTokenJsonResponse(
+ accessToken: String,
+ expiry: OffsetDateTime
+) = JSONObject().apply {
+ put("access_token", accessToken)
+ put("expiry", expiry.toString())
+}
diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/LoginMockApiTest.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/LoginMockApiTest.kt
new file mode 100644
index 0000000000..2a72b41373
--- /dev/null
+++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/LoginMockApiTest.kt
@@ -0,0 +1,70 @@
+package net.mullvad.mullvadvpn.test.mockapi
+
+import androidx.test.runner.AndroidJUnit4
+import androidx.test.uiautomator.By
+import java.time.OffsetDateTime
+import java.time.temporal.ChronoUnit
+import net.mullvad.mullvadvpn.test.common.extension.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove
+import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class LoginMockApiTest : MockApiTest() {
+ @Test
+ fun testLoginWithInvalidCredentials() {
+ // Arrange
+ val validAccountToken = "1234123412341234"
+ apiDispatcher.apply {
+ expectedAccountToken = null
+ accountExpiry =
+ OffsetDateTime.now().plusDays(1).truncatedTo(ChronoUnit.SECONDS)
+ }
+ app.launch(endpoint)
+
+ // Act
+ device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove()
+ app.attemptLogin(validAccountToken)
+
+ // Assert
+ device.findObjectWithTimeout(By.text("Login failed"))
+ }
+
+ @Test
+ fun testLoginWithValidCredentialsToUnexpiredAccount() {
+ // Arrange
+ val validAccountToken = "1234123412341234"
+ apiDispatcher.apply {
+ expectedAccountToken = validAccountToken
+ accountExpiry =
+ OffsetDateTime.now().plusDays(1).truncatedTo(ChronoUnit.SECONDS)
+ }
+
+ // Act
+ app.launch(endpoint)
+ device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove()
+ app.attemptLogin(validAccountToken)
+
+ // Assert
+ device.findObjectWithTimeout(By.text("UNSECURED CONNECTION"))
+ }
+
+ @Test
+ fun testLoginWithValidCredentialsToExpiredAccount() {
+ // Arrange
+ val validAccountToken = "1234123412341234"
+ apiDispatcher.apply {
+ expectedAccountToken = validAccountToken
+ accountExpiry =
+ OffsetDateTime.now().minusDays(1).truncatedTo(ChronoUnit.SECONDS)
+ }
+
+ // Act
+ app.launch(endpoint)
+ device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove()
+ app.attemptLogin(validAccountToken)
+
+ // Assert
+ device.findObjectWithTimeout(By.text("Out of time"))
+ }
+}
diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiDispatcher.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiDispatcher.kt
new file mode 100644
index 0000000000..b41ad34fa6
--- /dev/null
+++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiDispatcher.kt
@@ -0,0 +1,130 @@
+package net.mullvad.mullvadvpn.test.mockapi
+
+import android.util.Log
+import java.time.OffsetDateTime
+import java.time.temporal.ChronoUnit
+import net.mullvad.mullvadvpn.test.mockapi.constant.ACCOUNT_URL_PATH
+import net.mullvad.mullvadvpn.test.mockapi.constant.AUTH_TOKEN_URL_PATH
+import net.mullvad.mullvadvpn.test.mockapi.constant.DEVICES_URL_PATH
+import net.mullvad.mullvadvpn.test.mockapi.constant.DUMMY_ACCESS_TOKEN
+import net.mullvad.mullvadvpn.test.mockapi.constant.DUMMY_DEVICE_NAME
+import net.mullvad.mullvadvpn.test.mockapi.constant.DUMMY_ID
+import okhttp3.mockwebserver.Dispatcher
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.RecordedRequest
+import okio.Buffer
+import org.json.JSONArray
+
+class MockApiDispatcher : Dispatcher() {
+
+ var expectedAccountToken: String? = null
+ var accountExpiry: OffsetDateTime? = null
+
+ private var cachedPubKeyFromAppUnderTest: String? = null
+
+ override fun dispatch(request: RecordedRequest): MockResponse {
+ Log.d("mullvad", "Request: $request")
+ return when (request.path) {
+ AUTH_TOKEN_URL_PATH -> handleLoginRequest(request.body)
+ DEVICES_URL_PATH -> {
+ when (request.method) {
+ "get", "GET" -> handleDeviceListRequest()
+ "post", "POST" -> handleDeviceCreationRequest(request.body)
+ else -> MockResponse().setResponseCode(404)
+ }
+ }
+ "$DEVICES_URL_PATH/$DUMMY_ID" -> handleDeviceInfoRequest()
+ ACCOUNT_URL_PATH -> handleAccountInfoRequest()
+ else -> MockResponse().setResponseCode(404)
+ }
+ }
+
+ private fun handleLoginRequest(requestBody: Buffer): MockResponse {
+ val accountToken = requestBody.getAccountToken()
+
+ return if (accountToken != null && accountToken == expectedAccountToken) {
+ MockResponse()
+ .setResponseCode(200)
+ .addJsonHeader()
+ .setBody(
+ accessTokenJsonResponse(
+ accessToken = DUMMY_ACCESS_TOKEN,
+ expiry = OffsetDateTime.now().plusDays(1).truncatedTo(ChronoUnit.SECONDS)
+ ).toString()
+ )
+ } else {
+ MockResponse().setResponseCode(400)
+ }
+ }
+
+ private fun handleAccountInfoRequest(): MockResponse {
+ return accountExpiry?.let { expiry ->
+ MockResponse()
+ .setResponseCode(200)
+ .addJsonHeader()
+ .setBody(
+ accountInfoJson(
+ id = DUMMY_ID,
+ expiry = expiry
+ ).toString()
+ )
+ } ?: MockResponse().setResponseCode(400)
+ }
+
+ private fun handleDeviceInfoRequest(): MockResponse {
+ return cachedPubKeyFromAppUnderTest?.let { cachedKey ->
+ MockResponse()
+ .setResponseCode(200)
+ .addJsonHeader()
+ .setBody(
+ deviceJson(
+ id = DUMMY_ID,
+ name = DUMMY_DEVICE_NAME,
+ publicKey = cachedKey,
+ creationDate = OffsetDateTime.now().minusDays(1)
+ .truncatedTo(ChronoUnit.SECONDS)
+ ).toString()
+ )
+ } ?: MockResponse().setResponseCode(400)
+ }
+
+ private fun handleDeviceCreationRequest(body: Buffer): MockResponse {
+ return body.getPubKey()
+ .also { newKey ->
+ cachedPubKeyFromAppUnderTest = newKey
+ }
+ ?.let { newKey ->
+ MockResponse()
+ .setResponseCode(201)
+ .addJsonHeader()
+ .setBody(
+ deviceJson(
+ id = DUMMY_ID,
+ name = DUMMY_DEVICE_NAME,
+ publicKey = newKey,
+ creationDate = OffsetDateTime.now().minusDays(1)
+ .truncatedTo(ChronoUnit.SECONDS)
+ ).toString()
+ )
+ } ?: MockResponse().setResponseCode(400)
+ }
+
+ private fun handleDeviceListRequest(): MockResponse {
+ return cachedPubKeyFromAppUnderTest?.let { cachedKey ->
+ MockResponse()
+ .setResponseCode(200)
+ .addJsonHeader()
+ .setBody(
+ JSONArray().put(
+ deviceJson(
+ id = DUMMY_ID,
+ name = DUMMY_DEVICE_NAME,
+ publicKey = cachedKey,
+ creationDate = OffsetDateTime.now().minusDays(1)
+ .truncatedTo(ChronoUnit.SECONDS)
+ )
+ ).toString()
+ )
+ } ?: MockResponse().setResponseCode(400)
+ }
+}
diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiTest.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiTest.kt
new file mode 100644
index 0000000000..ca4338bb2e
--- /dev/null
+++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/MockApiTest.kt
@@ -0,0 +1,80 @@
+package net.mullvad.mullvadvpn.test.mockapi
+
+import android.Manifest.permission.READ_EXTERNAL_STORAGE
+import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
+import android.content.Context
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.GrantPermissionRule
+import androidx.test.runner.AndroidJUnit4
+import androidx.test.uiautomator.UiDevice
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpoint
+import net.mullvad.mullvadvpn.lib.endpoint.CustomApiEndpointConfiguration
+import net.mullvad.mullvadvpn.test.common.interactor.AppInteractor
+import net.mullvad.mullvadvpn.test.common.rule.CaptureScreenshotOnFailedTestRule
+import net.mullvad.mullvadvpn.test.mockapi.constant.LOG_TAG
+import net.mullvad.mullvadvpn.test.mockapi.constant.MOCK_SERVER_LOCALHOST_ADDRESS
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+abstract class MockApiTest {
+
+ @Rule
+ @JvmField
+ val rule = CaptureScreenshotOnFailedTestRule(LOG_TAG)
+
+ @Rule
+ @JvmField
+ val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ WRITE_EXTERNAL_STORAGE,
+ READ_EXTERNAL_STORAGE
+ )
+
+ protected val apiDispatcher = MockApiDispatcher()
+ private val mockWebServer = MockWebServer().apply {
+ dispatcher = apiDispatcher
+ }
+
+ lateinit var device: UiDevice
+ lateinit var targetContext: Context
+ lateinit var app: AppInteractor
+ lateinit var endpoint: CustomApiEndpointConfiguration
+
+ @Before
+ open fun setup() {
+ device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+ targetContext = InstrumentationRegistry.getInstrumentation().targetContext
+
+ app = AppInteractor(
+ device,
+ targetContext
+ )
+
+ mockWebServer.start()
+ endpoint = createEndpoint(mockWebServer.port)
+ }
+
+ @After
+ open fun teardown() {
+ mockWebServer.shutdown()
+ }
+
+ private fun createEndpoint(port: Int): CustomApiEndpointConfiguration {
+ val mockApiSocket = InetSocketAddress(
+ InetAddress.getByName(MOCK_SERVER_LOCALHOST_ADDRESS),
+ port
+ )
+ val api = ApiEndpoint(
+ address = mockApiSocket,
+ disableAddressCache = true,
+ disableTls = true,
+ forceDirectConnection = true
+ )
+ return CustomApiEndpointConfiguration(api)
+ }
+}
diff --git a/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/constant/Constants.kt b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/constant/Constants.kt
new file mode 100644
index 0000000000..713a712bf4
--- /dev/null
+++ b/android/test/mockapi/src/main/kotlin/net/mullvad/mullvadvpn/test/mockapi/constant/Constants.kt
@@ -0,0 +1,14 @@
+package net.mullvad.mullvadvpn.test.mockapi.constant
+
+const val LOG_TAG = "mullvad-mockapi"
+
+const val MOCK_SERVER_LOCALHOST_ADDRESS = "127.0.0.1"
+
+const val AUTH_TOKEN_URL_PATH = "/auth/v1/token"
+const val DEVICES_URL_PATH = "/accounts/v1/devices"
+const val ACCOUNT_URL_PATH = "/accounts/v1/accounts/me"
+
+const val DUMMY_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
+const val DUMMY_DEVICE_NAME = "mole mole"
+const val DUMMY_ACCESS_TOKEN =
+ "mva_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
diff --git a/android/e2e/e2e-suppression.xml b/android/test/test-suppression.xml
index 2b57bc13e8..2b57bc13e8 100644
--- a/android/e2e/e2e-suppression.xml
+++ b/android/test/test-suppression.xml