diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d98974b..1af9bd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: tags: ['v*.*.*'] pull_request: branches: [main] + workflow_dispatch: jobs: lint-and-test: diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml new file mode 100644 index 0000000..2b366a3 --- /dev/null +++ b/.github/workflows/screenshots.yml @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db342e8..e8453f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } \ No newline at end of file diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/app/src/androidTest/java/info/yuryv/androiddebugmode/screenshot/WidgetScreenshotTest.kt b/app/src/androidTest/java/info/yuryv/androiddebugmode/screenshot/WidgetScreenshotTest.kt new file mode 100644 index 0000000..a44aceb --- /dev/null +++ b/app/src/androidTest/java/info/yuryv/androiddebugmode/screenshot/WidgetScreenshotTest.kt @@ -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 { } + } +} diff --git a/app/src/main/res/xml/debug_mode_widget_info.xml b/app/src/main/res/xml/debug_mode_widget_info.xml index 157ca0d..7613438 100644 --- a/app/src/main/res/xml/debug_mode_widget_info.xml +++ b/app/src/main/res/xml/debug_mode_widget_info.xml @@ -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" /> diff --git a/fastlane/Fastfile b/fastlane/Fastfile index c506507..1ed8b19 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -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 diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01_widget.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/01_widget.png index cb8521e..3729873 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/01_widget.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01_widget.png differ