summaryrefslogtreecommitdiffhomepage
path: root/android/test/common/src
diff options
context:
space:
mode:
authorAlbin <albin@mullvad.net>2023-01-10 15:58:10 +0100
committerAlbin <albin@mullvad.net>2023-01-10 15:58:10 +0100
commit7ea38a3881b92b17658a5be4f0e627601212db6c (patch)
tree96c12212b3f95c7ea588099aec7cb5fdb22942ed /android/test/common/src
parentfee3b5804555b3287c9c59aecd3682f118735ba8 (diff)
parent57008a509342f547f5e56ef781a7f2e8a18298c0 (diff)
downloadmullvadvpn-7ea38a3881b92b17658a5be4f0e627601212db6c.tar.xz
mullvadvpn-7ea38a3881b92b17658a5be4f0e627601212db6c.zip
Merge branch 'add-instrumented-tests-using-mocked-api'
Diffstat (limited to 'android/test/common/src')
-rw-r--r--android/test/common/src/main/AndroidManifest.xml1
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt6
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt8
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt80
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt89
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/CaptureScreenshotOnFailedTestRule.kt114
-rw-r--r--android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/rule/ForgetAllVpnAppsInSettingsTestRule.kt37
7 files changed, 335 insertions, 0 deletions
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/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt
new file mode 100644
index 0000000000..05b47ef99b
--- /dev/null
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/AppConstants.kt
@@ -0,0 +1,6 @@
+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/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt
new file mode 100644
index 0000000000..0da1d02aaf
--- /dev/null
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/constant/TimeoutConstants.kt
@@ -0,0 +1,8 @@
+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/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt
new file mode 100644
index 0000000000..cb953b920e
--- /dev/null
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/extension/UiAutomatorExtensions.kt
@@ -0,0 +1,80 @@
+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.test.common.constant.DEFAULT_INTERACTION_TIMEOUT
+
+fun UiDevice.findObjectByCaseInsensitiveText(text: String): UiObject2 {
+ return findObjectWithTimeout(By.text(Pattern.compile(text, Pattern.CASE_INSENSITIVE)))
+}
+
+fun UiObject2.findObjectByCaseInsensitiveText(text: String): UiObject2 {
+ return findObjectWithTimeout(By.text(Pattern.compile(text, Pattern.CASE_INSENSITIVE)))
+}
+
+fun UiDevice.findObjectWithTimeout(
+ selector: BySelector,
+ timeout: Long = DEFAULT_INTERACTION_TIMEOUT
+): UiObject2 {
+
+ wait(
+ Until.hasObject(selector),
+ timeout
+ )
+
+ return try {
+ findObject(selector)
+ } catch (e: NullPointerException) {
+ throw IllegalArgumentException(
+ "No matches for selector within timeout ($timeout): $selector"
+ )
+ }
+}
+
+fun 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
+): UiObject2 {
+
+ wait(
+ Until.hasObject(selector),
+ timeout
+ )
+
+ return try {
+ findObject(selector)
+ } catch (e: NullPointerException) {
+ throw IllegalArgumentException(
+ "No matches for selector within timeout ($timeout): $selector"
+ )
+ }
+}
diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt
new file mode 100644
index 0000000000..1d6e9358a8
--- /dev/null
+++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt
@@ -0,0 +1,89 @@
+package net.mullvad.mullvadvpn.test.common.interactor
+
+import android.content.Context
+import android.content.Intent
+import android.widget.ImageButton
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import net.mullvad.mullvadvpn.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
+) {
+ 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(
+ Until.hasObject(By.pkg(MULLVAD_PACKAGE).depth(0)),
+ APP_LAUNCH_TIMEOUT
+ )
+ }
+
+ fun launchAndEnsureLoggedIn(accountToken: String) {
+ launch()
+ device.clickAllowOnNotificationPermissionPromptIfApiLevel31AndAbove()
+ attemptLogin(accountToken)
+ ensureLoggedIn()
+ }
+
+ fun attemptLogin(accountToken: String) {
+ device.findObjectWithTimeout(By.text("Login"))
+ val loginObject = device.findObjectWithTimeout(By.clazz("android.widget.EditText"))
+ .apply { text = accountToken }
+ loginObject.parent.findObject(By.clazz(ImageButton::class.java)).click()
+ }
+
+ fun ensureLoggedIn() {
+ device.findObjectWithTimeout(By.text("UNSECURED CONNECTION"), LOGIN_TIMEOUT)
+ }
+
+ fun extractIpAddress(): String {
+ device.findObjectWithTimeout(By.res(TUNNEL_INFO_ID)).click()
+ return device.findObjectWithTimeout(
+ By.res(TUNNEL_OUT_ADDRESS_ID),
+ CONNECTION_TIMEOUT
+ ).text.extractIpAddress()
+ }
+
+ fun clickSettingsCog() {
+ device.findObjectWithTimeout(By.res(SETTINGS_COG_ID)).click()
+ }
+
+ fun clickListItemByText(text: String) {
+ device.findObjectWithTimeout(By.text(text)).click()
+ }
+
+ fun clickActionButtonByText(text: String) {
+ device.findObjectWithTimeout(By.text(text)).click()
+ }
+
+ private fun String.extractIpAddress(): String {
+ return split(" ")[1].split(" ")[0]
+ }
+}
diff --git a/android/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"
+ }
+}