Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5f650f5
Add UIAutomator2 widget screenshot test and CI workflow
digitalby Apr 5, 2026
1596b46
Fix UiDevice.longClick: use UiObject2.longClick on workspace instead
digitalby Apr 5, 2026
830a732
Fix am instrument: remove backslash continuations and redundant boot …
digitalby Apr 5, 2026
dd57a9f
Fix SET_WALLPAPER: remove programmatic call, UIAutomator2 runs as she…
digitalby Apr 5, 2026
dec539c
chore: update widget screenshot from emulator run
github-actions[bot] Apr 5, 2026
d04f21e
Fix push rejection: rebase before push in commit step
digitalby Apr 5, 2026
b56d189
chore: update widget screenshot from emulator run
github-actions[bot] Apr 5, 2026
6c4a414
Fix long press via shell swipe; add hierarchy dumps for selector debu…
digitalby Apr 5, 2026
9391a39
Scroll widget picker to find app section; fix hierarchy dump to use U…
digitalby Apr 5, 2026
de113d6
Add diagnostic screenshots; fix dir creation; broaden UiScrollable
digitalby Apr 5, 2026
89c2654
Write diagnostics to /data/local/tmp; dump all texts; scroll all cont…
digitalby Apr 6, 2026
5ac7594
fix: route diagnostics to logcat and pull /sdcard/Pictures/ in CI
digitalby Apr 6, 2026
56a9020
fix: restart Launcher3 after install so widget is indexed, suppress h…
digitalby Apr 6, 2026
7036757
fix: click clickable parent of Widgets popup item, not the non-clicka…
digitalby Apr 6, 2026
aa62876
diag: add pre/post-click screenshots and fix script to exit on instru…
digitalby Apr 6, 2026
3d25055
fix: force-collapse notification shade after Widgets click so picker …
digitalby Apr 6, 2026
8955227
fix: use shell input tap at text element bounds to click the correct …
digitalby Apr 6, 2026
61d99d0
chore: update widget screenshot from emulator run
github-actions[bot] Apr 6, 2026
c4510b4
fix: drag widget to top-eighth of screen so it clears the picker
digitalby Apr 6, 2026
4744a7c
Retry tap on 'Widgets' popup up to 3 times to handle flaky shell inpu…
digitalby Apr 6, 2026
77a79bb
chore: update widget screenshot from emulator run
github-actions[bot] Apr 6, 2026
5411b08
Fix widget tile selector: desc is lowercase 'widget', drag from Widge…
digitalby Apr 6, 2026
8936f88
chore: update widget screenshot from emulator run
github-actions[bot] Apr 6, 2026
e3c82ea
Switch to UiObject2.drag(), drop at y=displayHeight/3, add post-drag …
digitalby Apr 6, 2026
87788dd
chore: update widget screenshot from emulator run
github-actions[bot] Apr 6, 2026
2a576d9
Replace UiObject2.drag with GestureDescription long-press (1500ms) + …
digitalby Apr 6, 2026
c7dcbfe
Fix compile error: revert to device.drag(), drop at screen centre (y=…
digitalby Apr 6, 2026
b5b1d64
chore: update widget screenshot from emulator run
github-actions[bot] Apr 6, 2026
69793c0
Extend Glance render wait to 6 s so both ADB settings show ON in the …
digitalby Apr 6, 2026
c38586a
chore: update widget screenshot from emulator run
github-actions[bot] Apr 6, 2026
8818192
fix: fail test on screenshot write error; fail CI on missing adb pull
digitalby Apr 6, 2026
cdf68a5
chore: update widget screenshot from emulator run
github-actions[bot] Apr 6, 2026
1d61198
ci: add workflow_dispatch to ci.yml so lint+tests can be run on demand
digitalby Apr 6, 2026
0013c93
chore: update widget screenshot from emulator run
github-actions[bot] Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
tags: ['v*.*.*']
pull_request:
branches: [main]
workflow_dispatch:

jobs:
lint-and-test:
Expand Down
89 changes: 89 additions & 0 deletions .github/workflows/screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: Capture Screenshots

on:
workflow_dispatch:
push:
branches: [feature/real-screenshots]
paths:
- 'app/src/main/java/**'
- 'app/src/main/res/**'
- 'app/src/androidTest/**'

jobs:
screenshot:
name: Emulator Screenshot
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}

- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: temurin

- uses: gradle/actions/setup-gradle@v4

- name: Enable KVM
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

- name: Build APKs
run: ./gradlew assembleDebug assembleDebugAndroidTest

