mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 07:11:56 +03:00
test(android): verify installed APK and proxy lifecycle on emulator
This commit is contained in:
@@ -9,9 +9,25 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: android-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
apk:
|
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
|
runs-on: ubuntu-22.04
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
@@ -34,7 +50,10 @@ jobs:
|
|||||||
echo "NDK_HOME=$ANDROID_HOME/ndk/28.0.13004108" >> "$GITHUB_ENV"
|
echo "NDK_HOME=$ANDROID_HOME/ndk/28.0.13004108" >> "$GITHUB_ENV"
|
||||||
- uses: dtolnay/rust-toolchain@1.88.0
|
- uses: dtolnay/rust-toolchain@1.88.0
|
||||||
with:
|
with:
|
||||||
targets: aarch64-linux-android
|
targets: ${{ matrix.rust-target }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
key: android-${{ matrix.target }}
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
# Tauri regenerates only ignored machine-specific Gradle glue. The
|
# Tauri regenerates only ignored machine-specific Gradle glue. The
|
||||||
@@ -42,16 +61,50 @@ jobs:
|
|||||||
- name: Build installable APK with the bundled frontend
|
- name: Build installable APK with the bundled frontend
|
||||||
run: |
|
run: |
|
||||||
chmod +x gen/android/gradlew
|
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
|
- name: Check APK signature and packaged Rust engine
|
||||||
run: |
|
run: |
|
||||||
apk=$(find gen/android/app/build/outputs/apk -name '*.apk' -print -quit)
|
apk=$(find gen/android/app/build/outputs/apk -name '*.apk' -print -quit)
|
||||||
test -n "$apk"
|
test -n "$apk"
|
||||||
"$ANDROID_HOME/build-tools/36.0.0/apksigner" verify "$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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: tglock-android-arm64-debug
|
name: tglock-android-${{ matrix.arch }}-debug
|
||||||
path: gen/android/app/build/outputs/apk/**/*.apk
|
path: gen/android/app/build/outputs/apk/**/*.apk
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
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
|
||||||
|
|||||||
+76
-66
@@ -1,66 +1,76 @@
|
|||||||
# Android (experimental)
|
# Android (experimental)
|
||||||
|
|
||||||
Issue #9 is implemented as a Tauri Android application sharing the current Rust
|
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
|
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.
|
retained, but its stale engine and desktop code are not imported.
|
||||||
|
|
||||||
## Install a test APK
|
## Install a test APK
|
||||||
|
|
||||||
Open this PR's **Android APK** check, then the workflow run's **Artifacts** section.
|
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
|
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.
|
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
|
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.
|
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.
|
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
|
Different CI runs can use different debug signing keys: if Android rejects an
|
||||||
update because the signatures differ, uninstall the previous test build first.
|
update because the signatures differ, uninstall the previous test build first.
|
||||||
Uninstalling deletes settings and changes the proxy secret, so reconnect Telegram
|
Uninstalling deletes settings and changes the proxy secret, so reconnect Telegram
|
||||||
with the new link. A future production release needs a stable signing key.
|
with the new link. A future production release needs a stable signing key.
|
||||||
|
|
||||||
1. Open TGLock and press **Включить защиту**.
|
1. Open TGLock and press **Включить защиту**.
|
||||||
2. Accept the proxy in Telegram when prompted. If opening Telegram fails, return
|
2. Accept the proxy in Telegram when prompted. If opening Telegram fails, return
|
||||||
to TGLock and use **Открыть Telegram** or **Скопировать ссылку**.
|
to TGLock and use **Открыть Telegram** or **Скопировать ссылку**.
|
||||||
3. Keep LAN access off when Telegram runs on this same phone (`127.0.0.1`).
|
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 **Выключить**.
|
4. To stop, return through the ongoing notification and press **Выключить**.
|
||||||
|
|
||||||
## Lifecycle and limitations
|
## Lifecycle and limitations
|
||||||
|
|
||||||
The native foreground service starts only for an explicitly started proxy and
|
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
|
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
|
not stopped by Activity destruction or rotation. Android 14+ declares the
|
||||||
`specialUse` service type, its dedicated permission, and a subtype describing the
|
`specialUse` service type, its dedicated permission, and a subtype describing the
|
||||||
user-controlled local proxy. Notification permission denial does not prevent the
|
user-controlled local proxy. Notification permission denial does not prevent the
|
||||||
foreground service from running; Android still exposes it in its task manager.
|
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
|
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
|
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
|
TGLock and enable protection again. No boot receiver or automatic background
|
||||||
restart is installed. Vendor battery management and network changes may still
|
restart is installed. Vendor battery management and network changes may still
|
||||||
interrupt connections. The app does not claim to be a device-wide VPN.
|
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
|
The proxy secret lives in the app's private configuration directory. Desktop
|
||||||
installs migrate an existing valid legacy secret, preserving saved Telegram
|
installs migrate an existing valid legacy secret, preserving saved Telegram
|
||||||
links. The link-copy control intentionally contains this secret; the public
|
links. The link-copy control intentionally contains this secret; the public
|
||||||
report-copy control includes only counters, port, route and mode.
|
report-copy control includes only counters, port, route and mode.
|
||||||
|
|
||||||
## Build and verification
|
## Build and verification
|
||||||
|
|
||||||
CI uses Java 17, Android SDK 36, NDK 28, Rust 1.88, and the locked npm/Rust
|
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`
|
manifests. `npm run tauri -- android build --debug --apk --target aarch64 --ci`
|
||||||
bundles the frontend inside a signed APK; no development web server is required.
|
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.
|
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
|
||||||
Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's
|
Activity launch, process survival, and crash/ANR logs. When UIAutomator exposes
|
||||||
machine-specific generated glue, SDK paths, native build output and signing files
|
the WebView buttons, it also checks Start, five seconds in the background, Stop,
|
||||||
remain ignored. Run the same build command locally after installing Tauri's
|
restart, and explicit force-stop/relaunch. It verifies the foreground service and
|
||||||
[Android prerequisites](https://v2.tauri.app/start/prerequisites/#android).
|
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
|
||||||
No physical-phone or Telegram end-to-end test has been performed by this change.
|
lifecycle checks as skipped; a launch-only pass is not lifecycle evidence.
|
||||||
Before promoting it beyond an experimental APK, test Android 13 notification
|
`android-emulator-smoke-evidence` retains the exact result, UI dumps, service
|
||||||
permission grant/denial, Android 14+ service startup, Start/Stop/restart, switching
|
state and logcat. This does not test automatic low-memory eviction, battery
|
||||||
to Telegram for at least 10 minutes, rotation, Activity recreation, process death,
|
behavior, Telegram connectivity, or a physical phone.
|
||||||
Wi-Fi/mobile-data handover, and restoration with the same persisted secret.
|
|
||||||
|
Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's
|
||||||
Native bridging follows [Tauri mobile plugins](https://v2.tauri.app/develop/plugins/develop-mobile/)
|
machine-specific generated glue, SDK paths, native build output and signing files
|
||||||
and uses [Tauri opener](https://v2.tauri.app/plugin/opener/) for `tg://` links.
|
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.
|
||||||
|
|||||||
@@ -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))
|
||||||
Reference in New Issue
Block a user