From b7e405eacbbaf1d017d8f01b89d3c34123e25d40 Mon Sep 17 00:00:00 2001 From: babin Date: Sat, 19 Sep 2026 15:26:04 +0300 Subject: [PATCH] test(android): verify installed APK and proxy lifecycle on emulator --- .github/workflows/android.yml | 63 ++++++++++++- docs/ANDROID.md | 142 +++++++++++++++------------- scripts/android_smoke.py | 168 ++++++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+), 71 deletions(-) create mode 100644 scripts/android_smoke.py diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c823587..c70d2d4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -9,9 +9,25 @@ on: permissions: contents: read +concurrency: + group: android-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: apk: - name: Android ARM64 debug APK + name: Android ${{ matrix.arch }} debug APK + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + target: aarch64 + rust-target: aarch64-linux-android + abi: arm64-v8a + - arch: x86_64 + target: x86_64 + rust-target: x86_64-linux-android + abi: x86_64 runs-on: ubuntu-22.04 timeout-minutes: 45 steps: @@ -34,7 +50,10 @@ jobs: echo "NDK_HOME=$ANDROID_HOME/ndk/28.0.13004108" >> "$GITHUB_ENV" - uses: dtolnay/rust-toolchain@1.88.0 with: - targets: aarch64-linux-android + targets: ${{ matrix.rust-target }} + - uses: Swatinem/rust-cache@v2 + with: + key: android-${{ matrix.target }} - name: Install frontend dependencies run: npm ci # Tauri regenerates only ignored machine-specific Gradle glue. The @@ -42,16 +61,50 @@ jobs: - name: Build installable APK with the bundled frontend run: | chmod +x gen/android/gradlew - npm run tauri -- android build --debug --apk --target aarch64 --ci + npm run tauri -- android build --debug --apk --target ${{ matrix.target }} --ci - name: Check APK signature and packaged Rust engine run: | apk=$(find gen/android/app/build/outputs/apk -name '*.apk' -print -quit) test -n "$apk" "$ANDROID_HOME/build-tools/36.0.0/apksigner" verify "$apk" - unzip -l "$apk" | grep 'lib/arm64-v8a/libtglock_lib.so' + unzip -l "$apk" | grep 'lib/${{ matrix.abi }}/libtglock_lib.so' - uses: actions/upload-artifact@v4 with: - name: tglock-android-arm64-debug + name: tglock-android-${{ matrix.arch }}-debug path: gen/android/app/build/outputs/apk/**/*.apk if-no-files-found: error retention-days: 14 + + emulator: + name: Android 15 emulator smoke + needs: apk + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v4 + with: + name: tglock-android-x86_64-debug + path: emulator-apk + - 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: Launch and exercise the native app + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + target: google_apis + profile: pixel_2 + 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 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: android-emulator-smoke-evidence + path: android-smoke-evidence/ + if-no-files-found: warn + retention-days: 14 diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 31c582c..0dcbe35 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -1,66 +1,76 @@ -# Android (experimental) - -Issue #9 is implemented as a Tauri Android application sharing the current Rust -proxy engine and UI with the desktop application. The old Android PR scaffold is -retained, but its stale engine and desktop code are not imported. - -## Install a test APK - -Open this PR's **Android APK** check, then the workflow run's **Artifacts** section. -Download `tglock-android-arm64-debug`, unzip it, and install the `.apk` on an -ARM64 Android 7.0+ device. GitHub requires signing in to download CI artifacts. -No compiler or Android Studio is needed on the phone. The artifact expires after -14 days; maintainers can rerun the workflow to create a fresh build. - -This is an automatically debug-signed test build, not a Play Store release. -Different CI runs can use different debug signing keys: if Android rejects an -update because the signatures differ, uninstall the previous test build first. -Uninstalling deletes settings and changes the proxy secret, so reconnect Telegram -with the new link. A future production release needs a stable signing key. - -1. Open TGLock and press **Включить защиту**. -2. Accept the proxy in Telegram when prompted. If opening Telegram fails, return - to TGLock and use **Открыть Telegram** or **Скопировать ссылку**. -3. Keep LAN access off when Telegram runs on this same phone (`127.0.0.1`). -4. To stop, return through the ongoing notification and press **Выключить**. - -## Lifecycle and limitations - -The native foreground service starts only for an explicitly started proxy and -stops when its Rust accept loop finishes, including a normal Stop action. It is -not stopped by Activity destruction or rotation. Android 14+ declares the -`specialUse` service type, its dedicated permission, and a subtype describing the -user-controlled local proxy. Notification permission denial does not prevent the -foreground service from running; Android still exposes it in its task manager. - -The service uses `START_NOT_STICKY`: after Android kills the process or the user -force-stops it, it does not restart a notification without a Rust engine. Open -TGLock and enable protection again. No boot receiver or automatic background -restart is installed. Vendor battery management and network changes may still -interrupt connections. The app does not claim to be a device-wide VPN. - -The proxy secret lives in the app's private configuration directory. Desktop -installs migrate an existing valid legacy secret, preserving saved Telegram -links. The link-copy control intentionally contains this secret; the public -report-copy control includes only counters, port, route and mode. - -## Build and verification - -CI uses Java 17, Android SDK 36, NDK 28, Rust 1.88, and the locked npm/Rust -manifests. `npm run tauri -- android build --debug --apk --target aarch64 --ci` -bundles the frontend inside a signed APK; no development web server is required. -CI verifies the signature and the presence of the ARM64 Rust library. - -Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's -machine-specific generated glue, SDK paths, native build output and signing files -remain ignored. Run the same build command locally after installing Tauri's -[Android prerequisites](https://v2.tauri.app/start/prerequisites/#android). - -No physical-phone or Telegram end-to-end test has been performed by this change. -Before promoting it beyond an experimental APK, test Android 13 notification -permission grant/denial, Android 14+ service startup, Start/Stop/restart, switching -to Telegram for at least 10 minutes, rotation, Activity recreation, process death, -Wi-Fi/mobile-data handover, and restoration with the same persisted secret. - -Native bridging follows [Tauri mobile plugins](https://v2.tauri.app/develop/plugins/develop-mobile/) -and uses [Tauri opener](https://v2.tauri.app/plugin/opener/) for `tg://` links. +# Android (experimental) + +Issue #9 is implemented as a Tauri Android application sharing the current Rust +proxy engine and UI with the desktop application. The old Android PR scaffold is +retained, but its stale engine and desktop code are not imported. + +## Install a test APK + +Open this PR's **Android APK** check, then the workflow run's **Artifacts** section. +Download `tglock-android-arm64-debug`, unzip it, and install the `.apk` on an +ARM64 Android 7.0+ device. GitHub requires signing in to download CI artifacts. +No compiler or Android Studio is needed on the phone. The artifact expires after +14 days; maintainers can rerun the workflow to create a fresh build. + +This is an automatically debug-signed test build, not a Play Store release. +Different CI runs can use different debug signing keys: if Android rejects an +update because the signatures differ, uninstall the previous test build first. +Uninstalling deletes settings and changes the proxy secret, so reconnect Telegram +with the new link. A future production release needs a stable signing key. + +1. Open TGLock and press **Включить защиту**. +2. Accept the proxy in Telegram when prompted. If opening Telegram fails, return + to TGLock and use **Открыть Telegram** or **Скопировать ссылку**. +3. Keep LAN access off when Telegram runs on this same phone (`127.0.0.1`). +4. To stop, return through the ongoing notification and press **Выключить**. + +## Lifecycle and limitations + +The native foreground service starts only for an explicitly started proxy and +stops when its Rust accept loop finishes, including a normal Stop action. It is +not stopped by Activity destruction or rotation. Android 14+ declares the +`specialUse` service type, its dedicated permission, and a subtype describing the +user-controlled local proxy. Notification permission denial does not prevent the +foreground service from running; Android still exposes it in its task manager. + +The service uses `START_NOT_STICKY`: after Android kills the process or the user +force-stops it, it does not restart a notification without a Rust engine. Open +TGLock and enable protection again. No boot receiver or automatic background +restart is installed. Vendor battery management and network changes may still +interrupt connections. The app does not claim to be a device-wide VPN. + +The proxy secret lives in the app's private configuration directory. Desktop +installs migrate an existing valid legacy secret, preserving saved Telegram +links. The link-copy control intentionally contains this secret; the public +report-copy control includes only counters, port, route and mode. + +## Build and verification + +CI uses Java 17, Android SDK 36, NDK 28, Rust 1.88, and the locked npm/Rust +manifests. `npm run tauri -- android build --debug --apk --target aarch64 --ci` +bundles the frontend inside a signed APK; no development web server is required. +CI verifies the signature and the presence of each architecture's Rust library. +The x86_64 build is installed on an Android 15 emulator. The bounded smoke checks +Activity launch, process survival, and crash/ANR logs. When UIAutomator exposes +the WebView buttons, it also checks Start, five seconds in the background, Stop, +restart, and explicit force-stop/relaunch. It verifies the foreground service and +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 +behavior, Telegram connectivity, or a physical phone. + +Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's +machine-specific generated glue, SDK paths, native build output and signing files +remain ignored. Run the same build command locally after installing Tauri's +[Android prerequisites](https://v2.tauri.app/start/prerequisites/#android). + +No physical-phone or Telegram end-to-end test has been performed by this change. +Before promoting it beyond an experimental APK, test Android 13 notification +permission grant/denial, Android 14+ service startup, Start/Stop/restart, switching +to Telegram for at least 10 minutes, rotation, Activity recreation, process death, +Wi-Fi/mobile-data handover, and restoration with the same persisted secret. + +Native bridging follows [Tauri mobile plugins](https://v2.tauri.app/develop/plugins/develop-mobile/) +and uses [Tauri opener](https://v2.tauri.app/plugin/opener/) for `tg://` links. diff --git a/scripts/android_smoke.py b/scripts/android_smoke.py new file mode 100644 index 0000000..7af7e95 --- /dev/null +++ b/scripts/android_smoke.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Bounded installed-APK smoke; no Telegram account or external network needed.""" +import json +import os +from pathlib import Path +import re +import socket +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + +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"} + + +def adb(*args, check=True, timeout=20): + 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): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(1) + raise AssertionError(f"Timed out: {description}") + + +def ui_dump(label): + # Dump first, then read the file; never tap coordinates inferred from a + # screenshot, a previous Activity, or assumed phone dimensions. + adb("shell", "rm", "-f", "/sdcard/tglock-ui.xml") + adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml") + text = adb("shell", "cat", "/sdcard/tglock-ui.xml") + (EVIDENCE / f"{label}.xml").write_text(text, encoding="utf-8") + return ET.fromstring(text) + + +def label_node(tree, label): + for node in tree.iter("node"): + if label in (node.get("text", ""), node.get("content-desc", "")): + bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", "")) + if bounds: + x1, y1, x2, y2 = map(int, bounds.groups()) + if x2 > x1 and y2 > y1: + return (x1 + x2) // 2, (y1 + y2) // 2 + return None + + +def tap_label(label, stage): + for attempt in range(3): + point = label_node(ui_dump(f"{stage}-{attempt}"), label) + if point is not None: + adb("shell", "input", "tap", str(point[0]), str(point[1])) + return + time.sleep(1) + raise AssertionError(f"Visible action not found: {label}") + + +def service_running(): + text = adb("shell", "dumpsys", "activity", "services", f"{PACKAGE}/.TunnelService") + (EVIDENCE / "services-last.txt").write_text(text, encoding="utf-8") + return "isForeground=true" in text + + +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: + peer.sendall(bytes([5, 1, 0])) + return peer.recv(2) == bytes([5, 0]) + except (OSError, TimeoutError): + return False + + +def launch(): + adb("shell", "am", "start", "-W", "-n", COMPONENT) + wait_for("Activity resumed", lambda: any( + "ResumedActivity" in line and PACKAGE in line + for line in adb("shell", "dumpsys", "activity", "activities").splitlines() + )) + assert adb("shell", "pidof", PACKAGE), "App process is absent" + + +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("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS") + adb("logcat", "-c") + adb("forward", "tcp:11080", "tcp:1080") + launch() + RESULT["launch"] = "passed" + deadline = time.monotonic() + 20 + tree = None + start_point = None + attempt = 0 + while time.monotonic() < deadline: + try: + tree = ui_dump(f"launched-{attempt}") + start_point = label_node(tree, "Включить защиту") + if start_point is not None: + break + except (subprocess.SubprocessError, ET.ParseError): + pass + attempt += 1 + time.sleep(1) + assert not service_running(), "Foreground service started without user action" + assert not proxy_ready(), "Proxy started without user action" + if tree is not None: + assert any( + node.get("class") == "android.webkit.WebView" + 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") + return + RESULT["lifecycle"] = "failed: lifecycle assertions incomplete" + tap_label("Включить защиту", "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) + assert service_running() and proxy_ready(), "Proxy stopped after backgrounding" + launch() + tap_label("Выключить", "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") + wait_for("Rust listener after restart", proxy_ready) + adb("shell", "am", "force-stop", PACKAGE) + launch() + assert not service_running() and not proxy_ready(), "Proxy silently restarted after force-stop" + RESULT["lifecycle"] = "passed: Start, background 5s, Stop, restart, force-stop" + + +try: + main() +except Exception as error: + RESULT["failure"] = str(error) + raise +finally: + try: + logs = adb("logcat", "-d", "-v", "threadtime") + (EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8") + crashes = adb("logcat", "-b", "crash", "-d") + (EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8") + if PACKAGE in crashes or f"ANR in {PACKAGE}" in logs or not adb("shell", "pidof", PACKAGE): + 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") + 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))