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
+138 -31
View File
@@ -22,19 +22,25 @@ from PIL import Image
CLIENT_ROOT = Path(__file__).resolve().parents[2]
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 (
Android16TopResumedForegroundReader,
DECLARED_QUANTITIES,
EXPECTED_GOODS_ID,
QuantityGate2EvidenceCapturer,
QuantityGate2EvidenceError,
QuantityGate2EvidenceTimeoutError,
QuantityGate2ForegroundReader,
QuantityGate2ReadDevice,
)
SERIAL = "192.168.0.173:5555"
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)
@@ -63,25 +69,18 @@ class FakeReadDevice:
self,
*,
version: str = "8.17.0",
package: str = "com.xunmeng.pinduoduo",
activity: str = "com.xunmeng.pinduoduo.activity.NewPageActivity",
screen_size: tuple[int, int] = (1080, 2376),
screenshot: object | None = None,
hierarchy: object = HIERARCHY,
) -> None:
self.version = version
self.package = package
self.activity = activity
self.screen_size = screen_size
self.screenshot = _png_base64() if screenshot is None else screenshot
self.hierarchy = hierarchy
self.calls: list[tuple[object, ...]] = []
self.app_info_reads = 0
self.current_reads = 0
self.window_reads = 0
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
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
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]:
self.calls.append(("window_size",))
self.window_reads += 1
@@ -113,6 +105,36 @@ class FakeReadDevice:
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:
script_path = CLIENT_ROOT / "scripts" / "capture_quantity_gate2_spike.py"
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)
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):
def _capturer(
self,
adb: FakeAdbClient,
device: FakeReadDevice,
*,
foreground: FakeForegroundReader | None = None,
connector_calls: list[str] | None = None,
) -> QuantityGate2EvidenceCapturer:
def connect(serial: str) -> FakeReadDevice:
@@ -149,17 +230,28 @@ class QuantityGate2EvidenceTests(unittest.TestCase):
connector_calls.append(serial)
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:
for state, quantity in DECLARED_QUANTITIES.items():
with self.subTest(state=state), TemporaryDirectory() as temporary:
adb = FakeAdbClient()
device = FakeReadDevice()
foreground = FakeForegroundReader()
connector_calls: list[str] = []
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,
EXPECTED_GOODS_ID,
state,
@@ -171,6 +263,7 @@ class QuantityGate2EvidenceTests(unittest.TestCase):
app = json.loads(result.app_path.read_text(encoding="utf-8"))
self.assertEqual(adb.calls, [SERIAL])
self.assertEqual(connector_calls, [SERIAL])
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
self.assertEqual(
[call[1] for call in device.calls if call[0] == "jsonrpc"],
["takeScreenshot", "dumpWindowHierarchy"],
@@ -236,18 +329,18 @@ class QuantityGate2EvidenceTests(unittest.TestCase):
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
scenarios = (
(FakeAdbClient(model="OTHER"), FakeReadDevice()),
(FakeAdbClient(android_version="15"), FakeReadDevice()),
(FakeAdbClient(), FakeReadDevice(version="8.17.1")),
(FakeAdbClient(), FakeReadDevice(package="com.example.other")),
(FakeAdbClient(), FakeReadDevice(activity="")),
(FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400))),
(FakeAdbClient(model="OTHER"), FakeReadDevice(), FakeForegroundReader()),
(FakeAdbClient(android_version="15"), FakeReadDevice(), FakeForegroundReader()),
(FakeAdbClient(), FakeReadDevice(version="8.17.1"), FakeForegroundReader()),
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(package="com.example.other")),
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(activity="")),
(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:
target = Path(temporary) / "evidence"
with self.assertRaises(QuantityGate2EvidenceError):
self._capturer(adb, device).capture(
self._capturer(adb, device, foreground=foreground).capture(
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
)
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:
drift_mutators = (
lambda device: setattr(device, "post_version", "8.17.1"),
lambda device: setattr(device, "post_package", "com.example.other"),
lambda device: setattr(device, "post_activity", "OtherActivity"),
lambda device: setattr(device, "post_screen_size", (1080, 2400)),
lambda device, foreground: setattr(device, "post_version", "8.17.1"),
lambda device, foreground: setattr(foreground, "post_package", "com.example.other"),
lambda device, foreground: setattr(foreground, "post_activity", "OtherActivity"),
lambda device, foreground: setattr(device, "post_screen_size", (1080, 2400)),
)
for mutate in drift_mutators:
with TemporaryDirectory() as temporary:
device = FakeReadDevice()
mutate(device)
foreground = FakeForegroundReader()
mutate(device, foreground)
target = Path(temporary) / "evidence"
with self.assertRaises(QuantityGate2EvidenceError):
self._capturer(FakeAdbClient(), device).capture(
self._capturer(
FakeAdbClient(),
device,
foreground=foreground,
).capture(
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
)
self.assertFalse(target.exists())
@@ -336,6 +434,15 @@ class QuantityGate2EvidenceTests(unittest.TestCase):
"pay",
}
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(
{name for name in QuantityGate2EvidenceCapturer.__dict__ if not name.startswith("_")},
{"capture"},