diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index ee9ca314..1ee1da9f 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -5,7 +5,7 @@ on: [ pull_request ] jobs: build: name: build - runs-on: macos-latest # Needs to be macos for running AVD on Github + runs-on: ubuntu-latest env: SKIP_ICS_OPENVPN_BUILD: > --build-cache @@ -27,7 +27,7 @@ jobs: -x :ics-openvpn-main:buildCMakeDebug[x86] strategy: matrix: - api-level: [ 31 ] + api-level: [ 34 ] steps: - name: Checkout repository and submodules uses: actions/checkout@v4 @@ -39,17 +39,6 @@ jobs: distribution: 'temurin' java-version: '17' cache: 'gradle' - # Based on https://github.com/actions/cache/blob/main/examples.md#java---gradle - - name: Cache Gradle caches and wrapper - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-${{ matrix.api-level }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.api-level }}-gradle- - - name: Cache build uses: actions/cache@v3 with: @@ -85,33 +74,64 @@ jobs: ./gradlew app:assembleBasicRelease app:assembleBasicDebugAndroidTest \ --build-cache --warning-mode all - # Based on https://github.com/marketplace/actions/android-emulator-runner - - name: AVD cache - uses: actions/cache@v3 - id: avd-cache - with: - path: | + # Setup the runner in the KVM group to enable HW Accleration for the emulator. + # see https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/ + - name: Enable KVM group perms + shell: bash + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # Get the AVD if it's already cached. + - name : AVD cache + uses : actions/cache/restore@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4 + id : restore-avd-cache + with : + path : | ~/.android/avd/* ~/.android/adb* - key: avd-${{ matrix.api-level }} + key : avd-${{ matrix.api-level }} - - name: Create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - arch: x86_64 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - script: echo "Generated AVD snapshot for caching." + # If the AVD cache didn't exist, create an AVD + - name : create AVD and generate snapshot for caching + if : steps.restore-avd-cache.outputs.cache-hit != 'true' + uses : reactivecircus/android-emulator-runner@6b0df4b0efb23bb0ec63d881db79aefbc976e4b2 # v2 + with : + api-level : ${{ matrix.api-level }} + arch : x86_64 + disable-animations : false + emulator-boot-timeout : 12000 + emulator-options : -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + force-avd-creation : false + profile : Galaxy Nexus + ram-size : 4096M + script : echo "Generated AVD snapshot." - - name: Run tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - arch: x86_64 - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true + # If we just created an AVD because there wasn't one in the cache, then cache that AVD. + - name : cache new AVD before tests + if : steps.restore-avd-cache.outputs.cache-hit != 'true' + id : save-avd-cache + uses : actions/cache/save@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4 + with : + path : | + ~/.android/avd/* + ~/.android/adb* + key : avd-${{ matrix.api-level }} + + - name : Run tests + uses : reactivecircus/android-emulator-runner@6b0df4b0efb23bb0ec63d881db79aefbc976e4b2 # v2 + with : + api-level : ${{ matrix.api-level }} + arch : x86_64 + disable-animations : true + emulator-options : -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + force-avd-creation : false + profile : Galaxy Nexus script: ./gradlew :app:connectedBasicDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.package=nl.eduvpn.app.service $SKIP_ICS_OPENVPN_BUILD + - name : Upload results + if : ${{ always() }} + uses : actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3 + with : + name : instrumentation-test-results + path : ./**/build/reports/androidTests/connected/** \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index e47f3c89..9e320d51 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -151,9 +151,6 @@ android { } } -def daggerVersion = "2.48" -def lifecycleVersion = "2.2.0" - dependencies { // OpenVPN library implementation project(path: ':ics-openvpn-main') @@ -188,10 +185,10 @@ dependencies { androidTestImplementation(eduvpnVersions.androidx.test.runner) androidTestImplementation(eduvpnVersions.androidx.test.rules) androidTestImplementation(eduvpnVersions.androidx.test.ext.junit) - androidTestImplementation(eduvpnVersions.androidx.test.orchestrator) androidTestImplementation(eduvpnVersions.espresso) androidTestImplementation(eduvpnVersions.uiautomator) coreLibraryDesugaring(eduvpnVersions.desugar.jdk.libs) + androidTestUtil(eduvpnVersions.androidx.test.orchestrator) } // This will fail the build if there is a Kotlin compiler warning. diff --git a/app/src/androidTest/java/nl/eduvpn/app/ui_test/BrowserTest.kt b/app/src/androidTest/java/nl/eduvpn/app/ui_test/BrowserTest.kt new file mode 100644 index 00000000..69e1cef2 --- /dev/null +++ b/app/src/androidTest/java/nl/eduvpn/app/ui_test/BrowserTest.kt @@ -0,0 +1,52 @@ +package nl.eduvpn.app.ui_test + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObjectNotFoundException +import androidx.test.uiautomator.UiSelector +import nl.eduvpn.app.utils.Log + +abstract class BrowserTest { + + companion object { + private val TAG = BrowserTest::class.java.name + } + fun prepareBrowser() { + // Switch over to UI Automator now, to control the browser + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + // Wait for the browser to open and load + Thread.sleep(2_000L) + try { + // Newer Chrome versions ask if you want to log in + val acceptButton = device.findObject(UiSelector().text("Use without an account")) + acceptButton.click() + } catch (ex: UiObjectNotFoundException) { + Log.w(TAG, "No Chrome user account shown, continuing", ex) + } + try { + // Chrome asks at first launch to accept data usage + val acceptButton = device.findObject(UiSelector().className("android.widget.Button").text("Accept & continue")) + acceptButton.click() + } catch (ex: UiObjectNotFoundException) { + Log.w(TAG, "No Chrome accept window shown, continuing", ex) + } + try { + // Do not send all our web traffic to Google + val liteModeToggle = device.findObject(UiSelector().className("android.widget.Switch")) + if(liteModeToggle.isChecked) { + liteModeToggle.click() + } + val nextButton = device.findObject(UiSelector().className("android.widget.Button").text("Next")) + nextButton.click() + } catch (ex: UiObjectNotFoundException) { + Log.w(TAG, "No lite mode window shown, continuing", ex) + } + try { + // Now it wants us to Sign in... + val noThanksButton = device.findObject(UiSelector().text("No thanks")) + noThanksButton.click() + } catch (ex: UiObjectNotFoundException) { + Log.w(TAG, "No request for sign in, continung", ex) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/nl/eduvpn/app/ui_test/ConnectVpnTest.kt b/app/src/androidTest/java/nl/eduvpn/app/ui_test/ConnectVpnTest.kt index 17e04eea..b693c1ae 100644 --- a/app/src/androidTest/java/nl/eduvpn/app/ui_test/ConnectVpnTest.kt +++ b/app/src/androidTest/java/nl/eduvpn/app/ui_test/ConnectVpnTest.kt @@ -27,6 +27,7 @@ import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.uiautomator.* import nl.eduvpn.app.BaseRobot @@ -46,7 +47,7 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) @LargeTest -class ConnectVpnTest { +class ConnectVpnTest : BrowserTest() { companion object { private val TAG = ConnectVpnTest::class.java.name @@ -82,48 +83,28 @@ class ConnectVpnTest { allOf(withText(TEST_SERVER_URL), withClassName(containsString("TextView"))) ).perform(click()) } - // Switch over to UI Automator now, to control the browser - val device = UiDevice.getInstance(getInstrumentation()) - // Wait for the browser to open and load - Thread.sleep(2_000L) - try { - // Chrome asks at first launch to accept data usage - val acceptButton = device.findObject(UiSelector().className("android.widget.Button").text("Accept & continue")) - acceptButton.click() - } catch (ex: UiObjectNotFoundException) { - Log.w(TAG, "No Chrome accept window shown, continuing", ex) - } - try { - // Do not send all our web traffic to Google - val liteModeToggle = device.findObject(UiSelector().className("android.widget.Switch")) - if(liteModeToggle.isChecked) { - liteModeToggle.click() - } - val nextButton = device.findObject(UiSelector().className("android.widget.Button").text("Next")) - nextButton.click() - } catch (ex: UiObjectNotFoundException) { - Log.w(TAG, "No lite mode window shown, continuing", ex) - } - try { - // Now it wants us to Sign in... - val noThanksButton = device.findObject(UiSelector().text("No thanks")) - noThanksButton.click() - } catch (ex: UiObjectNotFoundException) { - Log.w(TAG, "No request for sign in, continung", ex) - } + prepareBrowser() + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) try { // We can't find objects based on hints here, so we do it on layout order instead. Log.v(TAG, "Entering username.") val userName = device.findObject(UiSelector().className("android.widget.EditText").instance(0)) userName.click() - userName.text = TEST_SERVER_USERNAME - Log.v(TAG, "Scrolling down to see password input") - val webView = UiScrollable(UiSelector().className("android.webkit.WebView").scrollable(true)) - webView.scrollToEnd(1) + userName.setText(TEST_SERVER_USERNAME) Log.v(TAG, "Entering password.") - val password = device.findObject(UiSelector().className("android.widget.EditText").instance(1)) - password.click() - password.text = TEST_SERVER_PASSWORD + var password: UiObject? + try { + password = device.findObject(UiSelector().className("android.widget.EditText").instance(1)) + password.click() + } catch (ex: Exception) { + Log.v(TAG, "Scrolling down to see password input") + val webView = UiScrollable(UiSelector().className("android.webkit.WebView").scrollable(true)) + webView.flingToEnd(1) + Log.v(TAG, "Entering password again.") + password = device.findObject(UiSelector().className("android.widget.EditText").instance(1)) + password.click() + } + password?.setText(TEST_SERVER_PASSWORD) Log.v(TAG, "Hiding keyboard.") device.pressBack() // Closes the keyboard Log.v(TAG, "Signing in.") diff --git a/app/src/androidTest/java/nl/eduvpn/app/ui_test/InstituteAccessDemoTest.kt b/app/src/androidTest/java/nl/eduvpn/app/ui_test/InstituteAccessDemoTest.kt index 74452d89..be2ba902 100644 --- a/app/src/androidTest/java/nl/eduvpn/app/ui_test/InstituteAccessDemoTest.kt +++ b/app/src/androidTest/java/nl/eduvpn/app/ui_test/InstituteAccessDemoTest.kt @@ -49,7 +49,7 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) @LargeTest -class InstituteAccessDemoTest { +class InstituteAccessDemoTest : BrowserTest() { companion object { private val TAG = InstituteAccessDemoTest::class.java.name @@ -85,33 +85,7 @@ class InstituteAccessDemoTest { // Switch over to UI Automator now, to control the browser val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) val selector = UiSelector() - // Wait for the browser to open and load - Thread.sleep(2_000L) - try { - // Chrome asks at first launch to accept data usage - val acceptButton = device.findObject(UiSelector().className("android.widget.Button").text("Accept & continue")) - acceptButton.click() - } catch (ex: UiObjectNotFoundException) { - Log.w(TAG, "No Chrome accept window shown, continuing", ex) - } - try { - // Do not send all our web traffic to Google - val liteModeToggle = device.findObject(UiSelector().className("android.widget.Switch")) - if(liteModeToggle.isChecked) { - liteModeToggle.click() - } - val nextButton = device.findObject(UiSelector().className("android.widget.Button").text("Next")) - nextButton.click() - } catch (ex: UiObjectNotFoundException) { - Log.w(TAG, "No lite mode window shown, continuing", ex) - } - try { - // Now it wants us to Sign in... - val noThanksButton = device.findObject(UiSelector().text("No thanks")) - noThanksButton.click() - } catch (ex: UiObjectNotFoundException) { - Log.w(TAG, "No request for sign in, continuing", ex) - } + prepareBrowser() try { // Select eduID from the list // "Login with" is hidden in the UI @@ -129,20 +103,16 @@ class InstituteAccessDemoTest { selector.className("android.widget.EditText").instance(0) ) userName.click() - userName.text = DEMO_USER + userName.setText(DEMO_USER) device.pressBack() - try { - Log.v(TAG, "Clicking 'type a password' link") - val typePasswordLink = device.findObject(selector.text("type a password.")) - typePasswordLink.click() - Thread.sleep(500L) - } catch (ex: Exception) { - // Type a password preference is sometimes remembered. - } + Log.v(TAG, "Clicking 'Next' button") + val nextButton = device.findObject(selector.text("Next")) + nextButton.click() + Thread.sleep(1500L) Log.v(TAG, "Entering password.") - val password = device.findObject(selector.className("android.widget.EditText").instance(1)) + val password = device.findObject(selector.className("android.widget.EditText").instance(0)) password.click() - password.text = DEMO_PASSWORD + password.setText(DEMO_PASSWORD) device.pressBack() Log.v(TAG, "Logging in...") val loginButton = device.findObject(selector.text("Login")) @@ -165,11 +135,13 @@ class InstituteAccessDemoTest { webView.scrollToEnd(2) Log.v(TAG, "Approving VPN app.") val approveButton = device.findObject(selector.text("Approve")) - approveButton.click() try { + approveButton.click() approveButton.click() // Sometimes it doesn't work :) } catch (ex: Exception) { - // Unhandled + // It might be in dutch + val toestaanButton = device.findObject(selector.text("Toestaan")) + toestaanButton.click() } BaseRobot().waitForView(withText("Demo")).check(matches(isDisplayed())) } diff --git a/app/src/main/java/nl/eduvpn/app/service/BackendService.kt b/app/src/main/java/nl/eduvpn/app/service/BackendService.kt index 4aa18185..d3fb1642 100644 --- a/app/src/main/java/nl/eduvpn/app/service/BackendService.kt +++ b/app/src/main/java/nl/eduvpn/app/service/BackendService.kt @@ -225,14 +225,14 @@ class BackendService( } @kotlin.jvm.Throws(CommonException::class) - suspend fun handleRedirection(redirectUri: Uri?): Boolean { + fun handleRedirection(redirectUri: Uri?): Boolean { val cookie = pendingOAuthCookie val urlString = redirectUri?.toString() if (cookie == null || redirectUri == null || urlString.isNullOrEmpty()) { return false } - val error = goBackend.cookieReply(cookie, urlString) pendingOAuthCookie = null + val error = goBackend.cookieReply(cookie, urlString) if (!error.isNullOrEmpty()) { throw CommonException(error) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 181e90b5..9f1aa712 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,12 +15,13 @@ picasso = "2.71828" coroutines = "1.6.4" serialization-json = "1.6.0" core-library-desugaring = "2.0.3" -junit = "4.12" -androidx-test = "1.4.0" -androidx-test-ext-junit = "1.1.3" -androidx-test-orchestrator = "1.4.1" -espresso = "3.4.0" -uiautomator = "2.2.0" +junit = "4.13.2" +androidx-test-runner = "1.5.2" +androidx-test-rules = "1.5.0" +androidx-test-ext-junit = "1.1.5" +androidx-test-orchestrator = "1.4.2" +espresso = "3.5.1" +uiautomator = "2.3.0" [libraries] wireguard = { group = "com.wireguard.android", name = "tunnel", version.ref = "wireguard" } @@ -43,8 +44,8 @@ serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "core-library-desugaring" } # Unit and UI testing junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test" } -androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidx-test" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidx-test-rules" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } androidx-test-orchestrator = { group = "androidx.test", name = "orchestrator", version.ref = "androidx-test-orchestrator" } espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e411586a..48c0a02c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists