diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml
index 97274dc7e..6abd444cd 100644
--- a/.github/workflows/Build.yaml
+++ b/.github/workflows/Build.yaml
@@ -153,7 +153,7 @@ jobs:
timeout-minutes: 55
strategy:
matrix:
- api-level: [27, 31]
+ api-level: [26, 30]
steps:
- name: Delete unnecessary tools 🔧
@@ -189,9 +189,7 @@ jobs:
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- - name: Build projects and run instrumentation tests including screenshots
- id: dropshotsverify
- continue-on-error: true
+ - name: Build projects and run instrumentation tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
@@ -199,46 +197,7 @@ jobs:
disable-animations: true
disk-size: 6000M
heap-size: 600M
- profile: pixel_5
- emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
- # Run tests, if they fail, record screenshots and exit with a failure
- script: ./gradlew connectedDemoDebugAndroidTest --daemon || echo "Recording new screenshots" ; ./gradlew connectedDemoDebugAndroidTest -Pdropshots.record --daemon --stacktrace ; echo "Done recording new screenshots, exiting with failure" ; exit 5
-
- - name: Prevent pushing new screenshots if this is a fork (instrumented)
- id: checkfork_screenshots_instrumented
- continue-on-error: false
- if: steps.dropshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
- run: |
- echo "::error::Instrumented screenshot tests failed, please create a PR in your fork first." && exit 1
-
-#
-# - name: Record new instrumented screenshots
-# id: screenshotsrecordinstrumented
-# if: steps.dropshotsverify.outcome == 'failure' && github.event_name == 'pull_request'
-# uses: reactivecircus/android-emulator-runner@v2
-# with:
-# api-level: ${{ matrix.api-level }}
-# arch: x86_64
-# disable-animations: true
-# disk-size: 6000M
-# heap-size: 600M
-# profile: pixel_5
-# force-avd-creation: false
-# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
-# script: adb shell rm -rf /storage/emulated/0/Download/screenshots && ./gradlew connectedDemoDebugAndroidTest -Pdropshots.record --stacktrace
-
- - name: Checkout new changes (in case another job already uploaded screenshots)
- uses: actions/checkout@v4
- with:
- clean: false
-
- - name: Push new device screenshots if available
- uses: stefanzweifel/git-auto-commit-action@4b8a201e31cadd9829df349894b28c54e6c19fe6
- if: steps.dropshotsverify.outcome == 'failure'
- with:
- file_pattern: 'app/src/androidTest/screenshots/*.png'
- disable_globbing: true
- commit_message: "🤖 Updates instrumented screenshots. API ${{ matrix.api-level }}"
+ script: ./gradlew connectedDemoDebugAndroidTest --daemon
- name: Run local tests (including Roborazzi) for the combined coverage report (only API 30)
if: matrix.api-level == 30
@@ -278,3 +237,91 @@ jobs:
compression-level: 1
overwrite: false
path: '**/build/reports/jacoco/'
+
+
+ androidTestScreenshots:
+ runs-on: ubuntu-latest
+ timeout-minutes: 55
+ strategy:
+ matrix:
+ include:
+ - api-level: 27
+ profile: pixel_5
+ - api-level: 33
+ profile: pixel_fold
+ - api-level: "VanillaIceCream"
+ profile: pixel_fold
+
+ steps:
+ - name: Delete unnecessary tools 🔧
+ uses: jlumbroso/free-disk-space@v1.3.1
+ with:
+ android: false # Don't remove Android tools
+ tool-cache: true # Remove image tool cache - rm -rf "$AGENT_TOOLSDIRECTORY"
+ dotnet: true # rm -rf /usr/share/dotnet
+ haskell: true # rm -rf /opt/ghc...
+ swap-storage: true # rm -f /mnt/swapfile (4GiB)
+ docker-images: false # Takes 16s, enable if needed in the future
+ large-packages: false # includes google-cloud-sdk and it's slow
+
+ - name: Enable KVM group perms
+ 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
+ ls /dev/kvm
+
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Copy CI gradle.properties
+ run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: 17
+
+ - name: Setup Gradle
+ uses: gradle/gradle-build-action@v3
+
+ - name: Build projects and run instrumentation tests including screenshots
+ id: dropshotsverify
+ continue-on-error: true
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: ${{ matrix.api-level }}
+ arch: x86_64
+ disable-animations: true
+ disk-size: 6000M
+ heap-size: 600M
+ profile: pixel_5
+ emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ # Run tests, if they fail, record screenshots and exit with a failure
+ script: |
+ ./gradlew connectedDemoDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.annotation=com.google.samples.apps.nowinandroid.ui.InstrumentedScreenshotTests --daemon
+ || echo "Recording new screenshots"
+ ; ./gradlew connectedDemoDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.annotation=com.google.samples.apps.nowinandroid.ui.InstrumentedScreenshotTests -Pdropshots.record --daemon --stacktrace
+ ; echo "Done recording new screenshots, exiting with failure"
+ ; exit 5
+
+ - name: Prevent pushing new screenshots if this is a fork (instrumented)
+ id: checkfork_screenshots_instrumented
+ continue-on-error: false
+ if: steps.dropshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
+ run: |
+ echo "::error::Instrumented screenshot tests failed, please create a PR in your fork first." && exit 1
+
+ - name: Checkout new changes (in case another job already uploaded screenshots)
+ uses: actions/checkout@v4
+ with:
+ clean: false
+
+ - name: Push new device screenshots if available
+ uses: stefanzweifel/git-auto-commit-action@4b8a201e31cadd9829df349894b28c54e6c19fe6
+ if: steps.dropshotsverify.outcome == 'failure'
+ with:
+ file_pattern: 'app/src/androidTest/screenshots/*.png'
+ disable_globbing: true
+ commit_message: "🤖 Updates instrumented screenshots. API ${{ matrix.api-level }}"
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e9919a368..75e1432c5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -67,6 +67,10 @@ android {
unitTests {
isIncludeAndroidResources = true
}
+ // Espresso Device
+ emulatorControl {
+ enable = true
+ }
}
namespace = "com.google.samples.apps.nowinandroid"
}
diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/EdgeToEdgeTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/EdgeToEdgeTest.kt
index 4308796ce..8caa4e9a3 100644
--- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/EdgeToEdgeTest.kt
+++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/EdgeToEdgeTest.kt
@@ -16,8 +16,16 @@
package com.google.samples.apps.nowinandroid.ui
+import android.graphics.Bitmap
+import android.view.WindowInsets
import androidx.test.core.app.takeScreenshot
+import androidx.test.espresso.device.DeviceInteraction.Companion.setClosedMode
+import androidx.test.espresso.device.DeviceInteraction.Companion.setFlatMode
+import androidx.test.espresso.device.EspressoDevice.Companion.onDevice
import androidx.test.espresso.device.common.executeShellCommand
+import androidx.test.espresso.device.controller.DeviceMode.CLOSED
+import androidx.test.espresso.device.controller.DeviceMode.FLAT
+import androidx.test.espresso.device.filter.RequiresDeviceMode
import androidx.test.espresso.device.filter.RequiresDisplay
import androidx.test.espresso.device.sizeclass.HeightSizeClass.Companion.HeightSizeClassEnum
import androidx.test.espresso.device.sizeclass.WidthSizeClass.Companion.WidthSizeClassEnum
@@ -31,13 +39,20 @@ import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPer
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
-import org.junit.After
+import org.junit.AfterClass
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
+/**
+ * These tests must be run on the following devices:
+ * - A phone on API 27 (pixel_5)
+ * - A foldable on API 33 (pixel_fold)
+ * - A foldable on API 35 (pixel_fold)
+ */
@HiltAndroidTest
+@InstrumentedScreenshotTests
class EdgeToEdgeTest {
/**
* Manages the components' state and is used to perform injection on your test
@@ -72,7 +87,6 @@ class EdgeToEdgeTest {
@Before
fun enableDemoMode() {
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).apply {
- executeShellCommand("cmd overlay enable-exclusive com.android.internal.systemui.navbar.threebutton")
executeShellCommand("settings put global sysui_demo_allowed 1")
executeShellCommand("am broadcast -a com.android.systemui.demo -e command enter")
executeShellCommand("am broadcast -a com.android.systemui.demo -e command notifications -e visible false")
@@ -82,33 +96,109 @@ class EdgeToEdgeTest {
}
}
- @After
- fun resetDemoMode() {
- executeShellCommand("am broadcast -a com.android.systemui.demo -e command exit")
+ companion object {
+ @JvmStatic
+ @AfterClass
+ fun resetDemoMode(): Unit {
+ executeShellCommand("am broadcast -a com.android.systemui.demo -e command exit")
+ }
}
@RequiresDisplay(WidthSizeClassEnum.COMPACT, HeightSizeClassEnum.MEDIUM)
@SdkSuppress(minSdkVersion = 27, maxSdkVersion = 27)
@Test
fun edgeToEdge_Phone_Api27() {
- testEdgeToEdge("edgeToEdge_Phone_Api27")
+ screenshotSystemBar("edgeToEdge_Phone_systemBar_Api27")
+ screenshotNavigationBar("edgeToEdge_Phone_navBar_Api27")
}
- @RequiresDisplay(WidthSizeClassEnum.COMPACT, HeightSizeClassEnum.MEDIUM)
- @SdkSuppress(minSdkVersion = 31, maxSdkVersion = 31)
+ @RequiresDeviceMode(mode = FLAT)
+ @RequiresDeviceMode(mode = CLOSED)
+ @SdkSuppress(minSdkVersion = 33, maxSdkVersion = 33)
@Test
- fun edgeToEdge_Phone_Api31() {
- testEdgeToEdge("edgeToEdge_Phone_Api31")
+ fun edgeToEdge_Foldable_api33() {
+ runFoldableTests(apiName = "api33")
}
- @RequiresDisplay(WidthSizeClassEnum.EXPANDED, HeightSizeClassEnum.MEDIUM)
- @SdkSuppress(minSdkVersion = 30, maxSdkVersion = 30)
+ @RequiresDeviceMode(mode = FLAT)
+ @RequiresDeviceMode(mode = CLOSED)
+ @SdkSuppress(minSdkVersion = 35, codeName = "VanillaIceCream")
@Test
- fun edgeToEdge_Tablet_Api30() {
- testEdgeToEdge("edgeToEdge_Tablet_Api30")
+ fun edgeToEdge_Foldable_api35() {
+ runFoldableTests(apiName = "api35")
+ }
+
+ private fun runFoldableTests(apiName: String) {
+ onDevice().setClosedMode()
+ screenshotSystemBar("edgeToEdge_Foldable_closed_system_${apiName}")
+ forceThreeButtonNavigation()
+ screenshotNavigationBar("edgeToEdge_Foldable_closed_nav3button_${apiName}")
+ forceGestureNavigation()
+ screenshotNavigationBar("edgeToEdge_Foldable_closed_navGesture_${apiName}")
+
+ onDevice().setFlatMode()
+ enableDemoMode() // Flat mode resets demo mode!
+ screenshotSystemBar("edgeToEdge_Foldable_flat_system_${apiName}")
+ forceThreeButtonNavigation()
+ screenshotNavigationBar("edgeToEdge_Foldable_flat_nav3button_${apiName}")
+ forceGestureNavigation()
+ screenshotNavigationBar("edgeToEdge_Foldable_flat_navGesture_${apiName}")
+ }
+
+ private fun screenshotSystemBar(screenshotFileName: String) {
+ var topInset: Int? = null
+ var width: Int? = null
+ waitForWindowUpdate()
+ activityScenarioRule.scenario.onActivity { activity ->
+ topInset = activity.windowManager.maximumWindowMetrics.windowInsets.getInsets(
+ WindowInsets.Type.systemBars()).top
+ width = activity.windowManager.maximumWindowMetrics.bounds.width()
+ }
+ // Crop the top, adding extra pixels to check continuity
+ val bitmap = takeScreenshot().let {
+ Bitmap.createBitmap(it, 0, 0, width!!, (topInset!! * 2))
+ }
+ dropshots.assertSnapshot(bitmap, screenshotFileName)
+ }
+
+ private fun screenshotNavigationBar(screenshotFileName: String) {
+ var bottomInset: Int? = null
+ var width: Int? = null
+ var height: Int? = null
+ waitForWindowUpdate()
+ activityScenarioRule.scenario.onActivity { activity ->
+ bottomInset = activity.windowManager.maximumWindowMetrics.windowInsets.getInsets(
+ WindowInsets.Type.navigationBars()).bottom
+ width = activity.windowManager.maximumWindowMetrics.bounds.width()
+ height = activity.windowManager.maximumWindowMetrics.bounds.height()
+ }
+ // Crop the top, adding extra pixels to check continuity
+ val bitmap = takeScreenshot().let {
+ Bitmap.createBitmap(it, 0, height!! - (bottomInset!! * 2), width!!, (bottomInset!! * 2))
+ }
+ dropshots.assertSnapshot(bitmap, screenshotFileName)
+ }
+
+ private fun forceThreeButtonNavigation() {
+ UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).apply {
+ executeShellCommand("cmd overlay enable-exclusive " +
+ "com.android.internal.systemui.navbar.threebutton")
+ }
+ }
+
+ private fun forceGestureNavigation() {
+ UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).apply {
+ executeShellCommand("cmd overlay enable-exclusive " +
+ "com.android.internal.systemui.navbar.gestural")
+ }
}
- private fun testEdgeToEdge(screenshotFileName: String) {
- dropshots.assertSnapshot(takeScreenshot(), screenshotFileName)
+ private fun waitForWindowUpdate() {
+ // TODO: This works but it's unclear if it's making it wait too long. Investigate.
+ UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+ .waitForWindowUpdate(
+ InstrumentationRegistry.getInstrumentation().targetContext.packageName,
+ 4000
+ );
}
}
diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/InstrumentedScreenshotTests.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/InstrumentedScreenshotTests.kt
new file mode 100644
index 000000000..c279130cb
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/InstrumentedScreenshotTests.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.samples.apps.nowinandroid.ui
+
+/**
+ * TODO: Move to a test module.
+ */
+annotation class InstrumentedScreenshotTests
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index bc214c778..36313049e 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -19,5 +19,7 @@
-
+
+
+
diff --git a/gradle.properties b/gradle.properties
index 16906e249..8e2896cd5 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -42,3 +42,6 @@ android.defaults.buildfeatures.shaders=false
# Run Roborazzi screenshot tests with the local tests
roborazzi.test.verify=true
+
+# Espresso Device
+android.experimental.androidTest.enableEmulatorControl=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0b44d9841..943fb52dd 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -34,7 +34,7 @@ androidxWindowManager = "1.3.0-alpha03"
androidxWork = "2.9.0"
coil = "2.6.0"
dependencyGuard = "0.5.0"
-dropshots = "0.4.1"
+dropshots = "0.4.2"
firebaseBom = "32.4.0"
firebaseCrashlyticsPlugin = "2.9.9"
firebasePerfPlugin = "1.4.2"