- name: Run screenshot test on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
arch: x86_64
profile: pixel_6
emulator-options: >-
-no-window -gpu swiftshader_indirect -no-snapshot
-noaudio -no-boot-anim -camera-back none
disable-animations: true
script: |
set -e
adb shell settings put global adb_enabled 1
adb shell settings put global adb_wifi_enabled 1
adb shell settings put global heads_up_notifications_enabled 0
adb install -t -r app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
adb shell am force-stop com.android.launcher3
sleep 3
adb shell input keyevent KEYCODE_HOME
sleep 3
adb shell cmd statusbar collapse
sleep 1
adb shell am instrument -w -e class info.yuryv.androiddebugmode.screenshot.WidgetScreenshotTest info.yuryv.androiddebugmode.test/androidx.test.runner.AndroidJUnitRunner
adb pull /sdcard/Pictures/01_widget.png fastlane/metadata/android/en-US/images/phoneScreenshots/01_widget.png
mkdir -p diagnostics
adb pull /sdcard/Pictures/ diagnostics/ || true
adb logcat -d -s WDIAG:D > diagnostics/logcat_diag.txt 2>&1 || true

- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: screenshot-diagnostics
path: diagnostics/
if-no-files-found: warn

- name: Commit screenshot
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add fastlane/metadata/android/en-US/images/phoneScreenshots/01_widget.png
if git diff --cached --quiet; then
echo "Screenshot unchanged, nothing to commit."
else
git commit -m "chore: update widget screenshot from emulator run"
git pull --rebase origin feature/real-screenshots
git push
fi
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,5 @@ dependencies {
testImplementation(libs.androidx.glance.appwidget.testing)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
}
2 changes: 2 additions & 0 deletions app/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package info.yuryv.androiddebugmode.screenshot

import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiScrollable
import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.Until
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
import java.io.File

private const val LAUNCHER_PKG = "com.android.launcher3"
private const val TAG = "WDIAG"

