fix(client): read Android 16 foreground exactly

This commit is contained in:
QiuSW
2026-08-06 08:52:42 +08:00
parent ad80a7f23c
commit e18f7983fd
3 changed files with 197 additions and 39 deletions
@@ -14,6 +14,7 @@ sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.quantity_gate2_spike import ( from cmbuyer_client.pdd.quantity_gate2_spike import (
Android16TopResumedForegroundReader,
DECLARED_QUANTITIES, DECLARED_QUANTITIES,
EXPECTED_GOODS_ID, EXPECTED_GOODS_ID,
QuantityGate2EvidenceCapturer, QuantityGate2EvidenceCapturer,
@@ -84,12 +85,14 @@ def main(argv: list[str] | None = None) -> int:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr) print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2 return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
capturer = QuantityGate2EvidenceCapturer( capturer = QuantityGate2EvidenceCapturer(
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout), AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector( NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list, adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect, u2.connect,
), ),
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout, timeout_seconds=arguments.timeout,
) )
try: try:
@@ -10,6 +10,7 @@ import json
from math import isfinite from math import isfinite
import os import os
from pathlib import Path from pathlib import Path
import re
import shutil import shutil
from typing import Any, Protocol from typing import Any, Protocol
from uuid import uuid4 from uuid import uuid4
@@ -18,7 +19,7 @@ from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from uiautomator2.exceptions import HTTPTimeoutError from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection from ..device.adb import AdbClient, CommandRunner, DeviceConnectionError, DeviceInspection
from ..device.baseline import ( from ..device.baseline import (
HIERARCHY_PARAMS, HIERARCHY_PARAMS,
PDD_PACKAGE, PDD_PACKAGE,
@@ -52,13 +53,50 @@ class QuantityGate2ReadDevice(Protocol):
def app_info(self, package_name: str) -> dict[str, Any]: ... def app_info(self, package_name: str) -> dict[str, Any]: ...
def app_current(self) -> dict[str, Any]: ...
def window_size(self) -> tuple[int, int]: ... def window_size(self) -> tuple[int, int]: ...
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ... def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
class QuantityGate2ForegroundReader(Protocol):
"""只读取 Android 16 的唯一 resumed activity,不暴露通用 shell。"""
def read(self, serial: str) -> dict[str, str]: ...
class Android16TopResumedForegroundReader:
"""绕开 adbutils 2.12.0 对 Android 16 ``topResumedActivity`` 的误解析。"""
_TOP_RESUMED_PATTERN = re.compile(
r"(?m)^\s*topResumedActivity=ActivityRecord\{[^\r\n}]*?\s+u\d+\s+"
r"(?P<package>[^/\s]+)/(?P<activity>[^\s}]+)\s+t\d+\}\s*$"
)
def __init__(self, runner: CommandRunner, timeout_seconds: float) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._runner = runner
self._timeout_seconds = timeout_seconds
def read(self, serial: str) -> dict[str, str]:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise QuantityGate2EvidenceError("必须显式提供非空设备通道。")
result = self._runner.run(
("-s", serial, "shell", "dumpsys", "activity", "activities"),
self._timeout_seconds,
)
if result.returncode != 0:
raise QuantityGate2EvidenceError("Android 前台摘要读取失败,未发布证据。")
matches = list(self._TOP_RESUMED_PATTERN.finditer(result.stdout))
if len(matches) != 1:
raise QuantityGate2EvidenceError("Android 前台摘要不唯一,未发布证据。")
match = matches[0]
return {
"package": match.group("package"),
"activity": match.group("activity"),
}
@dataclass(frozen=True) @dataclass(frozen=True)
class QuantityGate2EvidenceResult: class QuantityGate2EvidenceResult:
output_directory: Path output_directory: Path
@@ -75,12 +113,14 @@ class QuantityGate2EvidenceCapturer:
self, self,
adb_client: AdbClient, adb_client: AdbClient,
connector: Callable[[str], QuantityGate2ReadDevice], connector: Callable[[str], QuantityGate2ReadDevice],
foreground_reader: QuantityGate2ForegroundReader,
timeout_seconds: float, timeout_seconds: float,
) -> None: ) -> None:
if not _is_positive_finite(timeout_seconds): if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值") raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._adb_client = adb_client self._adb_client = adb_client
self._connector = connector self._connector = connector
self._foreground_reader = foreground_reader
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
self._started = False self._started = False
@@ -104,7 +144,10 @@ class QuantityGate2EvidenceCapturer:
inspection = self._adb_client.inspect(serial) inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection) _require_expected_device(inspection)
device = self._connector(serial) device = self._connector(serial)
initial_app = _require_read_precondition(device) initial_app = _require_read_precondition(
device,
self._foreground_reader.read(serial),
)
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}" staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
@@ -132,7 +175,10 @@ class QuantityGate2EvidenceCapturer:
hierarchy_path = staging / "hierarchy.xml" hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8") hierarchy_path.write_text(hierarchy, encoding="utf-8")
final_app = _require_read_precondition(device) final_app = _require_read_precondition(
device,
self._foreground_reader.read(serial),
)
if final_app != initial_app: if final_app != initial_app:
raise QuantityGate2EvidenceError("数量状态取证期间前台页面漂移,未发布证据。") raise QuantityGate2EvidenceError("数量状态取证期间前台页面漂移,未发布证据。")
app_path = staging / "app.json" app_path = staging / "app.json"
@@ -216,12 +262,14 @@ def _require_expected_device(inspection: DeviceInspection) -> None:
raise QuantityGate2EvidenceError("设备不是已批准取证组合。") raise QuantityGate2EvidenceError("设备不是已批准取证组合。")
def _require_read_precondition(device: QuantityGate2ReadDevice) -> dict[str, str]: def _require_read_precondition(
device: QuantityGate2ReadDevice,
current: object,
) -> dict[str, str]:
info = device.app_info(PDD_PACKAGE) info = device.app_info(PDD_PACKAGE)
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
if version != EXPECTED_PDD_VERSION: if version != EXPECTED_PDD_VERSION:
raise QuantityGate2EvidenceError("拼多多版本不是已批准取证版本。") raise QuantityGate2EvidenceError("拼多多版本不是已批准取证版本。")
current = device.app_current()
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE: if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise QuantityGate2EvidenceError("拼多多不在前台。") raise QuantityGate2EvidenceError("拼多多不在前台。")
activity = current.get("activity") activity = current.get("activity")
+138 -31
View File
@@ -22,19 +22,25 @@ from PIL import Image
CLIENT_ROOT = Path(__file__).resolve().parents[2] CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src")) sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection from cmbuyer_client.device.adb import AdbDevice, CommandResult, DeviceInspection
from cmbuyer_client.pdd.quantity_gate2_spike import ( from cmbuyer_client.pdd.quantity_gate2_spike import (
Android16TopResumedForegroundReader,
DECLARED_QUANTITIES, DECLARED_QUANTITIES,
EXPECTED_GOODS_ID, EXPECTED_GOODS_ID,
QuantityGate2EvidenceCapturer, QuantityGate2EvidenceCapturer,
QuantityGate2EvidenceError, QuantityGate2EvidenceError,
QuantityGate2EvidenceTimeoutError, QuantityGate2EvidenceTimeoutError,
QuantityGate2ForegroundReader,
QuantityGate2ReadDevice, QuantityGate2ReadDevice,
) )
SERIAL = "192.168.0.173:5555" SERIAL = "192.168.0.173:5555"
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node text='local raw page' /></hierarchy>" HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node text='local raw page' /></hierarchy>"
TOP_RESUMED = (
" topResumedActivity=ActivityRecord{101034589 u0 "
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
)
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
@@ -63,25 +69,18 @@ class FakeReadDevice:
self, self,
*, *,
version: str = "8.17.0", version: str = "8.17.0",
package: str = "com.xunmeng.pinduoduo",
activity: str = "com.xunmeng.pinduoduo.activity.NewPageActivity",
screen_size: tuple[int, int] = (1080, 2376), screen_size: tuple[int, int] = (1080, 2376),
screenshot: object | None = None, screenshot: object | None = None,
hierarchy: object = HIERARCHY, hierarchy: object = HIERARCHY,
) -> None: ) -> None:
self.version = version self.version = version
self.package = package
self.activity = activity
self.screen_size = screen_size self.screen_size = screen_size
self.screenshot = _png_base64() if screenshot is None else screenshot self.screenshot = _png_base64() if screenshot is None else screenshot
self.hierarchy = hierarchy self.hierarchy = hierarchy
self.calls: list[tuple[object, ...]] = [] self.calls: list[tuple[object, ...]] = []
self.app_info_reads = 0 self.app_info_reads = 0
self.current_reads = 0
self.window_reads = 0 self.window_reads = 0
self.post_version: str | None = None self.post_version: str | None = None
self.post_package: str | None = None
self.post_activity: str | None = None
self.post_screen_size: tuple[int, int] | None = None self.post_screen_size: tuple[int, int] | None = None
def app_info(self, package_name: str) -> dict[str, str]: def app_info(self, package_name: str) -> dict[str, str]:
@@ -90,13 +89,6 @@ class FakeReadDevice:
version = self.post_version if self.app_info_reads > 1 and self.post_version is not None else self.version version = self.post_version if self.app_info_reads > 1 and self.post_version is not None else self.version
return {"versionName": version} return {"versionName": version}
def app_current(self) -> dict[str, str]:
self.calls.append(("app_current",))
self.current_reads += 1
package = self.post_package if self.current_reads > 1 and self.post_package is not None else self.package
activity = self.post_activity if self.current_reads > 1 and self.post_activity is not None else self.activity
return {"package": package, "activity": activity}
def window_size(self) -> tuple[int, int]: def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",)) self.calls.append(("window_size",))
self.window_reads += 1 self.window_reads += 1
@@ -113,6 +105,36 @@ class FakeReadDevice:
raise AssertionError(f"unexpected RPC {method}") raise AssertionError(f"unexpected RPC {method}")
class FakeForegroundReader:
def __init__(
self,
*,
package: str = "com.xunmeng.pinduoduo",
activity: str = ".activity.NewPageActivity",
) -> None:
self.package = package
self.activity = activity
self.post_package: str | None = None
self.post_activity: str | None = None
self.calls: list[str] = []
def read(self, serial: str) -> dict[str, str]:
self.calls.append(serial)
package = self.post_package if len(self.calls) > 1 and self.post_package is not None else self.package
activity = self.post_activity if len(self.calls) > 1 and self.post_activity is not None else self.activity
return {"package": package, "activity": activity}
class FakeCommandRunner:
def __init__(self, result: CommandResult) -> None:
self.result = result
self.calls: list[tuple[tuple[str, ...], float]] = []
def run(self, arguments: tuple[str, ...], timeout_seconds: float) -> CommandResult:
self.calls.append((arguments, timeout_seconds))
return self.result
def _load_script() -> object: def _load_script() -> object:
script_path = CLIENT_ROOT / "scripts" / "capture_quantity_gate2_spike.py" script_path = CLIENT_ROOT / "scripts" / "capture_quantity_gate2_spike.py"
spec = spec_from_file_location("capture_quantity_gate2_spike_for_test", script_path) spec = spec_from_file_location("capture_quantity_gate2_spike_for_test", script_path)
@@ -136,12 +158,71 @@ def _namespace(**changes: object) -> argparse.Namespace:
return argparse.Namespace(**values) return argparse.Namespace(**values)
class Android16TopResumedForegroundReaderTests(unittest.TestCase):
def test_reads_exact_unique_android16_top_resumed_activity(self) -> None:
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED))
reader = Android16TopResumedForegroundReader(runner, timeout_seconds=7)
self.assertEqual(
reader.read(SERIAL),
{
"package": "com.xunmeng.pinduoduo",
"activity": ".activity.NewPageActivity",
},
)
self.assertEqual(
runner.calls,
[
(
(
"-s",
SERIAL,
"shell",
"dumpsys",
"activity",
"activities",
),
7,
)
],
)
def test_missing_legacy_or_duplicate_top_resumed_activity_is_rejected(self) -> None:
rejected_outputs = (
"",
(
"mResumedActivity: ActivityRecord{101034589 u0 "
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
),
TOP_RESUMED + TOP_RESUMED,
"topResumedActivity=null\n",
)
for output in rejected_outputs:
with self.subTest(output=output):
reader = Android16TopResumedForegroundReader(
FakeCommandRunner(CommandResult(stdout=output)),
timeout_seconds=7,
)
with self.assertRaises(QuantityGate2EvidenceError):
reader.read(SERIAL)
def test_command_failure_and_invalid_serial_are_rejected(self) -> None:
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED, returncode=1))
reader = Android16TopResumedForegroundReader(runner, timeout_seconds=7)
with self.assertRaises(QuantityGate2EvidenceError):
reader.read(SERIAL)
with self.assertRaises(QuantityGate2EvidenceError):
reader.read(f" {SERIAL}")
self.assertEqual(len(runner.calls), 1)
class QuantityGate2EvidenceTests(unittest.TestCase): class QuantityGate2EvidenceTests(unittest.TestCase):
def _capturer( def _capturer(
self, self,
adb: FakeAdbClient, adb: FakeAdbClient,
device: FakeReadDevice, device: FakeReadDevice,
*, *,
foreground: FakeForegroundReader | None = None,
connector_calls: list[str] | None = None, connector_calls: list[str] | None = None,
) -> QuantityGate2EvidenceCapturer: ) -> QuantityGate2EvidenceCapturer:
def connect(serial: str) -> FakeReadDevice: def connect(serial: str) -> FakeReadDevice:
@@ -149,17 +230,28 @@ class QuantityGate2EvidenceTests(unittest.TestCase):
connector_calls.append(serial) connector_calls.append(serial)
return device return device
return QuantityGate2EvidenceCapturer(adb, connect, timeout_seconds=2) return QuantityGate2EvidenceCapturer(
adb,
connect,
foreground or FakeForegroundReader(),
timeout_seconds=2,
)
def test_initial_and_target_states_publish_only_raw_read_evidence(self) -> None: def test_initial_and_target_states_publish_only_raw_read_evidence(self) -> None:
for state, quantity in DECLARED_QUANTITIES.items(): for state, quantity in DECLARED_QUANTITIES.items():
with self.subTest(state=state), TemporaryDirectory() as temporary: with self.subTest(state=state), TemporaryDirectory() as temporary:
adb = FakeAdbClient() adb = FakeAdbClient()
device = FakeReadDevice() device = FakeReadDevice()
foreground = FakeForegroundReader()
connector_calls: list[str] = [] connector_calls: list[str] = []
target = Path(temporary) / state target = Path(temporary) / state
result = self._capturer(adb, device, connector_calls=connector_calls).capture( result = self._capturer(
adb,
device,
foreground=foreground,
connector_calls=connector_calls,
).capture(
SERIAL, SERIAL,
EXPECTED_GOODS_ID, EXPECTED_GOODS_ID,
state, state,
@@ -171,6 +263,7 @@ class QuantityGate2EvidenceTests(unittest.TestCase):
app = json.loads(result.app_path.read_text(encoding="utf-8")) app = json.loads(result.app_path.read_text(encoding="utf-8"))
self.assertEqual(adb.calls, [SERIAL]) self.assertEqual(adb.calls, [SERIAL])
self.assertEqual(connector_calls, [SERIAL]) self.assertEqual(connector_calls, [SERIAL])
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
self.assertEqual( self.assertEqual(
[call[1] for call in device.calls if call[0] == "jsonrpc"], [call[1] for call in device.calls if call[0] == "jsonrpc"],
["takeScreenshot", "dumpWindowHierarchy"], ["takeScreenshot", "dumpWindowHierarchy"],
@@ -236,18 +329,18 @@ class QuantityGate2EvidenceTests(unittest.TestCase):
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None: def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
scenarios = ( scenarios = (
(FakeAdbClient(model="OTHER"), FakeReadDevice()), (FakeAdbClient(model="OTHER"), FakeReadDevice(), FakeForegroundReader()),
(FakeAdbClient(android_version="15"), FakeReadDevice()), (FakeAdbClient(android_version="15"), FakeReadDevice(), FakeForegroundReader()),
(FakeAdbClient(), FakeReadDevice(version="8.17.1")), (FakeAdbClient(), FakeReadDevice(version="8.17.1"), FakeForegroundReader()),
(FakeAdbClient(), FakeReadDevice(package="com.example.other")), (FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(package="com.example.other")),
(FakeAdbClient(), FakeReadDevice(activity="")), (FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(activity="")),
(FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400))), (FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400)), FakeForegroundReader()),
) )
for adb, device in scenarios: for adb, device, foreground in scenarios:
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary: with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence" target = Path(temporary) / "evidence"
with self.assertRaises(QuantityGate2EvidenceError): with self.assertRaises(QuantityGate2EvidenceError):
self._capturer(adb, device).capture( self._capturer(adb, device, foreground=foreground).capture(
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
) )
self.assertFalse(target.exists()) self.assertFalse(target.exists())
@@ -295,18 +388,23 @@ class QuantityGate2EvidenceTests(unittest.TestCase):
def test_post_capture_app_or_screen_drift_does_not_publish(self) -> None: def test_post_capture_app_or_screen_drift_does_not_publish(self) -> None:
drift_mutators = ( drift_mutators = (
lambda device: setattr(device, "post_version", "8.17.1"), lambda device, foreground: setattr(device, "post_version", "8.17.1"),
lambda device: setattr(device, "post_package", "com.example.other"), lambda device, foreground: setattr(foreground, "post_package", "com.example.other"),
lambda device: setattr(device, "post_activity", "OtherActivity"), lambda device, foreground: setattr(foreground, "post_activity", "OtherActivity"),
lambda device: setattr(device, "post_screen_size", (1080, 2400)), lambda device, foreground: setattr(device, "post_screen_size", (1080, 2400)),
) )
for mutate in drift_mutators: for mutate in drift_mutators:
with TemporaryDirectory() as temporary: with TemporaryDirectory() as temporary:
device = FakeReadDevice() device = FakeReadDevice()
mutate(device) foreground = FakeForegroundReader()
mutate(device, foreground)
target = Path(temporary) / "evidence" target = Path(temporary) / "evidence"
with self.assertRaises(QuantityGate2EvidenceError): with self.assertRaises(QuantityGate2EvidenceError):
self._capturer(FakeAdbClient(), device).capture( self._capturer(
FakeAdbClient(),
device,
foreground=foreground,
).capture(
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
) )
self.assertFalse(target.exists()) self.assertFalse(target.exists())
@@ -336,6 +434,15 @@ class QuantityGate2EvidenceTests(unittest.TestCase):
"pay", "pay",
} }
self.assertTrue(forbidden.isdisjoint(QuantityGate2ReadDevice.__dict__)) self.assertTrue(forbidden.isdisjoint(QuantityGate2ReadDevice.__dict__))
self.assertTrue(forbidden.isdisjoint(QuantityGate2ForegroundReader.__dict__))
self.assertEqual(
{
name
for name in Android16TopResumedForegroundReader.__dict__
if not name.startswith("_")
},
{"read"},
)
self.assertEqual( self.assertEqual(
{name for name in QuantityGate2EvidenceCapturer.__dict__ if not name.startswith("_")}, {name for name in QuantityGate2EvidenceCapturer.__dict__ if not name.startswith("_")},
{"capture"}, {"capture"},