mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
1021 lines
40 KiB
Python
1021 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""ADB-driven mesh test orchestrator for two (or more) live devices.
|
|
|
|
Drives the debug-only TestHookReceiver in the app
|
|
(intent action: com.bitchat.droid.TEST_HOOK) to perform mesh operations:
|
|
peer scanning, connect, Noise handshake, DMs, file transfer, broadcast,
|
|
announce, and raw packet injection.
|
|
|
|
Each on-device command writes a JSON result to
|
|
cache/testhook/results/<id>.json inside the app sandbox; this module polls
|
|
for it via `run-as` and returns the parsed dict.
|
|
|
|
Typical usage:
|
|
python3 tools/release_gate/mesh_lab.py setup --serial-a X --serial-b Y --apk app/build/outputs/apk/debug/app-debug.apk
|
|
python3 tools/release_gate/mesh_lab.py scenario dm --serial-a X --serial-b Y
|
|
python3 tools/release_gate/mesh_lab.py scenario all --serial-a X --serial-b Y
|
|
python3 tools/release_gate/mesh_lab.py cmd --serial X scan --extra timeout_ms=30000
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import hashlib
|
|
import json
|
|
import random
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
|
if str(REPOSITORY_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPOSITORY_ROOT))
|
|
|
|
from tools.release_gate.android_lab import APPLICATION_ID, find_adb, run_adb
|
|
|
|
TEST_HOOK_ACTION = "com.bitchat.droid.TEST_HOOK"
|
|
TEST_HOOK_COMPONENT = f"{APPLICATION_ID}/com.bitchat.android.testhook.TestHookReceiver"
|
|
RESULTS_DIR = "cache/testhook/results"
|
|
DEVICE_TMP_DIR = "/data/local/tmp/meshlab"
|
|
APP_FIXTURE_DIR = f"/data/data/{APPLICATION_ID}/cache/fixtures"
|
|
|
|
WATCH_APPLICATION_ID = "com.bitchat.watch"
|
|
WATCH_TEST_HOOK_ACTION = "com.bitchat.watch.TEST_HOOK"
|
|
WATCH_TEST_HOOK_COMPONENT = f"{WATCH_APPLICATION_ID}/com.bitchat.watch.testhook.WearTestHookReceiver"
|
|
|
|
PERMISSIONS = [
|
|
"android.permission.BLUETOOTH_SCAN",
|
|
"android.permission.BLUETOOTH_CONNECT",
|
|
"android.permission.BLUETOOTH_ADVERTISE",
|
|
"android.permission.ACCESS_FINE_LOCATION",
|
|
"android.permission.ACCESS_COARSE_LOCATION",
|
|
"android.permission.POST_NOTIFICATIONS",
|
|
"android.permission.NEARBY_WIFI_DEVICES",
|
|
"android.permission.RECORD_AUDIO",
|
|
]
|
|
|
|
WATCH_PERMISSIONS = [
|
|
"android.permission.BLUETOOTH_SCAN",
|
|
"android.permission.BLUETOOTH_CONNECT",
|
|
"android.permission.BLUETOOTH_ADVERTISE",
|
|
"android.permission.POST_NOTIFICATIONS",
|
|
"android.permission.RECORD_AUDIO",
|
|
]
|
|
|
|
|
|
class MeshLabError(Exception):
|
|
pass
|
|
|
|
|
|
def _shell(serial: str, command: str) -> str:
|
|
return run_adb(serial, ["shell", command])
|
|
|
|
|
|
class Device:
|
|
"""One ADB-connected device running a debug build with the test hook."""
|
|
|
|
def __init__(
|
|
self,
|
|
serial: str,
|
|
alias: str,
|
|
package: str = APPLICATION_ID,
|
|
hook_action: str = TEST_HOOK_ACTION,
|
|
hook_component: str = TEST_HOOK_COMPONENT,
|
|
permissions: list[str] = PERMISSIONS,
|
|
activity_component: str = f"{APPLICATION_ID}/com.bitchat.android.MainActivity",
|
|
):
|
|
self.serial = serial
|
|
self.alias = alias
|
|
self.package = package
|
|
self.hook_action = hook_action
|
|
self.hook_component = hook_component
|
|
self.permissions = permissions
|
|
self.activity_component = activity_component
|
|
|
|
# -- app lifecycle ------------------------------------------------------
|
|
|
|
def install(self, apk: Path) -> None:
|
|
result = subprocess.run(
|
|
[find_adb(), "-s", self.serial, "install", "-r", "-g", str(apk)],
|
|
check=False, capture_output=True, text=True, timeout=300,
|
|
)
|
|
if result.returncode != 0 or "Success" not in result.stdout:
|
|
raise MeshLabError(f"[{self.alias}] install failed: {result.stdout} {result.stderr}")
|
|
|
|
def grant_permissions(self) -> None:
|
|
for perm in self.permissions:
|
|
subprocess.run(
|
|
[find_adb(), "-s", self.serial, "shell", "pm", "grant", self.package, perm],
|
|
check=False, capture_output=True, text=True, timeout=30,
|
|
)
|
|
|
|
def clear_app_data(self) -> None:
|
|
_shell(self.serial, f"am force-stop {self.package}")
|
|
output = _shell(self.serial, f"pm clear {self.package}")
|
|
if "Success" not in output:
|
|
raise MeshLabError(f"[{self.alias}] pm clear failed: {output}")
|
|
|
|
def force_stop(self) -> None:
|
|
_shell(self.serial, f"am force-stop {self.package}")
|
|
|
|
def launch(self) -> None:
|
|
"""Launch the app and verify it is actually top-resumed.
|
|
|
|
A background/cached process can be frozen by the system (observed on Wear OS),
|
|
which silently hangs test-hook commands; the foreground activity (and the FGS it
|
|
starts) keeps the process unfrozen.
|
|
"""
|
|
for _attempt in range(3):
|
|
_shell(self.serial, f"monkey -p {self.package} -c android.intent.category.LAUNCHER 1")
|
|
time.sleep(3)
|
|
try:
|
|
top = _shell(
|
|
self.serial,
|
|
"dumpsys activity activities | grep topResumedActivity",
|
|
)
|
|
if self.package in top:
|
|
return
|
|
except Exception:
|
|
pass
|
|
_shell(self.serial, f"am start -n {self.activity_component}")
|
|
time.sleep(3)
|
|
|
|
def wake(self) -> None:
|
|
"""Keep the screen on and the app foregrounded (full-power BLE duty cycle).
|
|
|
|
A backgrounded app drops to POWER_SAVER (1 s scan per 60 s), which makes
|
|
mesh reformation after restarts take minutes and scenarios flaky.
|
|
`svc power stayon` only applies while charging, so also stretch the
|
|
screen timeout as a fallback.
|
|
"""
|
|
_shell(self.serial, "svc power stayon true")
|
|
_shell(self.serial, "settings put system screen_off_timeout 600000")
|
|
subprocess.run(
|
|
[find_adb(), "-s", self.serial, "shell", "locksettings", "set-disabled", "true"],
|
|
check=False, capture_output=True, text=True, timeout=30,
|
|
)
|
|
_shell(self.serial, "input keyevent KEYCODE_WAKEUP")
|
|
_shell(self.serial, "wm dismiss-keyguard")
|
|
_shell(self.serial, "input keyevent 82") # dismiss non-secure keyguard
|
|
_shell(self.serial, "input swipe 500 1500 500 400") # swipe-up dismiss
|
|
|
|
def reset_bluetooth(self) -> None:
|
|
"""Cycle the BT adapter; clears zombie GATT connections from peer restarts."""
|
|
_shell(self.serial, "svc bluetooth disable")
|
|
time.sleep(2)
|
|
_shell(self.serial, "svc bluetooth enable")
|
|
time.sleep(3)
|
|
|
|
def enable_bluetooth(self) -> None:
|
|
subprocess.run(
|
|
[find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"],
|
|
check=False, capture_output=True, text=True, timeout=30,
|
|
)
|
|
|
|
# -- fixtures -----------------------------------------------------------
|
|
|
|
def push_fixture(self, local: Path, name: str | None = None) -> str:
|
|
"""Stage a fixture inside the app sandbox and return its app-readable path.
|
|
|
|
adb push lands files as shell:ext_data_rw, which the app cannot read
|
|
through the FUSE Android/data mount, so the bytes are piped through
|
|
the shell into the app's own cache directory via run-as.
|
|
"""
|
|
fname = name or local.name
|
|
tmp = f"{DEVICE_TMP_DIR}/{fname}"
|
|
_shell(self.serial, f"mkdir -p {DEVICE_TMP_DIR}")
|
|
result = subprocess.run(
|
|
[find_adb(), "-s", self.serial, "push", str(local), tmp],
|
|
check=False, capture_output=True, text=True, timeout=120,
|
|
)
|
|
if result.returncode != 0:
|
|
raise MeshLabError(f"[{self.alias}] push failed: {result.stderr}")
|
|
fixture_dir = f"/data/data/{self.package}/cache/fixtures"
|
|
target = f"{fixture_dir}/{fname}"
|
|
_shell(
|
|
self.serial,
|
|
f"run-as {self.package} mkdir -p {fixture_dir} && "
|
|
f"cat {tmp} | run-as {self.package} sh -c 'cat > {target}' && rm -f {tmp}",
|
|
)
|
|
return target
|
|
|
|
def clear_incoming(self) -> None:
|
|
_shell(
|
|
self.serial,
|
|
f"run-as {self.package} rm -rf cache/files/incoming cache/images/incoming",
|
|
)
|
|
|
|
# -- test hook commands -------------------------------------------------
|
|
|
|
def cmd(self, cmd: str, timeout_ms: int = 60_000, **extras: object) -> dict:
|
|
"""Send a test-hook command and poll for its JSON result."""
|
|
cmd_id = uuid.uuid4().hex[:12]
|
|
_shell(self.serial, f"run-as {self.package} rm -f {RESULTS_DIR}/{cmd_id}.json")
|
|
|
|
args = [
|
|
"am", "broadcast", "-a", self.hook_action,
|
|
"-n", self.hook_component,
|
|
"--es", "cmd", cmd,
|
|
"--es", "id", cmd_id,
|
|
"--el", "timeout_ms", str(timeout_ms),
|
|
"--el", "overall_timeout_ms", str(timeout_ms + 30_000),
|
|
]
|
|
for key, value in extras.items():
|
|
if value is None:
|
|
continue
|
|
if isinstance(value, bool):
|
|
args += ["--ez", key, "true" if value else "false"]
|
|
elif isinstance(value, int):
|
|
# `am` stores --el as Long and --ei as Integer; on-device
|
|
# readers use getIntExtra, so int extras must go via --ei.
|
|
args += ["--ei", key, str(value)]
|
|
else:
|
|
args += ["--es", key, str(value)]
|
|
try:
|
|
_shell(self.serial, " ".join(shlex.quote(a) for a in args))
|
|
except Exception as error:
|
|
# The shell occasionally hangs even though the broadcast was delivered;
|
|
# fall through to result polling, which is the authoritative channel.
|
|
print(f"[{self.alias}] warning: broadcast send for '{cmd}' raised: {error}", file=sys.stderr)
|
|
|
|
deadline = time.monotonic() + (timeout_ms + 60_000) / 1000
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
raw = _shell(self.serial, f"run-as {self.package} cat {RESULTS_DIR}/{cmd_id}.json")
|
|
if raw.strip().startswith("{"):
|
|
return json.loads(raw)
|
|
except Exception:
|
|
pass
|
|
time.sleep(1.0)
|
|
raise MeshLabError(f"[{self.alias}] timed out waiting for result of '{cmd}' ({cmd_id})")
|
|
|
|
def cmd_ok(self, cmd: str, timeout_ms: int = 60_000, **extras: object) -> dict:
|
|
result = self.cmd(cmd, timeout_ms=timeout_ms, **extras)
|
|
if result.get("status") != "ok":
|
|
raise MeshLabError(f"[{self.alias}] '{cmd}' failed: {result}")
|
|
return result
|
|
|
|
def logcat_dump(self, lines: int = 200) -> str:
|
|
return _shell(self.serial, f"logcat -d -t {lines}")
|
|
|
|
|
|
class WatchDevice(Device):
|
|
"""Pixel Watch running the com.bitchat.watch debug build.
|
|
|
|
Same test-hook protocol as the phone; different package/hook, a smaller permission
|
|
set (Bluetooth + notifications only), and wake tweaks that skip phone-only keyguard
|
|
commands. File-transfer scenarios are not supported on the watch yet (M5 deferred).
|
|
"""
|
|
|
|
def __init__(self, serial: str, alias: str = "watch"):
|
|
super().__init__(
|
|
serial,
|
|
alias,
|
|
package=WATCH_APPLICATION_ID,
|
|
hook_action=WATCH_TEST_HOOK_ACTION,
|
|
hook_component=WATCH_TEST_HOOK_COMPONENT,
|
|
permissions=WATCH_PERMISSIONS,
|
|
activity_component=f"{WATCH_APPLICATION_ID}/.MainActivity",
|
|
)
|
|
|
|
def wake(self) -> None:
|
|
# Keep the screen on while on the charging puck; otherwise Wear shows the
|
|
# charging activity on top, our app loses foreground, and the OS freezes the
|
|
# process (cached-app freezer), silently hanging test-hook commands.
|
|
_shell(self.serial, "settings put global stay_on_while_plugged_in 3")
|
|
_shell(self.serial, "svc power stayon true")
|
|
_shell(self.serial, "settings put system screen_off_timeout 600000")
|
|
_shell(self.serial, "input keyevent KEYCODE_WAKEUP")
|
|
|
|
|
|
# MARK: - fixtures
|
|
|
|
FIXTURE_SIZES = {
|
|
"small_1k.bin": 1_024,
|
|
"medium_512k.bin": 512 * 1_024,
|
|
"large_2m.bin": 2 * 1_024 * 1_024,
|
|
}
|
|
|
|
|
|
def make_fixtures(directory: Path, seed: int = 1337, names: list[str] | None = None) -> dict[str, dict]:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
fixtures = {}
|
|
rng = random.Random(seed)
|
|
for name, size in FIXTURE_SIZES.items():
|
|
if names is not None and name not in names:
|
|
rng.randbytes(size) # keep the stream deterministic across subsets
|
|
continue
|
|
path = directory / name
|
|
data = rng.randbytes(size)
|
|
path.write_bytes(data)
|
|
fixtures[name] = {"path": path, "sha256": hashlib.sha256(data).hexdigest(), "bytes": size}
|
|
return fixtures
|
|
|
|
|
|
def make_private_media_fixtures(directory: Path, seed: int = 7331) -> dict[str, dict]:
|
|
"""Small attachment fixtures covering every private-media UI type."""
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
fixtures = {}
|
|
rng = random.Random(seed)
|
|
for name, mime in (
|
|
("voice_note.m4a", "audio/mp4"),
|
|
("image_note.jpg", "image/jpeg"),
|
|
("document_note.txt", "text/plain"),
|
|
):
|
|
path = directory / name
|
|
data = rng.randbytes(1_024)
|
|
path.write_bytes(data)
|
|
fixtures[name] = {
|
|
"path": path,
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"bytes": len(data),
|
|
"mime": mime,
|
|
}
|
|
return fixtures
|
|
|
|
|
|
# MARK: - setup
|
|
|
|
def setup_pair(
|
|
a: Device,
|
|
b: Device,
|
|
apk_a: Path | None,
|
|
nickname_a: str,
|
|
nickname_b: str,
|
|
apk_b: Path | None = None,
|
|
) -> None:
|
|
if apk_b is None:
|
|
apk_b = apk_a
|
|
for device, nickname, apk in ((a, nickname_a, apk_a), (b, nickname_b, apk_b)):
|
|
device.reset_bluetooth()
|
|
device.enable_bluetooth()
|
|
if apk is not None:
|
|
device.install(apk)
|
|
device.clear_app_data()
|
|
device.grant_permissions()
|
|
device.wake()
|
|
device.launch()
|
|
device.cmd_ok("start")
|
|
device.cmd_ok("set_nickname", name=nickname)
|
|
wait_for_mutual_discovery(a, b)
|
|
|
|
|
|
def whoami(device: Device) -> dict:
|
|
return device.cmd_ok("whoami")
|
|
|
|
|
|
def wait_for_peer(device: Device, peer_id: str, timeout_s: int = 90) -> dict:
|
|
deadline = time.monotonic() + timeout_s
|
|
while time.monotonic() < deadline:
|
|
result = device.cmd_ok("peers")
|
|
for peer in result.get("peers", []):
|
|
if peer.get("id") == peer_id:
|
|
return peer
|
|
device.cmd_ok("announce")
|
|
time.sleep(3)
|
|
raise MeshLabError(f"[{device.alias}] peer {peer_id} not discovered within {timeout_s}s")
|
|
|
|
|
|
def wait_for_mutual_discovery(a: Device, b: Device) -> None:
|
|
id_a = whoami(a)["peer_id"]
|
|
id_b = whoami(b)["peer_id"]
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
fa = pool.submit(wait_for_peer, a, id_b)
|
|
fb = pool.submit(wait_for_peer, b, id_a)
|
|
fa.result()
|
|
fb.result()
|
|
|
|
|
|
# MARK: - scenarios
|
|
|
|
def scenario_dm(a: Device, b: Device) -> dict:
|
|
"""Handshake, then exchange DMs in both directions with content assertions."""
|
|
id_a = whoami(a)["peer_id"]
|
|
id_b = whoami(b)["peer_id"]
|
|
|
|
hs = a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b)
|
|
hs_back = b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a)
|
|
|
|
token_ab = f"dm-{uuid.uuid4().hex[:8]}"
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
recv = pool.submit(b.cmd_ok, "dm_recv", 60_000, peer=id_a, contains=token_ab)
|
|
time.sleep(2)
|
|
send = pool.submit(a.cmd_ok, "dm_send", 30_000, peer=id_b, content=f"hello b {token_ab}")
|
|
recv_result, send_result = recv.result(), send.result()
|
|
assert token_ab in recv_result["content"], recv_result
|
|
|
|
token_ba = f"dm-{uuid.uuid4().hex[:8]}"
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
recv = pool.submit(a.cmd_ok, "dm_recv", 60_000, peer=id_b, contains=token_ba)
|
|
time.sleep(2)
|
|
send = pool.submit(b.cmd_ok, "dm_send", 30_000, peer=id_a, content=f"hello a {token_ba}")
|
|
recv_result2, send_result2 = recv.result(), send.result()
|
|
assert token_ba in recv_result2["content"], recv_result2
|
|
|
|
return {
|
|
"handshake_a_to_b": hs, "handshake_b_to_a": hs_back,
|
|
"a_to_b": {"send": send_result, "recv": recv_result},
|
|
"b_to_a": {"send": send_result2, "recv": recv_result2},
|
|
}
|
|
|
|
|
|
def scenario_favorite_verification(a: Device, b: Device) -> dict:
|
|
"""Assert the three-state favorite exchange and local cryptographic verification."""
|
|
id_a = whoami(a)["peer_id"]
|
|
id_b = whoami(b)["peer_id"]
|
|
identity_a = whoami(a)["identity_fingerprint"]
|
|
|
|
a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b)
|
|
b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a)
|
|
|
|
def wait_for_status(
|
|
device: Device,
|
|
command: str,
|
|
peer_id: str,
|
|
predicate,
|
|
timeout_s: int = 30,
|
|
) -> dict:
|
|
deadline = time.monotonic() + timeout_s
|
|
last: dict = {}
|
|
while time.monotonic() < deadline:
|
|
last = device.cmd_ok(command, peer=peer_id)
|
|
if predicate(last):
|
|
return last
|
|
time.sleep(1)
|
|
raise MeshLabError(
|
|
f"[{device.alias}] {command} did not reach the expected state: {last}"
|
|
)
|
|
|
|
# Start from a known non-favorite relationship without clearing either app's data.
|
|
a.cmd_ok("favorite_set", peer=id_b, enabled=False)
|
|
b.cmd_ok("favorite_set", peer=id_a, enabled=False)
|
|
neutral_a = wait_for_status(
|
|
a,
|
|
"favorite_status",
|
|
id_b,
|
|
lambda state: not state["is_favorite"] and not state["they_favorited_us"],
|
|
)
|
|
neutral_b = wait_for_status(
|
|
b,
|
|
"favorite_status",
|
|
id_a,
|
|
lambda state: not state["is_favorite"] and not state["they_favorited_us"],
|
|
)
|
|
|
|
# A favorites B. B must show the orange outline while its own favorite remains false.
|
|
a.cmd_ok("favorite_set", peer=id_b, enabled=True)
|
|
received_only = wait_for_status(
|
|
b,
|
|
"favorite_status",
|
|
id_a,
|
|
lambda state: (
|
|
not state["is_favorite"]
|
|
and state["they_favorited_us"]
|
|
and state["star_state"] == "outlined_orange"
|
|
),
|
|
)
|
|
|
|
# B favorites back. Both relationships become mutual and B's star becomes filled.
|
|
b.cmd_ok("favorite_set", peer=id_a, enabled=True)
|
|
mutual_b = wait_for_status(
|
|
b,
|
|
"favorite_status",
|
|
id_a,
|
|
lambda state: state["is_mutual"] and state["star_state"] == "filled",
|
|
)
|
|
mutual_a = wait_for_status(
|
|
a,
|
|
"favorite_status",
|
|
id_b,
|
|
lambda state: state["is_mutual"] and state["star_state"] == "filled",
|
|
)
|
|
|
|
# The code B displays for A must be A's SHA-256 Noise identity fingerprint.
|
|
verification_before = b.cmd_ok("verification_status", peer=id_a)
|
|
if verification_before.get("fingerprint") != identity_a:
|
|
raise MeshLabError("displayed verification fingerprint does not match peer identity")
|
|
b.cmd_ok("verification_set", peer=id_a, enabled=True)
|
|
verification_after = wait_for_status(
|
|
b,
|
|
"verification_status",
|
|
id_a,
|
|
lambda state: state["verified"],
|
|
)
|
|
|
|
return {
|
|
"neutral": {"a": neutral_a, "b": neutral_b},
|
|
"received_favorite": received_only,
|
|
"mutual": {"a": mutual_a, "b": mutual_b},
|
|
"verification": {
|
|
"fingerprint_matches_peer_identity": True,
|
|
"before": verification_before,
|
|
"after": verification_after,
|
|
},
|
|
}
|
|
|
|
|
|
def scenario_broadcast(a: Device, b: Device) -> dict:
|
|
"""Public broadcast from A received by B."""
|
|
id_a = whoami(a)["peer_id"]
|
|
token = f"bc-{uuid.uuid4().hex[:8]}"
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
recv = pool.submit(b.cmd_ok, "msg_recv", 60_000, contains=token)
|
|
time.sleep(2)
|
|
send = pool.submit(a.cmd_ok, "broadcast_msg", 30_000, content=f"broadcast {token}")
|
|
recv_result, send_result = recv.result(), send.result()
|
|
assert recv_result["from"] == id_a, recv_result
|
|
return {"send": send_result, "recv": recv_result}
|
|
|
|
|
|
def _ptt_one_way(
|
|
sender: Device,
|
|
receiver: Device,
|
|
sender_id: str,
|
|
receiver_id: str,
|
|
scope: str,
|
|
) -> dict:
|
|
# Long enough to expose sustained GATT/codec backpressure while remaining a quick gate.
|
|
send_args: dict[str, object] = {"duration_ms": 3_000}
|
|
if scope == "dm":
|
|
send_args["peer"] = receiver_id
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
recv = pool.submit(
|
|
receiver.cmd_ok,
|
|
"ptt_recv",
|
|
240_000,
|
|
peer=sender_id,
|
|
scope="public" if scope == "public" else "dm",
|
|
)
|
|
time.sleep(2)
|
|
send = pool.submit(sender.cmd_ok, "ptt_send", 240_000, **send_args)
|
|
recv_result, send_result = recv.result(), send.result()
|
|
if not recv_result.get("live_observed") or recv_result.get("frames", 0) <= 0:
|
|
raise MeshLabError(f"live {scope} burst was not assembled before fallback: {recv_result}")
|
|
if recv_result.get("burst_id") != send_result.get("burst_id"):
|
|
raise MeshLabError(
|
|
f"live {scope} burst/final-note identity mismatch: send={send_result} recv={recv_result}"
|
|
)
|
|
encoded = int(send_result.get("encoded_frames", 0))
|
|
queued = int(send_result.get("queued_pcm_frames", 0))
|
|
sent_packets = int(send_result.get("data_packets", 0))
|
|
received_packets = int(recv_result.get("data_packets", 0))
|
|
expected_packets = int(recv_result.get("expected_packets", 0))
|
|
missing_packets = int(recv_result.get("missing_packets", -1))
|
|
received_frames = int(recv_result.get("frames", 0))
|
|
if int(send_result.get("dropped_oversize_frames", -1)) != 0:
|
|
raise MeshLabError(f"live {scope} encoder produced an unsent AAC frame: {send_result}")
|
|
# AAC encoders may emit up to two priming access units in addition to one unit per PCM block.
|
|
if queued <= 0 or encoded < queued or encoded > queued + 2 or sent_packets != encoded:
|
|
raise MeshLabError(f"live {scope} encoder/packetizer continuity failed: {send_result}")
|
|
outbound_packets = int(send_result.get("outbound_packets", 0))
|
|
delivered_packets = int(send_result.get("delivered_packets", -1))
|
|
if outbound_packets != delivered_packets or outbound_packets != sent_packets + 2:
|
|
raise MeshLabError(f"live {scope} network dispatch did not drain in order: {send_result}")
|
|
minimum_realtime_pcm_frames = int(3_000 / 64) - 2
|
|
if queued < minimum_realtime_pcm_frames:
|
|
raise MeshLabError(f"live {scope} capture fell behind real time: {send_result}")
|
|
if missing_packets != 0:
|
|
raise MeshLabError(f"live {scope} burst contained sequence gaps: {recv_result}")
|
|
if not (received_packets == expected_packets == sent_packets and received_frames == encoded):
|
|
raise MeshLabError(
|
|
f"live {scope} frame counts differ across the physical link: "
|
|
f"send={send_result} recv={recv_result}"
|
|
)
|
|
decoded_samples = int(recv_result.get("decoded_samples", 0))
|
|
if decoded_samples < max(1, received_frames - 2) * 1_024:
|
|
raise MeshLabError(f"live {scope} decoded PCM is truncated: {recv_result}")
|
|
rms = float(recv_result.get("rms", 0.0))
|
|
silent_fraction = float(recv_result.get("silent_block_fraction", 1.0))
|
|
longest_silent_run = int(recv_result.get("longest_silent_block_run", 999))
|
|
crossings_per_second = float(recv_result.get("zero_crossings_per_second", 0.0))
|
|
if rms < 0.05 or silent_fraction > 0.10 or longest_silent_run > 2:
|
|
raise MeshLabError(f"live {scope} decoded tone is silent or broken up: {recv_result}")
|
|
if not 650.0 <= crossings_per_second <= 1_150.0:
|
|
raise MeshLabError(f"live {scope} decoded tone continuity is distorted: {recv_result}")
|
|
return {"send": send_result, "recv": recv_result}
|
|
|
|
|
|
def scenario_ptt_dm(a: Device, b: Device) -> dict:
|
|
"""Gap-free Noise-encrypted PTT tone plus finalized note in both directions."""
|
|
id_a = whoami(a)["peer_id"]
|
|
id_b = whoami(b)["peer_id"]
|
|
a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b)
|
|
b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a)
|
|
return {
|
|
"a_to_b": _ptt_one_way(a, b, id_a, id_b, "dm"),
|
|
"b_to_a": _ptt_one_way(b, a, id_b, id_a, "dm"),
|
|
}
|
|
|
|
|
|
def scenario_ptt_broadcast(a: Device, b: Device) -> dict:
|
|
"""Gap-free signed public PTT tone plus finalized note in both directions."""
|
|
id_a = whoami(a)["peer_id"]
|
|
id_b = whoami(b)["peer_id"]
|
|
return {
|
|
"a_to_b": _ptt_one_way(a, b, id_a, id_b, "public"),
|
|
"b_to_a": _ptt_one_way(b, a, id_b, id_a, "public"),
|
|
}
|
|
|
|
|
|
def scenario_file(
|
|
a: Device,
|
|
b: Device,
|
|
fixtures: dict[str, dict],
|
|
private: bool = False,
|
|
recipient: str | None = None,
|
|
) -> dict:
|
|
"""File transfer A -> B with sha256 integrity verification."""
|
|
id_b = whoami(b)["peer_id"]
|
|
b.clear_incoming() # avoid name-uniquified collisions across runs
|
|
results = {}
|
|
for name, fixture in fixtures.items():
|
|
remote = a.push_fixture(fixture["path"])
|
|
send_kwargs: dict[str, object] = {"path": remote}
|
|
if private:
|
|
send_kwargs["peer"] = recipient or id_b
|
|
if fixture.get("mime"):
|
|
send_kwargs["mime"] = fixture["mime"]
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
recv = pool.submit(b.cmd_ok, "file_recv", 240_000, name_contains=name)
|
|
time.sleep(2)
|
|
send = pool.submit(a.cmd_ok, "file_send", 240_000, **send_kwargs)
|
|
recv_result, send_result = recv.result(), send.result()
|
|
digest_ok = recv_result["sha256"] == fixture["sha256"]
|
|
results[name] = {
|
|
"send": send_result, "recv": recv_result,
|
|
"expected_sha256": fixture["sha256"], "digest_match": digest_ok,
|
|
}
|
|
if not digest_ok:
|
|
raise MeshLabError(
|
|
f"file '{name}' digest mismatch: {recv_result['sha256']} != {fixture['sha256']}"
|
|
)
|
|
return results
|
|
|
|
|
|
def scenario_private_media(a: Device, b: Device) -> dict:
|
|
"""Voice, image, and generic file sends through the private-chat contact ID."""
|
|
identity = whoami(b)
|
|
noise_public_key = bytes.fromhex(identity["noise_public_key"])
|
|
conversation_id = f"contact_{hashlib.sha256(noise_public_key).hexdigest()}"
|
|
fixtures = make_private_media_fixtures(
|
|
Path(tempfile.mkdtemp(prefix="meshlab-private-media-"))
|
|
)
|
|
return scenario_file(
|
|
a,
|
|
b,
|
|
fixtures,
|
|
private=True,
|
|
recipient=conversation_id,
|
|
)
|
|
|
|
|
|
def scenario_raw(a: Device, b: Device) -> dict:
|
|
"""Raw packet injection (unsigned announce-type packet) reaches the mesh."""
|
|
payload = b"meshlab-raw-" + uuid.uuid4().hex[:8].encode()
|
|
result = a.cmd_ok("raw_send", 30_000, type="05", payload_hex=payload.hex())
|
|
return {"send": result}
|
|
|
|
|
|
# MARK: - session / identity churn scenarios
|
|
|
|
def _dm_roundtrip(a: Device, b: Device, id_a: str, id_b: str) -> dict:
|
|
"""Exchange DMs in both directions with content assertions."""
|
|
token_ab = f"dm-{uuid.uuid4().hex[:8]}"
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
recv = pool.submit(b.cmd_ok, "dm_recv", 60_000, peer=id_a, contains=token_ab)
|
|
time.sleep(2)
|
|
send = pool.submit(a.cmd_ok, "dm_send", 30_000, peer=id_b, content=f"hello b {token_ab}")
|
|
recv_ab, send_ab = recv.result(), send.result()
|
|
assert token_ab in recv_ab["content"], recv_ab
|
|
|
|
token_ba = f"dm-{uuid.uuid4().hex[:8]}"
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
|
recv = pool.submit(a.cmd_ok, "dm_recv", 60_000, peer=id_b, contains=token_ba)
|
|
time.sleep(2)
|
|
send = pool.submit(b.cmd_ok, "dm_send", 30_000, peer=id_a, content=f"hello a {token_ba}")
|
|
recv_ba, send_ba = recv.result(), send.result()
|
|
assert token_ba in recv_ba["content"], recv_ba
|
|
return {"a_to_b": recv_ab, "b_to_a": recv_ba}
|
|
|
|
|
|
def wait_session_established(device: Device, peer_id: str, timeout_s: int = 90) -> dict:
|
|
deadline = time.monotonic() + timeout_s
|
|
last: dict = {}
|
|
while time.monotonic() < deadline:
|
|
last = device.cmd_ok("session", peer=peer_id)
|
|
if last.get("established"):
|
|
return last
|
|
time.sleep(2)
|
|
raise MeshLabError(
|
|
f"[{device.alias}] session with {peer_id} not established within {timeout_s}s (last: {last})"
|
|
)
|
|
|
|
|
|
def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None:
|
|
"""Wait for rediscovery, then force a direct GATT connection both ways.
|
|
|
|
Backgrounded devices drop to POWER_SAVER duty cycles (1 s scan per 60 s), so
|
|
passively waiting for the mesh to reform takes minutes. The explicit connect
|
|
makes restart scenarios deterministic. The address↔peer mapping is learned from
|
|
direct-link announces and can lag peer-list discovery after a restart, so the
|
|
connect attempt is retried while the peer announces.
|
|
"""
|
|
wait_for_peer(a, id_b, timeout_s=120)
|
|
wait_for_peer(b, id_a, timeout_s=120)
|
|
for device, peer, announcer in ((a, id_b, b), (b, id_a, a)):
|
|
connected = False
|
|
last: dict = {}
|
|
for _attempt in range(4):
|
|
last = device.cmd("connect", timeout_ms=45_000, peer=peer)
|
|
if last.get("status") == "ok" and last.get("direct"):
|
|
connected = True
|
|
break
|
|
# Already acceptable if the mesh formed a direct link on its own.
|
|
peers = device.cmd_ok("peers").get("peers", [])
|
|
match = next((p for p in peers if p.get("id") == peer), None)
|
|
if match and match.get("direct"):
|
|
connected = True
|
|
break
|
|
try:
|
|
announcer.cmd_ok("announce")
|
|
except MeshLabError:
|
|
pass
|
|
time.sleep(4)
|
|
if not connected:
|
|
raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={last}")
|
|
|
|
|
|
def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict:
|
|
"""Retry explicit handshakes; inits can be lost while links settle."""
|
|
last: dict = {}
|
|
for _ in range(attempts):
|
|
last = device.cmd("handshake", timeout_ms=per_attempt_s * 1000, peer=peer_id)
|
|
if last.get("status") == "ok":
|
|
return last
|
|
time.sleep(2)
|
|
raise MeshLabError(f"[{device.alias}] handshake with {peer_id} failed after {attempts} attempts (last: {last})")
|
|
|
|
|
|
def scenario_session_recovery(a: Device, b: Device) -> dict:
|
|
"""Process death on B: identity must persist, in-memory Noise sessions are lost.
|
|
|
|
Expected recovery flow: A's DM sent with its stale session is dropped by B
|
|
(B has no session and no kick path on pure decrypt failure); B's outgoing DM
|
|
auto-triggers a fresh handshake; subsequent DMs must flow both ways.
|
|
"""
|
|
id_a = whoami(a)["peer_id"]
|
|
id_b = whoami(b)["peer_id"]
|
|
a.cmd_ok("handshake", 60_000, peer=id_b)
|
|
b.cmd_ok("handshake", 60_000, peer=id_a)
|
|
baseline = _dm_roundtrip(a, b, id_a, id_b)
|
|
|
|
b.force_stop()
|
|
b.wake()
|
|
b.launch()
|
|
b.cmd_ok("start")
|
|
b.cmd_ok("set_nickname", name="bob")
|
|
id_b_after = whoami(b)["peer_id"]
|
|
if id_b_after != id_b:
|
|
raise MeshLabError(f"identity changed across process death: {id_b} -> {id_b_after}")
|
|
|
|
wait_for_peer(a, id_b, timeout_s=120)
|
|
wait_for_peer(b, id_a, timeout_s=120)
|
|
ensure_direct_link(a, b, id_a, id_b)
|
|
|
|
# A -> B with A's stale session: B lost its in-memory session; drop expected.
|
|
a.cmd_ok("dm_send", 30_000, peer=id_b, content=f"stale-{uuid.uuid4().hex[:8]}")
|
|
# B -> A: no session on B, sendPrivateMessage auto-fires the re-handshake.
|
|
# The fire-and-forget handshake has no retry, so repeat the trigger, then
|
|
# fall back to explicit handshake commands if the auto-path stays stuck.
|
|
session_b: dict = {}
|
|
for _attempt in range(3):
|
|
b.cmd_ok("dm_send", 30_000, peer=id_a, content=f"trigger-{uuid.uuid4().hex[:8]}")
|
|
try:
|
|
session_b = wait_session_established(b, id_a, timeout_s=20)
|
|
break
|
|
except MeshLabError:
|
|
continue
|
|
if not session_b:
|
|
force_handshake(b, id_a)
|
|
session_b = wait_session_established(b, id_a, timeout_s=30)
|
|
|
|
session_a = wait_session_established(a, id_b)
|
|
recovered = _dm_roundtrip(a, b, id_a, id_b)
|
|
return {
|
|
"identity_preserved": True,
|
|
"baseline": baseline,
|
|
"session_a": session_a,
|
|
"session_b": session_b,
|
|
"recovered": recovered,
|
|
}
|
|
|
|
|
|
def scenario_identity_reset(a: Device, b: Device) -> dict:
|
|
"""pm clear on B mid-session: new identity, rediscovery, fresh handshake and DMs."""
|
|
id_a = whoami(a)["peer_id"]
|
|
id_b_old = whoami(b)["peer_id"]
|
|
a.cmd_ok("handshake", 60_000, peer=id_b_old)
|
|
b.cmd_ok("handshake", 60_000, peer=id_a)
|
|
_dm_roundtrip(a, b, id_a, id_b_old)
|
|
|
|
b.clear_app_data()
|
|
b.grant_permissions()
|
|
b.wake()
|
|
b.launch()
|
|
b.cmd_ok("start")
|
|
b.cmd_ok("set_nickname", name="bob")
|
|
id_b_new = whoami(b)["peer_id"]
|
|
if id_b_new == id_b_old:
|
|
raise MeshLabError("identity survived pm clear")
|
|
|
|
wait_for_peer(a, id_b_new, timeout_s=180)
|
|
ensure_direct_link(a, b, id_a, id_b_new)
|
|
force_handshake(a, id_b_new)
|
|
force_handshake(b, id_a)
|
|
recovered = _dm_roundtrip(a, b, id_a, id_b_new)
|
|
|
|
# Inspect how A treats the dead peer's stale session (evidence, not an assertion).
|
|
stale = a.cmd("session", peer=id_b_old)
|
|
return {
|
|
"old_peer_id": id_b_old,
|
|
"new_peer_id": id_b_new,
|
|
"identity_changed": True,
|
|
"recovered": recovered,
|
|
"stale_session_on_a": stale,
|
|
}
|
|
|
|
|
|
def scenario_file_oversize(a: Device, b: Device, fixtures: dict[str, dict]) -> dict:
|
|
"""Oversized broadcast file must be rejected sender-side (>256 fragments)."""
|
|
fixture = fixtures["medium_512k.bin"]
|
|
remote = a.push_fixture(fixture["path"])
|
|
send = a.cmd("file_send", timeout_ms=60_000, path=remote)
|
|
rejected = send.get("status") == "error" and "rejected" in send.get("error", "")
|
|
if not rejected:
|
|
raise MeshLabError(f"expected sender-side rejection, got: {send}")
|
|
# Receiver must not see any file appear.
|
|
recv = b.cmd("file_recv", timeout_ms=15_000, name_contains="medium_512k")
|
|
if recv.get("status") == "ok":
|
|
raise MeshLabError(f"receiver unexpectedly saved an oversized file: {recv}")
|
|
return {"send": send, "receiver_saw_file": False}
|
|
|
|
|
|
SCENARIOS = {
|
|
"dm": scenario_dm,
|
|
"favorite_verification": scenario_favorite_verification,
|
|
"broadcast": scenario_broadcast,
|
|
"ptt_dm": scenario_ptt_dm,
|
|
"ptt_broadcast": scenario_ptt_broadcast,
|
|
# Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only
|
|
# the small fixture is end-to-end receivable.
|
|
"file": lambda a, b: scenario_file(
|
|
a, b,
|
|
make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")), names=["small_1k.bin"]),
|
|
),
|
|
"file_oversize": lambda a, b: scenario_file_oversize(
|
|
a, b, make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")))
|
|
),
|
|
# Private media is hard-capped at 256 fragments (PrivateMediaTransfer), so only
|
|
# the small fixture fits; larger sizes are expected to be rejected by the sender.
|
|
"file_private": lambda a, b: scenario_file(
|
|
a, b,
|
|
make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")), names=["small_1k.bin"]),
|
|
private=True,
|
|
),
|
|
"media_private": scenario_private_media,
|
|
"raw": scenario_raw,
|
|
"session_recovery": scenario_session_recovery,
|
|
"identity_reset": scenario_identity_reset,
|
|
}
|
|
|
|
# Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends,
|
|
# the watch must receive with matching digests).
|
|
WATCH_SCENARIOS = [
|
|
"dm",
|
|
"favorite_verification",
|
|
"broadcast",
|
|
"ptt_dm",
|
|
"ptt_broadcast",
|
|
"raw",
|
|
"file",
|
|
"file_private",
|
|
"session_recovery",
|
|
"identity_reset",
|
|
]
|
|
|
|
|
|
def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
|
|
started = time.time()
|
|
evidence: dict[str, object] = {"scenario": name, "devices": [a.alias, b.alias]}
|
|
try:
|
|
supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS)
|
|
if name == "all":
|
|
results = {}
|
|
failures = []
|
|
for n in supported:
|
|
sub = run_scenario(n, a, b, out)
|
|
results[n] = sub.get("results", {"error": sub.get("error", "unknown")})
|
|
if sub["status"] != "pass":
|
|
failures.append(n)
|
|
evidence["results"] = results
|
|
if failures:
|
|
raise MeshLabError(f"sub-scenarios failed: {', '.join(failures)}")
|
|
elif name not in supported:
|
|
raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'")
|
|
else:
|
|
evidence["results"] = SCENARIOS[name](a, b)
|
|
evidence["status"] = "pass"
|
|
except (MeshLabError, AssertionError) as error:
|
|
evidence["status"] = "fail"
|
|
evidence["error"] = str(error)
|
|
evidence["logcat"] = {d.alias: d.logcat_dump() for d in (a, b)}
|
|
evidence["duration_s"] = round(time.time() - started, 1)
|
|
if out is not None:
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
(out / f"{name}-evidence.json").write_text(json.dumps(evidence, indent=2, default=str))
|
|
return evidence
|
|
|
|
|
|
# MARK: - CLI
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
|
|
setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover")
|
|
setup.add_argument("--serial-a", required=True)
|
|
setup.add_argument("--serial-b")
|
|
setup.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
|
|
setup.add_argument("--apk", type=Path, default=None)
|
|
setup.add_argument("--watch-apk", type=Path, default=None)
|
|
setup.add_argument("--nickname-a", default="alice")
|
|
setup.add_argument("--nickname-b", default="bob")
|
|
|
|
scenario = commands.add_parser("scenario", help="run a test scenario on two devices")
|
|
scenario.add_argument("name", choices=[*SCENARIOS.keys(), "all"])
|
|
scenario.add_argument("--serial-a", required=True)
|
|
scenario.add_argument("--serial-b")
|
|
scenario.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
|
|
scenario.add_argument("--out", type=Path, default=None, help="evidence output directory")
|
|
|
|
raw = commands.add_parser("cmd", help="send a raw test-hook command to one device")
|
|
raw.add_argument("--serial", required=True)
|
|
raw.add_argument("cmd")
|
|
raw.add_argument("--extra", action="append", default=[], help="key=value string extra (repeatable)")
|
|
raw.add_argument("--extra-int", action="append", default=[], help="key=value int extra (repeatable)")
|
|
raw.add_argument("--timeout-ms", type=int, default=60_000)
|
|
return parser
|
|
|
|
|
|
def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device]:
|
|
"""Device A is always the phone; device B is a watch when --serial-watch is given."""
|
|
a = Device(args.serial_a, "alpha")
|
|
if getattr(args, "serial_watch", None):
|
|
return a, WatchDevice(args.serial_watch)
|
|
if not getattr(args, "serial_b", None):
|
|
raise MeshLabError("either --serial-b or --serial-watch is required")
|
|
return a, Device(args.serial_b, "beta")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
if args.command == "setup":
|
|
a, b = _resolve_devices(args)
|
|
nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b
|
|
setup_pair(
|
|
a, b, args.apk, args.nickname_a, nickname_b,
|
|
apk_b=args.watch_apk if isinstance(b, WatchDevice) else None,
|
|
)
|
|
print(json.dumps({"status": "ok", "step": "setup"}))
|
|
elif args.command == "scenario":
|
|
a, b = _resolve_devices(args)
|
|
evidence = run_scenario(args.name, a, b, args.out)
|
|
print(json.dumps(evidence, indent=2, default=str))
|
|
return 0 if evidence["status"] == "pass" else 1
|
|
elif args.command == "cmd":
|
|
extras: dict[str, object] = {}
|
|
extras: dict[str, object] = {}
|
|
for item in args.extra:
|
|
key, _, value = item.partition("=")
|
|
extras[key] = value
|
|
for item in args.extra_int:
|
|
key, _, value = item.partition("=")
|
|
extras[key] = int(value)
|
|
result = Device(args.serial, "device").cmd(args.cmd, timeout_ms=args.timeout_ms, **extras)
|
|
print(json.dumps(result, indent=2, default=str))
|
|
return 0 if result.get("status") == "ok" else 1
|
|
return 0
|
|
except MeshLabError as error:
|
|
print(f"mesh lab error: {error}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|