From 33322ea3a327733f0b27397133057dfc85e56bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20Sonic?= Date: Sat, 24 Oct 2026 12:37:41 +0000 Subject: [PATCH] ci(android): make the emulator smoke survive a slow emulator, not app failures (#68) Evidence from runs 39866280662, 39870244857 and 40091145294 shows the red smoke runs were the emulator, not TGLock: on the software GPU the WebView paints about one frame per second while the page re-renders every second, so a tap's down/up can straddle a re-render and Chromium drops the click (no setEnabled call at all in the failed Stop run). Play services and Pixel Launcher post-boot work also starved system_server into foreign ANR dialogs. - AOSP image, 4 cores, 4 GB RAM, no snapshot save; wait for CPU to settle after boot before launching. - Answer foreign "isn't responding" dialogs with Wait and record them; a TGLock ANR/error dialog, crash or missing process still fails. - Re-tap only when the button is unchanged 10s after a tap (at most 3 taps); foreground-service and SOCKS listener effects stay strictly asserted with a 45s budget sized for this emulator. - Timed step markers in stdout and logcat, failure screenshot and window dump, one evidence artifact per run attempt. Co-authored-by: babin Co-authored-by: Claude Opus 5.5 --- .github/workflows/android.yml | 15 ++- docs/ANDROID.md | 9 +- scripts/android_smoke.py | 193 ++++++++++++++++++++++++++++------ 3 files changed, 178 insertions(+), 39 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c70d2d4..06d93ff 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -79,7 +79,7 @@ jobs: name: Android 15 emulator smoke needs: apk runs-on: ubuntu-22.04 - timeout-minutes: 15 + timeout-minutes: 25 steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v4 @@ -91,20 +91,25 @@ jobs: 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 + # The AOSP image has no Play services or Pixel Launcher. Their post-boot + # work starved system_server on the 4-vCPU runner and produced foreign ANR + # dialogs over the app. The app needs neither; WebView is still present. - name: Launch and exercise the native app uses: reactivecircus/android-emulator-runner@v2 with: api-level: 35 arch: x86_64 - target: google_apis + target: default profile: pixel_2 + cores: 4 + ram-size: 4096M disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: timeout 180 python3 scripts/android_smoke.py emulator-apk + emulator-options: -no-window -no-snapshot -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: timeout 420 python3 scripts/android_smoke.py emulator-apk - uses: actions/upload-artifact@v4 if: always() with: - name: android-emulator-smoke-evidence + name: android-emulator-smoke-evidence-attempt-${{ github.run_attempt }} path: android-smoke-evidence/ if-no-files-found: warn retention-days: 14 diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 0dcbe35..b1fe018 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -57,8 +57,13 @@ restart, and explicit force-stop/relaunch. It verifies the foreground service an performs a real SOCKS5 greeting through ADB port forwarding to the Rust listener. If buttons are inaccessible after a bounded wait, CI explicitly reports the lifecycle checks as skipped; a launch-only pass is not lifecycle evidence. -`android-emulator-smoke-evidence` retains the exact result, UI dumps, service -state and logcat. This does not test automatic low-memory eviction, battery +`android-emulator-smoke-evidence-attempt-N` (one per run attempt) retains the +exact result, timed steps, UI dumps, service state, logcat and, on failure, a +screenshot. The emulator is the AOSP image without Play services; foreign +system ANR dialogs are answered with Wait and recorded, while a crash, ANR or +error dialog of TGLock itself fails the job. A tap the WebView dropped (the +button is unchanged ten seconds later) is repeated at most twice; the service +and listener effects are then asserted with the same strictness. This does not test automatic low-memory eviction, battery behavior, Telegram connectivity, or a physical phone. Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's diff --git a/scripts/android_smoke.py b/scripts/android_smoke.py index 7ec9f41..85902e5 100644 --- a/scripts/android_smoke.py +++ b/scripts/android_smoke.py @@ -14,23 +14,103 @@ PACKAGE = "com.bysonic.tglock" COMPONENT = f"{PACKAGE}/.MainActivity" EVIDENCE = Path("android-smoke-evidence") EVIDENCE.mkdir(exist_ok=True) -RESULT = {"launch": "not_run", "lifecycle": "not_run"} +RESULT = {"launch": "not_run", "lifecycle": "not_run", "foreign_dialogs": [], "taps": {}} +STARTED = time.monotonic() + +# Budgets measured on the GitHub-hosted emulator, not on a phone. With the +# software GPU the WebView paints about one frame per second, and dumpsys +# itself can take seconds while system_server catches up after boot. +SETTLE_SECONDS = 120 +VISIBLE_SECONDS = 60 +EFFECT_SECONDS = 45 +# A click that reaches the handler re-renders the button as "Подождите…" or +# the opposite action within a frame. Only a button left unchanged this long +# was never clicked. +RETAP_AFTER_SECONDS = 10 +MAX_TAPS = 3 +SYSTEM_DIALOG = re.compile(r"(Application Not Responding|Application Error): ([\w.:]+)") -def adb(*args, check=True, timeout=20): +def adb(*args, check=True, timeout=30): return subprocess.run( ["adb", *args], check=check, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, ).stdout.strip() -def wait_for(description, predicate, seconds=20): +def step(message): + # The same marker in stdout and logcat lines up the job log with evidence. + line = f"[{time.monotonic() - STARTED:6.1f}s] {message}" + print(line, flush=True) + with open(EVIDENCE / "steps.txt", "a", encoding="utf-8") as steps: + steps.write(line + "\n") + adb("shell", "log", "-t", "tglock-smoke", json.dumps(message), check=False) + + +def system_dialog(): + try: + windows = adb("shell", "dumpsys", "window", "windows", check=False) + except subprocess.SubprocessError: + return None + match = SYSTEM_DIALOG.search(windows) + return (match.group(1), match.group(2)) if match else None + + +def clear_foreign_dialogs(): + # A loaded emulator regularly declares its own launcher or system apps + # unresponsive, and that dialog covers the WebView. Ours is a failure. + dialog = system_dialog() + if dialog is None: + return + kind, process = dialog + if process.split(":")[0] == PACKAGE: + raise AssertionError(f"{kind} dialog for {process}") + button = "android:id/aerr_wait" if kind == "Application Not Responding" else "android:id/aerr_close" + try: + point = node_center(ui_dump(f"dialog-{len(RESULT['foreign_dialogs'])}"), + lambda node: node.get("resource-id") == button) + except (subprocess.SubprocessError, ET.ParseError): + point = None + if point is not None: + adb("shell", "input", "tap", str(point[0]), str(point[1])) + outcome = f"tapped {button}" if point else "button not dumped yet" + RESULT["foreign_dialogs"].append(f"{kind}: {process}, {outcome}") + step(f"foreign dialog {kind}: {process}, {outcome}") + + +def wait_for(description, predicate, seconds=EFFECT_SECONDS): deadline = time.monotonic() + seconds while time.monotonic() < deadline: + clear_foreign_dialogs() if predicate(): + step(f"ok: {description}") return time.sleep(1) - raise AssertionError(f"Timed out: {description}") + raise AssertionError(f"Timed out after {seconds}s: {description}") + + +def cpu_idle_share(interval=3): + def sample(): + values = list(map(int, adb("shell", "head", "-1", "/proc/stat").split()[1:])) + return values[3] + values[4], sum(values) + idle1, total1 = sample() + time.sleep(interval) + idle2, total2 = sample() + return (idle2 - idle1) / max(1, total2 - total1) + + +def settle(): + # boot_completed fires long before package, launcher and dexopt work + # drains. Start the app on a quiet device instead of racing that work. + deadline = time.monotonic() + SETTLE_SECONDS + quiet = 0 + idle = 0.0 + while time.monotonic() < deadline and quiet < 2: + clear_foreign_dialogs() + idle = cpu_idle_share() + quiet = quiet + 1 if idle >= 0.6 else 0 + RESULT["settle"] = f"{'quiet' if quiet >= 2 else 'still busy'} at {time.monotonic() - STARTED:.0f}s, {idle:.0%} idle" + step(f"settle: {RESULT['settle']}") def ui_dump(label): @@ -47,12 +127,9 @@ def ui_dump(label): return ET.fromstring(text) -def label_node(tree, label): +def node_center(tree, matches): for node in tree.iter("node"): - # Android aggregates the decorative arrow into the button's accessible - # text (observed in the API35 CI dump). Accept only that exact suffix. - names = (node.get("text", "").strip(), node.get("content-desc", "").strip()) - if any(name in (label, f"{label} →") for name in names): + if matches(node): bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", "")) if bounds: x1, y1, x2, y2 = map(int, bounds.groups()) @@ -61,17 +138,53 @@ def label_node(tree, label): return None -def tap_label(label, stage): - for attempt in range(5): - try: - point = label_node(ui_dump(f"{stage}-{attempt}"), label) - except (subprocess.SubprocessError, ET.ParseError) as error: - (EVIDENCE / f"{stage}-{attempt}.error.txt").write_text(str(error), encoding="utf-8") - point = None - if point is not None: - adb("shell", "input", "tap", str(point[0]), str(point[1])) +def label_node(tree, label): + # Android aggregates the decorative arrow into the button's accessible + # text (observed in the API35 CI dump). Accept only that exact suffix. + return node_center(tree, lambda node: any( + name in (label, f"{label} →") + for name in (node.get("text", "").strip(), node.get("content-desc", "").strip()) + )) + + +def find_label(label, stage): + clear_foreign_dialogs() + try: + return label_node(ui_dump(stage), label) + except (subprocess.SubprocessError, ET.ParseError) as error: + (EVIDENCE / f"{stage}.error.txt").write_text(str(error), encoding="utf-8") + return None + + +def press(label, stage): + """Tap a visible WebView action until the page shows it was clicked. + + The page re-renders every second. At one frame per second the touch down + and up can straddle that re-render, and Chromium then drops the click: the + button stays exactly as it was and the handler never runs. Callers still + assert the service and listener effect strictly afterwards. + """ + deadline = time.monotonic() + VISIBLE_SECONDS + taps = 0 + last_tap = 0.0 + attempt = 0 + while time.monotonic() < deadline: + point = find_label(label, f"{stage}-{attempt}") + attempt += 1 + if point is None and taps: + RESULT["taps"][stage] = taps + step(f"{label}: clicked after {taps} tap(s)") return + if point is not None and (not taps or time.monotonic() - last_tap >= RETAP_AFTER_SECONDS): + if taps == MAX_TAPS: + raise AssertionError(f"{label} stayed unchanged after {taps} taps") + adb("shell", "input", "tap", str(point[0]), str(point[1])) + taps += 1 + last_tap = time.monotonic() + step(f"{label}: tap {taps} at {point}") time.sleep(1) + if taps: + raise AssertionError(f"{label} stayed unchanged {VISIBLE_SECONDS}s after {taps} tap(s)") raise AssertionError(f"Visible action not found: {label}") @@ -85,7 +198,7 @@ def proxy_ready(): # ADB forwards to emulator loopback. A real SOCKS5 greeting proves the # Rust listener is serving, beyond just a notification being displayed. try: - with socket.create_connection(("127.0.0.1", 11080), timeout=1) as peer: + with socket.create_connection(("127.0.0.1", 11080), timeout=3) as peer: peer.sendall(bytes([5, 1, 0])) return peer.recv(2) == bytes([5, 0]) except (OSError, TimeoutError): @@ -93,7 +206,7 @@ def proxy_ready(): def launch(): - adb("shell", "am", "start", "-W", "-n", COMPONENT) + adb("shell", "am", "start", "-W", "-n", COMPONENT, timeout=60) wait_for("Activity resumed", lambda: any( "ResumedActivity" in line and PACKAGE in line for line in adb("shell", "dumpsys", "activity", "activities").splitlines() @@ -104,17 +217,20 @@ def launch(): def main(): apks = sorted(Path(sys.argv[1]).rglob("*.apk")) assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}" - adb("install", "-r", str(apks[0]), timeout=60) + adb("install", "-r", str(apks[0]), timeout=120) adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS") - adb("logcat", "-c") adb("forward", "tcp:11080", "tcp:1080") + settle() + adb("logcat", "-c") + step("launch") launch() RESULT["launch"] = "passed" - deadline = time.monotonic() + 20 + deadline = time.monotonic() + VISIBLE_SECONDS tree = None start_point = None attempt = 0 while time.monotonic() < deadline: + clear_foreign_dialogs() try: tree = ui_dump(f"launched-{attempt}") start_point = label_node(tree, "Включить защиту") @@ -132,23 +248,25 @@ def main(): for node in tree.iter("node") ), "App Activity is resumed but its WebView is absent" if start_point is None: - RESULT["lifecycle"] = "skipped: WebView Start not accessible after 20s" - print("::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after 20s") + RESULT["lifecycle"] = f"skipped: WebView Start not accessible after {VISIBLE_SECONDS}s" + print(f"::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after {VISIBLE_SECONDS}s") return RESULT["lifecycle"] = "failed: lifecycle assertions incomplete" - tap_label("Включить защиту", "before-start") + press("Включить защиту", "before-start") wait_for("foreground service after Start", service_running) wait_for("Rust SOCKS listener after Start", proxy_ready) adb("shell", "input", "keyevent", "KEYCODE_HOME") time.sleep(5) + clear_foreign_dialogs() assert service_running() and proxy_ready(), "Proxy stopped after backgrounding" + step("ok: still serving after 5s in background") launch() - tap_label("Выключить", "before-stop") + press("Выключить", "before-stop") wait_for("foreground service after Stop", lambda: not service_running()) wait_for("Rust listener after Stop", lambda: not proxy_ready()) # Explicit restart and user force-stop, then relaunch. This tests the # user-stop contract, not Android's automatic low-memory process eviction. - tap_label("Включить защиту", "before-restart") + press("Включить защиту", "before-restart") wait_for("Rust listener after restart", proxy_ready) adb("shell", "am", "force-stop", PACKAGE) launch() @@ -160,10 +278,21 @@ try: main() except Exception as error: RESULT["failure"] = str(error) + for name, args in (("failure-windows.txt", ("dumpsys", "window", "windows")), + ("failure-activities.txt", ("dumpsys", "activity", "activities"))): + try: + (EVIDENCE / name).write_text(adb("shell", *args, check=False), encoding="utf-8") + except subprocess.SubprocessError: + pass + try: + (EVIDENCE / "failure.png").write_bytes(subprocess.run( + ["adb", "exec-out", "screencap", "-p"], capture_output=True, timeout=30).stdout) + except subprocess.SubprocessError: + pass raise finally: try: - logs = adb("logcat", "-d", "-v", "threadtime") + logs = adb("logcat", "-d", "-v", "threadtime", timeout=60) (EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8") crashes = adb("logcat", "-b", "crash", "-d") (EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8") @@ -171,9 +300,9 @@ finally: RESULT["launch"] = "failed: application crash, ANR, or missing process" raise AssertionError("Application did not remain healthy") finally: - (EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2), encoding="utf-8") + (EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2, ensure_ascii=False), encoding="utf-8") summary = os.environ.get("GITHUB_STEP_SUMMARY") if summary: with open(summary, "a", encoding="utf-8") as report: - report.write("\nAndroid emulator smoke: " + json.dumps(RESULT) + "\n") - print(json.dumps(RESULT)) + report.write("\nAndroid emulator smoke: " + json.dumps(RESULT, ensure_ascii=False) + "\n") + print(json.dumps(RESULT, ensure_ascii=False))