@RunWith(AndroidJUnit4::class)
class WidgetScreenshotTest {

private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val device = UiDevice.getInstance(instrumentation)

@Test
fun placeWidgetAndCaptureScreenshot() {
pressHomeAndWait()
openWidgetPicker()
placeWidget()
pressHomeAndWait()
waitForGlanceRender()
takeScreenshot()
}

// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------

private fun pressHomeAndWait() {
device.pressHome()
device.waitForIdle(3_000)
}

private fun openWidgetPicker() {
longPressHomeScreen()
captureScreen("after_long_press")

// The popup menu has a clickable FrameLayout container for all 3 items. Using
// By.clickable(true).hasDescendant(By.text("Widgets")) returns the outermost
// clickable ancestor (the container), not the specific row. Sending a raw
// "input tap" via shell at the TEXT element's centre propagates naturally
// through the view hierarchy to the correct OptionItem click handler.
val widgetsText = device.wait(Until.findObject(By.text("Widgets").pkg(LAUNCHER_PKG)), 5_000)
?: error("'Widgets' text not found in popup after long-pressing the home screen")

val cx = widgetsText.visibleBounds.centerX()
val cy = widgetsText.visibleBounds.centerY()
Log.d(TAG, "CLICK_TARGET: cls=${widgetsText.className} pkg=${widgetsText.applicationPackage} bounds=${widgetsText.visibleBounds}")
captureScreen("before_click")

// Retry the tap up to 3 times. The popup remains open between attempts so
// retrying is safe. Each attempt collapses the notification shade immediately
// after the tap so it cannot cover the widget picker.
val pickerVisible = (1..3).any { attempt ->
shell("input tap $cx $cy")
captureScreen("after_tap_attempt_$attempt")
Thread.sleep(1_000)
shell("cmd statusbar collapse")
Thread.sleep(300)
val appeared = device.wait(Until.hasObject(By.pkg(LAUNCHER_PKG).scrollable(true)), 3_000) == true
Log.d(TAG, "TAP_ATTEMPT[$attempt]: pickerAppeared=$appeared")
appeared
}
if (!pickerVisible) {
captureScreen("picker_not_opened")
logHierarchy("picker_not_opened")
logAllTexts("picker_not_opened")
error("Widget picker did not open after 3 tap attempts on 'Widgets' popup item")
}
device.waitForIdle(1_000)

captureScreen("after_widgets_click")
logHierarchy("after_widgets_click")
logAllTexts("after_widgets_click")
}

private fun placeWidget() {
// Use UiScrollable so the scroll stays inside the Launcher3 RecyclerView
// and cannot accidentally dismiss the picker or open the app drawer.
val picker = UiScrollable(
UiSelector()
.packageName(LAUNCHER_PKG)
.scrollable(true),
)
picker.setMaxSearchSwipes(25)

val found = try {
picker.scrollIntoView(UiSelector().textContains("Debug Mode"))
} catch (_: Exception) {
false
}

val target = device.findObject(By.textContains("Debug Mode").pkg(LAUNCHER_PKG))
if (target == null) {
captureScreen("not_found")
logHierarchy("not_found")
logAllTexts("not_found")
error("App section 'Debug Mode' not found in widget picker (scrollIntoView=$found)")
}

captureScreen("found_app_section")

// Tap the app header to expand the widget tile if collapsed.
target.click()
device.waitForIdle(1_500)
captureScreen("after_app_header_click")
logAllTexts("after_app_header_click")

// Draggable widget tile. Launcher3 uses desc="Debug Mode widget" (lowercase w)
// on the title TextView inside the WidgetCell. Navigate up to the WidgetCell
// parent so the drag starts on the correct container and triggers Launcher3's
// drag-and-drop mode rather than being ignored by the child TextView.
val widgetText =
device.wait(Until.findObject(By.desc("Debug Mode widget").pkg(LAUNCHER_PKG)), 3_000)
?: device.findObject(By.desc("Debug Mode widget"))
?: error("Widget tile not found (desc='Debug Mode widget') after expanding app section")

// parent is the WidgetCell container — the actual draggable view.
val widgetTile = widgetText.parent ?: widgetText

captureScreen("found_widget_tile")
val bounds = widgetTile.visibleBounds
Log.d(TAG, "WIDGET_TILE: cls=${widgetTile.className} bounds=$bounds")

// Drop in the vertical centre of the screen — well below the search bar
// (top ~8 %) and well above the dock (bottom ~12 %). With 400 steps the
// drag takes ~2 s of movement, giving Launcher3 time to process
// the spring-loaded state and resolve the drop on the workspace grid.
// UiDevice.drag() holds at the start for the UIAutomator2 Configurator's
// long-press timeout (default 2 000 ms) before any movement, which
// exceeds Launcher3's 400 ms long-click threshold.
val dropX = device.displayWidth / 2
val dropY = device.displayHeight / 2 // exact centre of Pixel 6 = 1 200 px
Log.d(TAG, "DRAG: from=${bounds.centerX()},${bounds.centerY()} to=$dropX,$dropY")
device.drag(bounds.centerX(), bounds.centerY(), dropX, dropY, 400)
Thread.sleep(3_000)

captureScreen("after_drag")
logHierarchy("after_drag")
logAllTexts("after_drag")
}

private fun waitForGlanceRender() {
// Glance pushes RemoteViews asynchronously. 6 s gives enough time for
// provideGlance() to run and for the AppWidgetManager to deliver the update.
Thread.sleep(6_000)
}

private fun takeScreenshot() {
val out = File("/sdcard/Pictures/01_widget.png")
out.parentFile?.mkdirs()
val ok = device.takeScreenshot(out, 1.0f, 100)
check(ok) { "takeScreenshot returned false — file not written: $out" }
}

// ---------------------------------------------------------------------------
// Diagnostics
// ---------------------------------------------------------------------------

private fun longPressHomeScreen() {
val x = device.displayWidth / 2
val y = device.displayHeight / 3
shell("input touchscreen swipe $x $y $x $y 2000")
device.waitForIdle(3_000)
}

/** Saves a half-resolution screenshot to /sdcard/Pictures/ for CI artifact upload. */
private fun captureScreen(tag: String) {
runCatching {
val dir = File("/sdcard/Pictures").also { it.mkdirs() }
device.takeScreenshot(File(dir, "screen_$tag.png"), 0.5f, 80)
}
}

/** Logs the full UI hierarchy via logcat (tag WDIAG). */
private fun logHierarchy(tag: String) {
runCatching {
val bos = ByteArrayOutputStream()
device.dumpWindowHierarchy(bos)
val xml = bos.toString("UTF-8")
xml.chunked(4000).forEachIndexed { i, chunk ->
Log.d(TAG, "HIERARCHY[$tag][$i]: $chunk")
}
}
}

/** Logs every accessible text/desc element via logcat (tag WDIAG). */
private fun logAllTexts(tag: String) {
runCatching {
val lines = device.findObjects(By.enabled(true)).mapNotNull { el ->
val t = el.text?.takeIf { it.isNotBlank() }
val d = el.contentDescription?.takeIf { it.isNotBlank() }
if (t != null || d != null) "t='$t' d='$d' cls=${el.className} pkg=${el.applicationPackage}"
else null
}
Log.d(TAG, "TEXTS[$tag]: ${lines.joinToString(" || ")}")
}
}

private fun shell(cmd: String) {
instrumentation.uiAutomation.executeShellCommand(cmd).use { }
}
}
3 changes: 2 additions & 1 deletion app/src/main/res/xml/debug_mode_widget_info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@
android:resizeMode="horizontal|vertical"
android:initialLayout="@layout/glance_default_loading_layout"
android:previewImage="@mipmap/ic_launcher"
android:description="@string/widget_description" />
android:description="@string/widget_description"
android:label="@string/app_name" />
17 changes: 17 additions & 0 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,21 @@ platform :android do
}
)
end

desc "Capture widget screenshot on a running emulator and pull to fastlane metadata"
lane :screenshot do
gradle(task: "assembleDebug")
gradle(task: "assembleDebugAndroidTest")
sh("adb shell settings put global adb_enabled 1")
sh("adb shell settings put global adb_wifi_enabled 1")
sh(
"adb shell am instrument -w " \
"-e class info.yuryv.androiddebugmode.screenshot.WidgetScreenshotTest " \
"info.yuryv.androiddebugmode.test/androidx.test.runner.AndroidJUnitRunner"
)
sh(
"adb pull /sdcard/Pictures/01_widget.png " \
"../fastlane/metadata/android/en-US/images/phoneScreenshots/01_widget.png"
)
end
end
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.