diff options
| author | Albin <albin@mullvad.net> | 2023-01-10 15:58:10 +0100 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2023-01-10 15:58:10 +0100 |
| commit | 7ea38a3881b92b17658a5be4f0e627601212db6c (patch) | |
| tree | 96c12212b3f95c7ea588099aec7cb5fdb22942ed /android | |
| parent | fee3b5804555b3287c9c59aecd3682f118735ba8 (diff) | |
| parent | 57008a509342f547f5e56ef781a7f2e8a18298c0 (diff) | |
| download | mullvadvpn-7ea38a3881b92b17658a5be4f0e627601212db6c.tar.xz mullvadvpn-7ea38a3881b92b17658a5be4f0e627601212db6c.zip | |
Merge branch 'add-instrumented-tests-using-mocked-api'
Diffstat (limited to 'android')
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 |
